Changelog
Format: Keep a Changelog.
Versioning: SemVer with pre-release identifiers per
PEP 440. While the API is pre-alpha, services
pin exactly (jfastframework==0.1.0a5); a compatible-release pin (~=) starts
making sense at 0.2.
Renumbering
The entries below were originally numbered 0.1.0 through 0.7.0. That numbering
overstated the maturity of the code. Nothing had been published at the time; the workspace
file format is about to change; the Redis queue backend does not implement the
visibility timeout its own contract documents; the RabbitMQ and Kafka backends have
never been run against a real broker.
The package therefore restarts at 0.1.0a1. The history is kept verbatim as a
development log — it records what was built and when — but those numbers were never
releases and were never installable. pip install jfastframework will not resolve a
pre-release without --pre, so the packaging tool enforces the warning rather than a
sentence in a README.
Subsystem-level maturity lives in STATUS.md, which is the file to read before depending on any single part of this.
[Unreleased]
[0.1.0a12] - 2026-09-30
What building a new SaaS from scratch on 0.1.0a11 ran into, fixed. None of it
was visible to the test suite, because each needed the path a new user takes:
an optional unique field, a production image, the generated frontend in a
browser, an upload, a migration run on the host, a model call from a worker.
One addition came with it: enums in --fields.
Fixed
- Security: with
authon, a subdomain, path or header granted a tenant by itself.current_tenantreturned the subdomain's tenant with nobody signed in, so with the plugin's defaultsources = ["token", "subdomain"]an anonymouscurl -H "Host: acme.localhost" localhost:8700/ticketslisted and created acme's rows -- theHostheader is the client's to choose -- and a signed-in user whose token carried no tenant was served as whichever tenant's subdomain they reached. An unsigned source now only names a tenant; the tenant granted to the request (onrequest.state.tenant_id, in the RLS session,TenantSession, jobs, events andcurrent_tenant) must be backed by the principal: no session is a 401, a token claim that disagrees is a 403, and a token with no tenant is a 403 unless the new[plugin.tenancy] trust_unscoped_principals = truesays the service checks membership itself. A signed source outranks an unsigned one wherever it sits insources. Sign-in keeps working on the subdomain: the named tenant is onrequest.state.tenant_requested, which/auth/login,/auth/registerand password reset read, and a public page reads it with the newrequested_tenantdependency. Withoutauth(a public site with a tenant per subdomain) the resolved tenant stays usable. Found building a multi-company help desk; every row of the rule is tested over HTTP, including that curl and its POST (401).jfast upgrade --check:unsigned-tenant-needs-a-session. - The Go scaffold had the same hole. Its
ResolveTenanttook the first source that yielded a tenant, so with auth on, an anonymous request toacme.<base_domain>gotTenantFrom= acme andRequireTenantlet it through. It now applies the Python rule: the named tenant (TenantRequestedFrom) is separate from the granted one; no session is a 401; a claim that disagrees is a 403; a token with no tenant is a 403 unlessJFAST_TENANCY_TRUST_UNSCOPED_PRINCIPALS=true; without auth, nothing changes.NewTenancynow takes the service's*Auth, so whether auth is on cannot be left out when wiring it.tests/test_go_service.pysends the same seven requests (host plus token) to a JFast app and to the compiled Go chain, with the opt-in on and off, and requires the same status and tenant from both.scripts/smoke_go.shruns the curl from the log against the built binary; the old template fails that step. JFAST_ENV=proddid nothing in a projectjfast startgenerated. The generator wrote[app] env = "local", andJFastConfig.loadpassed[app]to the settings as arguments, which beat environment variables -- so the switchdocs/deploy.md's checklist names was overridden by the file, and a production image ran with/docs,/openapi.json,/info(every plugin's settings) and/queue/statsopen, console mail "sent" to stdout and no HSTS, with nothing in the log.envanddebugdescribe the deployment, soJFAST_ENVandJFAST_DEBUGset in the process environment now win over[app] envanddebug-- and so does every other setting that depends on where the service runs (next entry). A.envfile read by the settings does not count (the generated.env.examplesaysJFAST_ENV=local, and a copied one must not turn a committedprodoff). A disagreement is a WARNING at boot.jfast start,jfast new serviceand the gateway no longer writeenvat all (the default islocal), and the Cloud Run script now setsJFAST_ENV=prodinstead ofproduction, which the settings refuse -- and which was only harmless while the file won. Verified on a generated project: withJFAST_ENV=prod,/docs,/openapi.json,/infoand/queue/statsare 404,/healthsaysprod, and console mail stops the boot.jfast upgrade --check:jfast-env-wins-over-the-file.JFAST_MAIL_BACKEND=smtp,JFAST_LLM_BUDGET_USDandJFAST_STORAGE_SERVE_LOCAL=falsewere ignored under ajfast.tomlthat set them. The same help desk hit it afterJFAST_ENV: every[plugin.x]table beat the environment, so a production deployment kept the file's console mail, its $10 cap and its Python file server, silently. The rule is now the environment wins for what depends on where the service runs; the file keeps winning for the service's shape, declared once injfastframework.deployment_keys.DEPLOYMENT_KEYSand read by the kernel and every tool:[app]env,debug,cors_origins,cors_origin_regex,trusted_hosts,trusted_proxies,root_path; the log level and format; mail backend, host, port, credentials, sender and TLS flags; the notifications backend; the LLM key,base_url, models and both caps; storageserve_localandsigning_key; authissuer,audience,jwks_urland keys; accountsfrontend_url; tenancybase_domain; every DSN and address a plugin connects to (database, cache, mongo, qdrant, rabbitmq, kafka, ollama, sentry, the OTLP endpoint) and each HTTP upstream'sbase_url. The full table, and why a storage disk's bucket or[app] portare not in it, is indocs/deploy.md, "Which wins". As forenv, only the process environment counts, never a.envfile the settings read. A test checks every row against the settings class that reads it. Every disagreement is visible, with credentials masked ('***', and a URL keeps its host but not its user and password): one WARNING per key at boot; ajfast checknotice withjfast.toml:<line>and the variable -- reported, never a failure,--ciincluded, since it describes the machine and not the repository; anenvironmentsection injfast ai context; and a paragraph in the generatedAGENTS.mdtelling an agent which keys the environment owns and to report a disagreement instead of editing either side. The generator no longer writes[plugin.auth] issuer = ""or[plugin.notifications] project_id = "", and every owned key it still writes (log format,serve_local,base_domain, the LLM caps, the dev CORS origins) names the variable that wins over it.jfast upgrade --check:jfast-env-wins-over-the-filenow lists every owned key a project set to a chosen value, plus the two lines 0.1.0a12 stopped writing.- The first
jfast devof a new project crashed: the API and the worker created the bootstrap administrator at once. Both saw no administrator and inserted theadminrole; the loser died onuq_jfast_roles_tenant_namewith a 400-line traceback andjfast devstopped everything. The production image, with one uvicorn worker per CPU, raced the same way. The bootstrap now holds a PostgreSQL advisory lock (serialize_setup, like the other startup work) until its transaction commits, so the next process waits and finds the administrator. Tested against PostgreSQL with six concurrent startups in one event loop and four separate processes started at the same instant: one administrator, one role, no failures (both failed before the fix). - CLI help dropped every
[section]it named. Rich reads[scaffold]as a style tag, so "Defaults to [scaffold] language." printed "Defaults to language." Escaped innew module,removeandtenancy enable; a test now walks all 61 commands' help. The same walk exposed that the bare-install test added in 0.1.0a11 checked the root command only: it now reaches every one. GET /queue/statsanswered anyone, in every environment. It lists the service's task names and queue depths without authentication. Unset,expose_statsnow follows/docs: on in development, closed in production; set it to choose.- The generated image could not write local storage, so it stopped at
boot. It runs as
appuser, butWORKDIRcreated/appas root and--chownonly reached the copied files. The Dockerfile now creates/app/storageand the default disks owned byappuser(a volume mounted on them starts out writable), and a disk that still cannot create its root says which line to add. Found by the compose smoke's new upload step, which runsjfast add storageon a generated project and uploads a file to the built image.jfast upgrade --check:image-cannot-write-local-storage. --uniqueon an optional field allowed one row without a value. The generated key wasNULLS NOT DISTINCTand the rule lookedNoneup, so the second receipt without a UUID answered 409. A key with a?field is now a partial unique index (WHERE field IS NOT NULL), the rule skips a missing value, and the generated tests cover it. Verified on PostgreSQL. Tables already created keep their constraint:jfast upgrade --checklists them (unique-key-on-optional-field) with the migration to write.- The
storageextra lackedpython-multipart. FastAPI refuses to import a route withUploadFilewithout it; development worked because thedevextra pulls it, and a production image built fromrequirements.txtdid not start. The compose smoke now uploads a file to that image. - The generated frontend could not call its API in development. Two
origins (:8610 and :8600) and no
cors_origins: the browser blocked the first request.jfast start,jfast new serviceandjfast workspace envadd the frontends' dev origins to the API's[app] cors_origins, never removing one somebody configured. - The generated API client turned uploads into JSON. It forced
Content-Type: application/json, and axios then serialised aFormDataas{"archivo":{}}. Removed; axios sends objects as JSON by itself. - The generated contract forbade
print()inscripts/too. The async rules already exemptedscripts/, andforbid_call printhad noexcept_in, so a project's own e2e script failedjfast contracts checkonce per line it printed. The rule now carriesexcept_in = ["scripts/**"]: a command someone runs in a terminal talks through stdout. Contracts already written keep their rule; add the line to[[rules.forbid_call]] pattern = "print"by hand. - Generated facades accepted
tenant_id=Nonein a multitenant service.public.py'sget_<module>(session, *, tenant_id: str | None, ...)passed the value to the repository, and None means no tenant filter: a task or another module holding a variable that happened to be None read every tenant's rows, and--multitenant-readyonly caught the literal. A module generated with tenant access (what tenancy implies) now takestenant_id: str. A single-tenant service keepsstr | None-- its rows are written with no tenant, so None is the only value that finds them -- with a comment saying so, andjfast check --multitenant-readyhas a new rule,facade-tenant-optional, that lists every facade signature admitting None (str | None,Optional[str],= None) as a step before the switch.jfast upgrade --check:facade-tenant-optional, for services with tenancy. -
jfast new enumleft the module's# None yet: jfast new enum ...comment above the enum it wrote. The placeholder is removed when the first enum lands. -
A
@taskor@subscribehandler could not reachllm,storageor the outbox. It received the payload and aTaskSession, nothing else, and the docs only showedrequest.app.state.jfast.require(...)-- which a worker does not have -- so every project kept its own global copy of the context. A parameter annotatedTaskContext(fromjfastframework.tasks; it isAppContext, and that annotation works too) now receives the running app's context, injfast workerand in the API alike. Verified with a realjfast workerprocess running a task and a subscriber that ask it for providers. jfast devannounced the frontend on :5173 while Vite ran on 8610. The generated dev script pins the workspace port andjfast devprinted Vite's default;--web-port 8610then ranvite --port 8610 --port 8610. The announced URL is now read from the frontend (its dev script, thenvite.config, then 5173), and--portis passed only when it changes something.- Only
jfast devcould use a workspace's.envon the host. It names the database by its compose name and leaves the password for compose to fill in, soalembic revision --autogenerate-- the stepjfast new moduleprints --jfast serve,jfast workerandpytestfailed with a DNS error.jfast serveandjfast workernow translate it asjfast devdoes when they find the compose file,jfast exec -- <command>runs anything else with it, and every printedalembicstep goes throughjfast exec --. A variable set in the shell wins; inside a container, and in the production image (which has no compose file to find), nothing changes.
What a help desk built from scratch on this release hit in production containers, fixed before it shipped:
- A local disk other than
publicandprivatewas not writable in the image. The Dockerfile created only the two default roots, so compose's volume on/app/storage/adjuntoswas root's:/ready503 and every upload a 500PermissionError, while/healthsaid 200.jfast deploy dockerfilenow reads[plugin.storage.disks]and creates and hands toappuserevery local root (and its parents);jfast add storagerewrites just that line in an existing Dockerfile, so an edited one keeps its edits. The compose volumes and the Dockerfile come from one resolver, which also stopped skipping a disk that relies on the defaultdriverorroot.image-cannot-write-local-storagenow names each root the Dockerfile does not create, and docs/storage.md says how tochowna volume Docker already created as root. Verified by building the image: the compose smoke declares its own disk and writes an upload to it; by hand, the old line reproduced the 503, and the documentedchownrepaired the existing volume. jfast workspace caddy --wildcard-tenantswrote a Caddyfile Caddy refuses.header_upsat at site level (unrecognized directive) and the container restarted in a loop. It is gone --reverse_proxyalready passes the Host header and setsX-Forwarded-Host, verified behindcaddy:2-alpine-- and so are on-demand TLSintervalandburst, which Caddy 2.11 no longer accepts, and the blank line that made every start warn the file was unformatted. A test runscaddy validateover every variant and shape (skipped without Docker), and requires no warnings.- Caddy served an empty site. The workspace compose mounted
./dist; the frontend builds into<frontend>/dist. It mounts that now, andjfast start's panel builds the SPA beforedocker compose up --buildinstead of promising "nothing else to install". The compose smoke asks the running Caddy for/, a client-side route and/api/health. jfast migration checkon the host reporteddatabase: unavailable. It read the workspace.envas is; it now translates it asserve,workerandexecdo. On a real PostgreSQL that also exposed a second cause: on a database nothing had migrated yet, the failed read ofalembic_versionaborted the transaction the row counts ran in.from jfastframework.auth import require_authfailedmypy --strict. The lazy__getattr__was typed-> object, soDepends(require_auth)was an error in every project that followed the docs. The names are declared for the type checker; the import stays lazy. The generatedruff.tomlalso letsDepends(require_scopes(...))through B008, which it flagged. The generated-quality smoke gates the documented imports.
Added
-
[app] cors_origin_regex, for origins a list cannot spell -- one per tenant subdomain, when the SPA signs users in at<tenant>.example.com. Passed to Starlette'sallow_origin_regex, matched against the whole origin, in addition tocors_origins; a pattern that does not compile stops the boot. Tested against a lookalike (evil-example.com) and a suffix (example.com.evil.com). Documented in docs/deploy.md, CORS. -
--fieldsknows enums:tipo:enum(personal,empresa,otra)(with?and=personallike any other type, and allowed in a--uniquekey). It writesclass CarteraTipo(StrEnum)into the module's enums file -- the shapejfast new enumwrites -- and uses it in the column, the Create/Update/Read models (any other value is a 422, and the generated tests say so), the domain entity and thepublic.pyDTO, in all four layouts. The column isEnum(native_enum=False)storing the member's value, plus a named CHECK built from the enum: autogenerate renders it as one column and oneCheckConstraint(withcreate_constraint=Trueit wrote the CHECK twice), it reads back as the enum, and SQLite creates the same table. Verified on PostgreSQL and SQLite: the rendered migration applies, a second autogenerate finds nothing, and a raw INSERT of another value is refused. Autogenerate does not compare CHECK constraints, so a member added later needs a hand-written migration (docs/modules.md says which).
[0.1.0a11] - 2026-09-30
Built from two real services on 0.1.0a10, Cuadra and Dictamen, and from what
they had to work around. The worst finding broke a rule this project had not
written down: the contracts told a module to publish an event instead of
calling another module, and in the default stack that event went nowhere --
outbox.publish answered 201 and the row retried until it died. So this
release is mostly about making the recommended path the one that works:
events that reach a subscriber without a broker, a worker that exists,
accounts a SaaS can open with, a switch from one customer to several that is
a command and not a hunt, a deadline on every call to something that can
hang, and generated code that passes its own checks.
For every breaking item below, run jfast upgrade --check: it lists the ones
that apply to your project, with file and line, and the remedy.
Breaking
-
A bare
X-Tenant-IDheader is no longer a tenant (security). The observability middleware copied it intorequest.state.tenant_idwhen nothing else had resolved one, andcurrent_tenant, the RLS session and everyJoborEventbuilt in the request trusted that value: withauthon andtenancyoff, an anonymous request carryingX-Tenant-ID: victimwas served as tenantvictim(a signed-in user could not switch: the token won). The header is now only a log field,tenant_claimed. A service behind a trusted gateway that sets it listsheaderin[plugin.tenancy] sources. Found while mirroring tenancy in the Go scaffold.jfast upgrade --check:tenant-header-not-a-tenant. -
outbox.publishrefuses an event nobody will receive. With no module@subscribed to the event type and no event bus configured, it raisesUndeliverableEvent-- a 500 whose detail names the fix, with nothing written -- instead of answering 201 and retrying the row until it died. Declare a subscriber (below) or enableevents. It fails in the request, not at boot: nothing checks declaredpublishesagainst subscribers at startup. Outbox(...)built by hand needsevents=<bus>to write event rows.contracts checkhas new rules.orphan-subscription(a subscriber to an event nobody publishes),undeclared-event(a module publishing a type its[modules.x] publishesdoes not list) andunused-dependency(adepends_onnothing uses, reported at itscontracts.tomlline and waivable there).undeclared-dependencynow coversJob(task="<another module's task>"), which also counts as an edge formodule-cycle: the hidden coupling Cuadra's workaround got through. Existing projects may fail until they declarepublishesand drop staledepends_onentries. Contracts generated before this release have no[layers.tasks]block, and in the screaming layout atasks.pythat importsuse_casesis reported until it is added.Worker.run()waits at mostdrain_timeout(25 s) on stop, then releases the jobs still running back to the queue without spending an attempt. Passdrain_timeout=for jobs that need longer at shutdown.POST /auth/loginmay answer an MFA challenge --{mfa_required | mfa_enrollment_required, mfa_token, expires_in}-- instead of a token pair whenmfa = true. Withemail_verification = "required",POST /auth/registeranswers 202 with no tokens, and existing users are unverified until you grandfather them (UPDATE jfast_users SET email_verified_at = created_at WHERE email_verified_at IS NULL).- Generated frontends are private by default (
PUBLIC_BY_DEFAULT = false) when a backend in the workspace enablesaccounts, and fetch/auth/accountafter signing in instead of reading the user from the login response. The two nexoraLoginViewoverrides are replaced by one sharedLoginViewinside a per-lookAuthShell. - Boot refuses misconfiguration in the
database,cache,storage,mail,authandqueuesettings, one test per rule. Among them:pool_size = 0(SQLAlchemy reads it as unlimited), an unknownsession_timezone, a tenant DSN template without{tenant}, a storagevisibilitythat is notpublicorprivate(a typo silently made a disk private),access_keywithoutsecret_key, a PostgreSQL queue name that is not an SQL identifier (it is interpolated into SQL),public_keymode withissue_tokens, algorithms PyJWT does not know, afrom_emailwithout@; in production, an httpjwks_url, an HMAC secret or signing key under 32 bytes and RabbitMQ'sguestdefault. - Redis commands time out after 1 s (
[plugin.cache] command_timeout). Raise it for long Lua scripts orSCANs. Blocking commands and pub/sub are exempt. - A lost database answers 503, not 500 or a hang. Connect failures,
dropped connections and a full pool raise
DatabaseUnavailableError; every other database error stays a 500. - Token revocation fails open by default. A Redis outage used to answer
500 on every authenticated request; now the token is accepted without the
revocation check, with a throttled warning and a degraded
/ready.revocation_fail_open = falseanswers 503 instead. This is a trade-off, not a free fix: during the outage a revoked token keeps working until it expires. jfast check --cifails a service withragon,tenancyoff andtenant_scopednot false (tenancy-rag-scoped-without-tenancy, medium). Plainjfast checkstill exits 0. Settenant_scoped = false, or runjfast tenancy enable.- Hexagonal modules no longer import FastAPI from their package init.
0.1.0a10's hexagonal template imported
CreatePayloadfrom the HTTP adapter at the top of__init__.py, so importing the domain pulled in FastAPI and the module's own generated test failed. The import is deferred through__getattr__; modules generated by 0.1.0a10 need the same change (hexagonal-eager-create-payload).
Added -- events and background work
- Local, durable domain events.
@subscribe("<type>")inmodules/<name>/tasks.py;outbox.publishqueues one job per subscriber in the publishing transaction, so an event exists if and only if its rows were committed. Job ids are deterministic per event and subscriber, so publishing twice queues each subscriber once. Subscribers match on event type, never topic; delivery is at least once, and a subscriber that takes aTaskSessionclaims the event in its own transaction -- one effect per subscriber. With a bus configured the event also goes to Kafka. @taskdeclared in the owning module (jfastframework.tasks), discovered frommodules/<name>/tasks.pyby the API and the worker alike; a brokentasks.pystops the boot.TaskSessionis a session on the primary database with the job's tenant (RLS-aware), committed on return and rolled back on error.idempotent_on=claims a key withclaim_oncein the handler's transaction.jfast workerboots the app's lifespan and runs every module's tasks and subscriptions. SIGTERM stops claiming and drains for--graceseconds (25); what is left is released without spending an attempt; a second signal releases at once.jfast devstarts it when the queue is on (--no-worker). Generated compose files (service and workspace) get a<svc>-workerservice and Kubernetes a<svc>-workerDeployment.jfast jobs deadandjfast jobs retry <id...> | --allfor the PostgreSQL and Redis queues. RabbitMQ gets a message pointing at its management UI.- Events in the contract.
[modules.x] publishes; subscriptions, task ownership andJob(task=...)references are read from the code.contracts show --json,CONTRACTS.mdandjfast ai contextlist publishers, subscribers, tasks and each module's facade functions. Job.traceandEvent.tracecarry W3C trace context next to the tenant and request id; job and event handler spans are consumer spans inside the trace of the request that created them.
Added -- telemetry
telemetryplugin (pip install "jfastframework[telemetry]"): OpenTelemetry traces over OTLP to Jaeger, Tempo, Honeycomb, Datadog or a Collector. Spans for every request (route template, never the raw path), every SQL statement (never bound parameters; statement text only withrecord_sql_statement = true), everyhttpclient and gateway call,llm.chat/llm.embedwith tokens and cost,rag.ingest/rag.searchwith counts, jobs and event handlers. Never prompt text, documents, answers, queries or bodies, enforced at the call sites and again in the backend, which drops any attribute that is not a small scalar.- Free until configured. With no
OTEL_EXPORTER_OTLP_ENDPOINTnothing is installed -- no middleware, no SQL listener, no tracer -- and OpenTelemetry need not even be installed. Measured in-process: 41 us per request without the plugin, 41 with it and no endpoint, 66 exporting to memory./readyreports the exporter and is never critical. - One trace across services. The
httpclient sendstraceparentandtracestateon every call inside a client span of its own; the gateway replaces the caller's header with its own span's, and relays it untouched when telemetry is off; incoming requests continue the caller's trace.sample_ratiodecides at the edge and every service behind follows. include_infra = trueadds an OpenTelemetry Collector and Jaeger to the generated compose -- one pair for a whole workspace.tracing.span(...)andtracing.inject()for your own spans and raw clients, free when telemetry is off; the provider is set as OpenTelemetry's global one when nothing set one first.
Added -- accounts
- Email verification (
email_verification = "off" | "optional" | "required") and password reset by email, which ends every session, access tokens included. Tokens are stored as SHA-256 hashes, single use, expiring. "Forgot" and "resend" answer 202 before the address is looked up, and inrequiredmode a taken address signs up with the same answer as a new one. - TOTP MFA (RFC 6238, standard library only) with recovery codes and
replay protection, enforceable per role (
mfa_required_roles) at sign-in, refresh and disable; the secret is encrypted at rest. A social sign-in of an account with MFA still gets the challenge. Wrong codes count toward both the MFA token's attempts and the account lockout. - Sign-in rate limited by default when
cacheis on: token buckets per IP, per account and for email requests, 429 withRetry-After. POST /auth/logout/all,GET /auth/features, and an admin MFA reset (DELETE /accounts/users/{id}/mfa). Deactivating a user ends their sessions at once. Errors a frontend must tell apart carry acode.- Framework tables gain new columns at startup (
ensure_columns), so a 0.1.0a10jfast_userstable upgrades in place. - Generated Vue and React frontends speak accounts: sign-in with the MFA
step, register, verify email, forgot and reset password, MFA enrolment and
a Security page;
VITE_API_TIMEOUT(60 s by default, for AI calls). With no backend enablingaccountsthe frontend stays public, with none of those pages. - The service refuses to boot with verification or reset on and no
mailplugin or nofrontend_url, withmfa_required_roleswhilemfais off, or withmfaon and noJFAST_ENCRYPTION_KEYS.
Added -- one customer today, several tomorrow
jfast checkgains a seventh check,tenancy: tenant settings that contradict each other or the code -- rag scoped with no tenancy, a per-tenant budget with no tenant,rls = truewith nothing setting the tenant, policies in revisions withrls = false,current_tenantwith no source, sources that can never resolve. Each finding names the fix.jfast check --multitenant-ready: what a switch to several customers would break, with file and line --tenant_id=None, routes and service factories with no tenant dependency, raw SQL, storage and cache keys, unscoped RAG, scheduled tasks, LLM calls. Heuristics, documented with their blind spots; a rule that cannot decide stays quiet. Waive with# contracts: allow <reason>;--json.jfast tenancy enable --tenant <id>: one reviewable Alembic revision (backfill, optional--not-null, tenant RLS, RAG chunks re-keyed) plus thejfast.tomledit with its comments kept, then the manual steps left.--dry-runwrites nothing. Verified end to end on a generated service: as a non-BYPASSRLSrole, PostgreSQL shows a second tenant none of the first tenant's rows and refuses its writes.[plugin.database] pgbouncer = truefor transaction pooling: no asyncpg or SQLAlchemy statement caches, unique statement names; tenant engines inherit it. RLS behind PgBouncer 1.25 in transaction mode is verified bytests/test_rls_pgbouncer.py: the tenant does not survive into the next client's transaction on the same backend, and fifty interleaved transactions from two tenants on one backend never see each other.jfast initasks whether the app serves several customers, and the answer sets every piece consistently: tenancy and its sources, auth,rag.tenant_scoped,llm.tenant_budget_usd, and generated routes oncurrent_tenant.jfast startis single-tenant by default;--multitenantgeneratesJFAST_AUTH_SECRETand a first-admin password into.envsojfast check --cipasses. Thetenant_idcolumns stay either way.
Added -- deadlines, breakers and failure drills
- Every external call has a deadline and a breaker by default, each a setting with its reason in the code. PostgreSQL: 10 s to connect (asyncpg's own is 60), a bounded 2 s pool ping that replaces SQLAlchemy's (which had no deadline and hung requests), a connect breaker. Redis: 2 s to connect, 1 s per command, a breaker. JWKS: 5 s for the whole attempt, one retry on 429, 5xx and network errors only, a breaker, and callers queued behind a failed fetch share its failure. S3/MinIO: configurable timeouts, retries and a breaker per disk; a 404 never counts.
/readynever fails on a non-critical plugin. Auth is degraded, not unavailable, while it serves cached JWKS keys; an object store is degraded; a local disk failure is critical.get_or_setfails open in one round trip when Redis is down.- Mail rejected with 5xx, oversized or malformed goes to the dead letters at once; timeouts and 4xx keep their retries.
- Failure drills,
tests/test_failure_drills.py: PostgreSQL and Redis paused (docker pause: the socket stays open and nothing answers) and the identity provider hung. Each checks the status inside its deadline, what/readynames, and recovery with no restart. The timings are indocs/resilience.md, new.
Added -- generated code that passes its own gates
- Generated services pass ruff, ruff format,
mypy --strictand pytest in every layout and form: each ships aruff.tomlnaming its rules (FastAPI'sDependsand friends allowed as default calls) and a strictmypy.ini.scripts/smoke_generated_quality.shgeneratesjfast startsingle- and multitenant andjfast new servicewith 19 plugins, one module per layout in each form, and gates them all. jfast new module --fields "..." --unique "..." --barein all four layouts: int, bigint,str(N), text, bool, float,decimal(P,S), money (integer minor units), date, timezone-aware datetime and json;?for nullable,=valuefor a default. Entity, models with matching limits, repository finders, uniqueness on create and update, domain rules, port and adapter,public.pyDTO, README and tests all come from the fields.--access open|auth|tenantpicks the route guard; its default followsjfast.toml.--ui htmxis refused with--fieldsor--bare.jfast initlists plugins from the catalog, so a new plugin cannot be left out again;telemetryandqueueare recommended and pre-checked.jfast start --no-telemetry.jfast add <plugin>andjfast remove <plugin>:addenables it and what it requires, pins the extra, appends its settings block and prints the env vars, keeping comments;removerefuses while another plugin requires it.- Modules get
tasks.pyand generated contracts ataskslayer. scripts/smoke_upgrade.sh: the previous release from PyPI, a project generated by it, this checkout's wheel over it, and exactly the change codes inscripts/smoke_upgrade.expectedfromjfast upgrade --check.JFAST_TEST_WINDOWS_CLOCK=1floorstime.monotonicto 1/64 s, as Windows does, for the whole suite. Against the pre-fix JWKS code it failed 16 of 30 runs; with it on, the concurrency suites passed 10 of 10.
Added -- Go services in a Python workspace
"Contract yes, framework no": the Go scaffold gets what a Go service needs to
sit next to Python ones -- still standard library only, all of it plain
net/http middleware that wraps a Gin, Echo or Chi engine as it is.
- Go services propagate trace context.
traceparent/tracestateare validated (W3C version 00; an invalid one is dropped with its state), logged astrace_id, and passed on unchanged withX-Request-IDbyjfast.Propagate/jfast.PropagatingTransport. No spans are created; the README showsotelhttpas the user's own choice. - Go services verify the workspace's JWTs and resolve the tenant, with the
Python plugins' variable names and rules:
JFAST_AUTH_*insecret(HS256/384/512) orpublic_key(RS256/384/512, ES256/384) mode, algorithms pinned by configuration,exp/iat/subrequired,nbf,issandaudchecked with the same 30 s leeway, refresh tokens refused as bearers, and the same boot-time refusals;JFAST_TENANCY_*sources in the same order of trust.RequireAuth,RequireScopes,RequireRoles(401/403) andRequireTenant(401 without a session, 403 without a tenant), withClaimsFrom(ctx)andTenantFrom(ctx); the tenant is in the access log. Off unless configured.jwksmode refuses to start and names a library. Revocation is not checked: a revoked access token works against a Go service until it expires (15 minutes by default); the service says so at every start. - The sample module partitions its store by tenant, and works unchanged with auth and tenancy off.
- The PostgreSQL queue's format is documented for other languages
(
docs/service-contract.md, "Consuming the queue from another language"): thejfast_jobscolumns and states, the claim, ack, retry, dead and release statements, the event envelope, and what a consumer must do to be safe. A documented format, not a supported client: there is no Go worker. tests/test_go_service.py: tokens minted by the auth plugin's ownTokenIssuer(HS256 and RS256) are accepted by the generated service's middleware under a real Go toolchain and resolve the same tenant a JFast app resolves; refresh and expired tokens get 401 on both sides; atraceparentreaches the Go service's outgoing call unchanged.scripts/smoke_go.shalso runs the binary with Python-minted tokens (tenant isolation,trace_idandtenant_idin the log,jwksrefused) and checksgofmt.
Added -- CI
- The
gojob also runs the cross-language test above. - Jobs for the generated-project gates, the upgrade smoke, the concurrency
suites on a Windows-grained clock, RLS behind a pinned PgBouncer
(
edoburu/pgbouncer:v1.25.2-p0), and spans exported over OTLP and read back from Jaeger (scripts/smoke_telemetry.sh); the performance budget against the change's base on the same runner; the S3 disk against MinIO; the multi-replica suite. The failure drills run last in the main job, pausing its own PostgreSQL and Redis. The suites for local events, the worker, dead letters, account flows, SQL spans and the tenancy switch are on the list that fails the build if they skip.
Changed
Eventmoved tojfastframework.events(still importable fromplugins.builtin.events) and inheritstenant_idandrequest_idfrom context, asJobdoes. Kafka@onhandlers run with the event's tenant, request id and trace.jfast inspect'smodule-cycleuses declareddepends_onas well as imports, ascontracts checkdoes; they disagreed.- The PostgreSQL queue adds a
tracecolumn at startup (ADD COLUMN IF NOT EXISTS; the app role needsALTERonjfast_jobs) and records the error of a failed job. - The accounts failure count is cleared only when a session is issued. A correct password used to reset it, so anyone holding the password got a fresh set of MFA guesses at every sign-in.
- The generated frontend's axios timeout is 60 s (
VITE_API_TIMEOUT). --with accountsalso enablesauth; the generator turns on whatever a chosen plugin requires.jfast new enumwritesStrEnum. Rewriting a service.envkeeps the keys the workspace graph does not own.- Quieter by default: storage's "no signing key" warning appears only in
production (
temporary_url()still fails with the fix), and auth's in-memory token store notice is info outside production. docs/modules.mdno longer recommendsoutbox.publishplus Kafka@onbetween modules -- the dead end -- butpublishesplus@subscribe.
Fixed
- Every process's startup took exclusive locks, and
/readyflapped. The generated image runs one uvicorn process per CPU, each running its plugins' startup. The queue ranALTER TABLE jfast_jobs ADD COLUMN IF NOT EXISTS traceat every boot, and pgvector its upgrade statements: both take an ACCESS EXCLUSIVE lock before noticing there is nothing to do, so a booting process queued behind any open read and every query -- another process's/readyincluded -- queued behind it (the compose smoke saw 200, then 503). Schema work now asks the catalogue first and runs only what is missing, under a transaction-scoped advisory lock, which also fixes concurrentCREATE TABLE IF NOT EXISTSfailing on an empty database when several processes boot at once.tests/test_startup_ddl.py. -
pgvector store writes went through a sequential scan. They matched a document's rows with
tenant_id IS NOT DISTINCT FROM, which no btree serves: one document's delete took 19.2 ms instead of 0.03 ms at 300k chunks, growing with the table. They usetenant_id = :tenant(orIS NULLfor an unscoped store);tests/test_rag_scale.pyasks the planner. -
Outbox
/readysaidokwhile messages failed to send. It is degraded from the first failed attempt and quotes the latest dead reason; the relay's log line includes the cause; a row that can never be delivered goes dead on its first attempt with its reason. - Workspace compose containers read the host's address. A service using
Kafka, RabbitMQ or MinIO got no
client_envinjfast workspace compose, fell back to its.env, and dialledlocalhost-- itself. Regenerate the compose file. Job(task="alerta.x")from another module passedcontracts checkwhen the task was registered in a rootworker.py(the 0.1.0a10 shape), so no@taskdeclared it. The task name's module prefix now names its owner. Found migrating Cuadra.--multitenant-readyreported raw SQL whose filter is interpolated afterWHERE/AND-- a repository helper that does filter by tenant. That is undecidable from the source, so the rule stays quiet. Cuadra had eight findings, all false.- FastAPI 0.142's own telemetry ran beside the plugin. On seeing
OTEL_EXPORTER_OTLP_ENDPOINTit configured a second global provider (unknown_service), duplicated every server span, and would have exported logs carrying exception messages and rejected input values.create_appturns it off whenever FastAPI has the switch; FastAPI's own spans are not recorded, by design. Found tracing Cuadra to Jaeger. jfast addreinstalled the previous release over the running one. Arequirements.txtstill pinned to 0.1.0a10 madejfast add telemetryrunpip install -rand put a10 -- which does not ship that extra -- over a11. pip is now skipped, with the command to run, when the pin differs from the running version or the install is editable. Found migrating Cuadra.- The S3 disk's
listing(limit=1500)returned 1,000. It now follows the continuation token. - Spanish docs linked three accented anchors the site strips; fixed, with the one in the new telemetry page.
Performance and scale, measured
Every number here is in docs/scaling.md, new, with the machine it was
measured on (an Apple M5 laptop with other suites running) and the script that
produces it.
- A performance budget.
scripts/bench_overhead.pydrives each app in-process and measures process CPU time per request as a ratio to bare FastAPI in the same run -- JFast's defaults 2.48x, auth + tenancy + metrics 5.43x -- andtests/test_performance_budget.py(JFAST_PERF_BUDGET=1) fails when a ratio grows more than 20 % over the baseline. A second test proves it has teeth: oneBaseHTTPMiddlewareput back, the 0.1.0a10 regression, fails it. jfast bench <url>: a step load test built from the service's OpenAPI. It reports req/s, p50/p95/p99 and errors per step, where the service breaks (--max-p99-ms,--max-error-rate) and where throughput stops growing, and any/readycheck that degraded;--k6exports the scenario,--jsonand--fail-on-breakare for CI. Its own generator tops out at 3,000-3,600 req/s; useabor k6 above that.- Framework routes are matched after the application's.
/health,/ready,/info,/metricsand the docs move behind the app's routes at startup, which saves 2-4 us of CPU per app request (measured; the plan's "~8 us" was not). An app route that would claim one of their paths, fully or with a 405, still does not: each is probed and put back in front of it.app.routesand/openapi.jsonlist the application's paths first. - The S3 disk is verified against MinIO (
tests/test_storage_minio.py): put, get, stat, listing, signed URLs fetched and then expired, presigned uploads, a three-part multipart stream and its abort, health. - Multi-replica guarantees, proven. Two apps and two workers against one
PostgreSQL and one Redis (
tests/test_multi_replica.py): 400 outbox messages relayed and consumed once each; scheduler ticks enqueued once on both tick stores; a $1.00 LLM cap across both replicas lets exactly 10 of 60 concurrent $0.10 calls through; a logout on one replica is refused on the other, and a refresh race across them rotates once. jfastframework.db.rollups.MonthlyRollup: pre-computed monthly totals per tenant, refreshed one bucket at a time from its rows, so an event handled twice is harmless; serialised on an advisory lock. On 3M rows, a 990k-row tenant's six-aggregate panel went from 455 ms to 3.25 ms at p50.- RAG at 300k chunks across 1,000 tenants (
scripts/bench_rag.py): vector search p50 2.54 ms, p99 6.92 ms; hybrid p50 2.96 ms. At that size the planner serves tenant-scoped queries from the tenant btree and an exact sort, so the 586 MB HNSW index serves none of them; with four large tenants it does, and recall@10 is 0.918 at the defaultef_search(0.950 at 200).
Not done, and named
- Not run for real: the Kafka path against a broker; local events over
the Redis or RabbitMQ queue (tested with a recording queue); the Kubernetes worker manifests
(parsed, never applied);
jfast devspawning the worker (stubbed); a RabbitMQ failure drill; S3 or MinIO (doubles only); SMTP (the 5xx/4xx split is tested againstsmtplibexceptions); a hosted tracing backend or TLS to the collector; the generated account pages in a browser; the interactivejfast initend to end;--not-nullapplied against PostgreSQL; read replicas behind PgBouncer under load. - Limits: a worker long-polling a paused Redis waits on the socket (BLMOVE
and pub/sub have no command deadline); an identity provider down with no
key ever cached still answers 401, not 503;
TaskSessionalways uses the primary database, not per-tenant ones; the worker has no health endpoint and does not reload underjfast dev; RabbitMQ has nojfast jobs; the frontend's social-login callback does not handle the MFA challenge; there is no QR code for MFA enrolment; changing a password does not end other sessions (a reset does); the generatedtasks.pyis a docstring. - Ten
jfast upgrade --checkdetectors have known bugs, each pinned by a strict xfail intests/test_upgrade_detectors.py(for exampleasync-dependenciesflags any call namedcurrent_tenant, andSKIP_DIRSis matched against absolute paths, so a project under a folder namedbuildis not read). - The upgrade smoke fails until
upgrades.pynameshexagonal-eager-create-payload, by design. - Drill timings were measured on one loaded laptop; a slower CI runner is untested. Only Python 3.12 was run locally for the generator gates.
- Scale, not yet measured: RAG at 1M chunks (all embeddings were
synthetic); the
abtable indocs/deploy.md(not re-measured on a loaded machine); a Linux baseline for the budget, which CI measures against the base branch instead -- a step not yet run in CI; the telemetry plugin as a budgeted scenario;jfast benchnaming the saturated dependency beyond/ready, and a mocked-model scenario. - Go, not verified: a Go service behind the gateway or in
jfast workspace composewith auth on (the values reach it only through its own.env, as for a Python service); ES256/ES384 against Python-minted tokens (only Go-minted); tokens from a real identity provider; a Go consumer of the queue table (only documented); the Gin/Echo/Chi wrapping shown in the docs (not compiled here, since the scaffold has no dependencies to try it with). The cross-language test and the extended smoke have run locally ingolang:1.23, not yet in CI. - Found, not fixed: a 405's problem+json response drops the
Allowheader. - The MinIO CI job runs a community fork (
pgsty/minio): MinIO stopped publishing images, and the suite was verified locally only against the last official one,RELEASE.2025-09-07T16-13-09Z. - Long AI work through the queue by default, tested recipes, and the RLS
tables in
jfast ai contextare not started.
[0.1.0a10] - 2026-09-29
Three things a project outgrows in its first month of real use: retrieval that mixed tenants, modules that talked to each other through raw SQL because the contracts forbade every other way, and a tenant model with no place for "each account is its own".
Breaking
- The rag store refuses to work without a tenant. A chunk's identity was
(document_id, chunk_index): two tenants with acontract-1overwrote each other, andsearch(tenant_id=None)searched every tenant. Identity is(tenant_id, document_id, chunk_index)now, every statement filters by tenant, and a tenant-scoped store -- the default -- raisesTenantRequiredError(403).tenant_scoped = falsefor a single-tenant service.ensure_schemaupgrades a 0.1.0a9 pgvector table in place and keeps its rows. - The rag router is off by default and authenticated when on. It checked
no token and took the tenant from the request body. Now
mount_router = truerequires theauthplugin, a signed-in caller, optionalread_scopes/write_scopes, and takes the tenant from the tenancy plugin or the token. - Qdrant point ids include the tenant. Collections written by 0.1.0a9 must be re-ingested.
- Modules talk through
modules/<name>/public.py, declared in[modules.<name>] depends_on. A module could not import another, and the advice was to move the thing toshared/-- right for an enum, wrong for behaviour -- so in practice modules read each other's tables with raw SQL no check could see. The facade returns DTOs and takes the caller's session and an explicittenant_id.contracts checkgainsundeclared-dependency,module-cycle,public-leak(an ORM entity or FastAPI crossing the facade),cross-module-sql(a string holding SQL against another module's table) andunknown-dependency;cross-modulenow also catches relative imports across modules and every name inimport a, b, and only suggestsshared/for enums and types.jfast upgrade --checklists the lines each rule reports in a project. A screaming contract from 0.1.0a9 needs the new[layers.public]block. require_auth,optional_auth,current_tenantandtenant_zoneareasync def, and so are the dependenciesrequire_scopes/require_rolesreturn. ThroughDepends(...)nothing changes; a direct call now returns a coroutine.principal_of(request)is the synchronous way to read the caller.jfast upgrade --checklists every direct call.- Metrics are labelled by route template. The middleware read the route
before routing had run, found none, and labelled by raw path: one series per
id (
/users/41,/users/42...), a registry that grew without bound under a scanner.endpointis now/users/{user_id}(prefix of included routers and mounts kept),<unmatched>when no route matched, andhttp_requests_in_progressis labelled bymethodonly. VectorStoreprotocol:delete_documentandsearchtaketenant_id; newexisting_hashes,sync_documentandsupports_hybrid. A custom store needs those methods.
Fixed
- JWKS single-flight on Windows. Fifty requests arriving on a cold cache
are meant to cause one fetch of the identity provider's keys. A waiter
decided "somebody already fetched" by comparing two readings of
time.monotonic(), which on Windows moves in ~15.6 ms steps: a fetch that finished in the tick it started looked like no fetch, and the next waiter fetched again (the Windows CI caught 2 fetches; a frozen clock gives 50). A generation counter bumped by each successful fetch replaces the clock, and a test with a frozen clock reproduces the failure on every OS.
Performance
Measured with ab on one uvicorn worker (table in docs/deploy.md#performance):
a service with auth, tenancy, metrics and logs went from 2,411 to 8,581
requests a second on the same endpoint; FastAPI with JWT and tenant written
by hand does 9,494. JFast with its default plugins went from 4,228 to 12,443.
- Every middleware is plain ASGI. Observability, metrics, auth, tenancy
and the read/write pin were
BaseHTTPMiddleware, which runs the app in a task group and streams the response through a memory channel: about 75 us of CPU per request each. Same behaviour, a wrappedsendinstead. - Framework dependencies and generated
get_servicefactories areasync def. FastAPI runs adefdependency in its threadpool; that hop cost 75-85 us per request, more than every middleware together. tests/test_performance_guards.pyfails if aBaseHTTPMiddlewareor a sync framework dependency comes back.docs/deploy.mdgains a Performance section with the numbers, what the rest costs, and the rules that keep your own code fast.
Added
public.pyin every generated module, for all four layouts, with a DTO and aget_<name>(session, *, tenant_id, <name>_id)wired through that layout's repository;jfast new modulealso appends[modules.<name>] depends_on = []tocontracts.toml.contracts show --json,CONTRACTS.md,contracts explainandcontracts diffknow about modules and their dependencies.AGENTS.mdand therespect-contractsskill tell an agent: another module's data through its facade, reactions through the outbox, never raw SQL on its tables,shared/for vocabulary only.ragservice (ctx.require("rag"),jfastframework.rag.RagService):ingest,search,delete, all tenant-scoped, andformat_contextfor numbered, citable excerpts. No FastAPI dependency, so a queue worker ingests exactly as a route does.- Re-ingest embeds only what changed. Each chunk carries a hash of its text and its embedder; unchanged chunks keep their vectors, a shortened document loses its tail, all in one transaction. A model change re-embeds instead of mixing vector spaces.
- HNSW instead of IVFFlat on pgvector. IVFFlat built on an empty table
learnt nothing and, with
probes = 1, returned one or two hits where eight were relevant.hnsw.iterative_scanon pgvector 0.8+, so a selective filter does not starve the result. - Hybrid search on pgvector: a generated
tsvectorcolumn with a GIN index and reciprocal rank fusion with the vector ranking.text_search_configpicks the dictionary (spanishstems "entregará" to match "entrega"). - Filters:
document_ids,where(exact match on metadata, GIN-indexed),min_score. - Structure-aware chunking (
chunk_strategy = "recursive", the default): headings, paragraphs, lines, sentences, words; a heading always opens a chunk.fixedkeeps the 0.1.0a9 windows. - Row-level security on the chunks table: every store transaction sets
jfast.tenant_id, soenable_tenant_rls(op, "rag_chunks")works. Verified with a non-superuser role. schema_sql()injfastframework.vectors.pgvector: the statementsensure_schemaruns, for an Alembic migration withauto_migrate = false.llmplugin andjfastframework.llm.LLMClient: chat, strict JSON schemas, images and embeddings over any OpenAI-compatible API (OpenAI, Azure, Ollama, vLLM, LiteLLM). A spending cap per service and per tenant, per month, day or in total, reserved atomically before a call and settled to the real cost -- concurrent calls cannot all see "under budget" and overshoot together. The ledger in Redis records purpose, model, tokens, cost and latency, never the prompt. Retries on 408/409/429/5xx honouringRetry-After. Prices in one table, overridable in[plugin.llm.prices]; unknown models are priced high.[plugin.rag] embedder = "llm"makes indexing spend from the same budget.- Tenancy source
user: the signed-in user's id is the tenant, for the SaaS where each account owns its data. Aftertoken, so joining an organisation moves a user to it without a code change. current_tenantroute dependency: the resolved tenant; 401 with no session (so an expired token gets refreshed), 403 when signed in but not scoped. Never a header or a body field.- Docs:
modules.md,contracts.mdandshared-and-events.mdexplain communication between modules with a worked example.docs/rag.mdanddocs/llm.md, new, in English and Spanish;multitenancy.mdgains theusersource,current_tenantand a table of every isolation layer and what it catches. - The CI's PostgreSQL is
pgvector/pgvector:pg16, so the rag suite runs there.
Changed
ragisalphain STATUS.md,llmenters atalpha.
[0.1.0a9] - 2026-09-28
A 201 has to mean the row exists.
The request's session committed in dependency teardown, and FastAPI runs a
yield dependency's teardown after the response has been sent. So a commit
that failed had already been answered with success, and a client that read its
own write straight away could get there before the commit did. Every
generated module wired its session that way.
Breaking
- The database plugin refuses to start while a route's session would commit
after the response.
DbSession,ReadSessionandTenantSessionare the three session dependencies withscope="function"applied, which commits when the endpoint returns and before the response exists; a failed commit is a 500 now.Depends(session_dependency, scope="function")works too. The refusal names each route.jfast upgrade --checklists the lines before the boot does. - FastAPI floor 0.121, the first release with
Depends(..., scope=...). Nothing below it can commit before the response. SQLAlchemy floor 2.0.16 forpostgresql_nulls_not_distinct.
Added
- Constraint violations are 409s.
BaseRepositoryturns a unique, foreign key or exclusion violation raised by its flush intoConflictError, instead of a driver exception that surfaced as a 500. VersionedMixinandPreconditionFailedError. Aversioncolumn checked by SQLAlchemy on everyUPDATE, so the second of two concurrent saves fails with a 409 instead of silently replacing the first; andupdate(expected_version=n), a 412 when the row has moved past the version the client read. It refuses to be listed afterTimestampMixin, where it would have been dropped without a word.get_for_update,advisory_lock,run_in_transaction. A row lock for read-modify-write; a transaction-scoped lock on any key, for rules one constraint cannot express; and a runner that retries a whole unit of work on serialisation failure or deadlock, and on nothing else.docs/transactions.md, in English and Spanish.
Added -- the rest of the transaction story
outboxplugin.outbox.enqueue(session, job)andoutbox.publish(session, topic, event)write through the request's session, so a message exists if and only if the rows it is about were committed. On the PostgreSQL queue in the same database the job goes straight intojfast_jobs; everything else goes throughjfast_outboxand a relay that runs in every process withFOR UPDATE SKIP LOCKED, backs off, and sets a message aside as dead aftermax_attempts.claim_once(session, id)is the consumer's half, andcurrent_job()gives a handler its job id to deduplicate on.idempotencyplugin.IdempotencyKey/RequiredIdempotencyKey: the key is recorded in the request's transaction, a retry replays the stored response withIdempotent-Replayed: true, a different body with the same key is 422, and a concurrent duplicate waits on the first insert and gets 409 or the replay. Per tenant, expiring afterttl_hours.- Row-level security.
enable_tenant_rls(op, table)in a migration and[plugin.database] rls = true: every transaction sets its tenant with a transaction-localset_config, a query with no tenant sees no rows, and a write for another tenant is refused by PostgreSQL.bypass_rls()for work across tenants, on tables that allow it. Production refuses to start with RLS on under a superuser orBYPASSRLSrole, which ignore every policy. accountsplugin. The user storeauthleaves out: users, argon2id password login, lockout after repeated failures, roles and permissions that travel as token scopes (require_permission), per-tenant administration under/accounts, a bootstrap administrator, andauth'son_refreshandon_identityhooks registered for you -- so a removed permission or a deactivated account ends at the next refresh, and a provider identity links to an account only through a verified email.
Changed
- Generated modules. Every layout depends on
DbSession.limitis bounded to 1-200 andoffsetto 0 and up:?limit=-1was a 500 and?limit=10000000a table dump. Thenamethe service checks for duplicates is nowUniqueConstraint("tenant_id", "name", postgresql_nulls_not_distinct=True)-- two requests could both pass the check.layeredmodules are versioned:Readreturnsversion,Updateaccepts it, and the list sendsX-Total-Count.
Fixed
- Jobs ran with no tenant.
Job.tenant_idandrequest_idexisted and nothing filled them, so every handler ran unscoped and its repositories read every tenant's rows. A job built inside a request takes both from context, and the worker restores them around the handler. jfast checkacceptsTenantSessionas opening a tenant's database; it only looked fortenant_session_dependency.- Documentation that contradicted itself. The README named
0.1.0a5; STATUS said installing needs--pre, which it does not while only pre-releases exist; STATUS said there were no upper bounds, which0.1.0a8added; CI said the package was not on PyPI.
Fixed -- framework tables and autogenerate
alembic revision --autogenerateproposed droppingjfast_jobs. The queue creates its table at startup and it is not among the service's models, so autogenerate read it as a table the service had deleted. Every framework-owned table isjfast_*now, and the generatedenv.pypassesinclude_nameto skip them.jfast upgrade --checknames anenv.pywithout it.
Changed -- the CLI
cli/main.pywent from 2,786 lines to 90. The commands moved tocli/commands/by responsibility, withregister(app)like the existingmigrationsandcheckmodules; shared generation helpers live incli/generate.pyso no command module imports another. Command names, options, help text and order are unchanged -- the--helpoutput of the root and all 52 subcommands is identical before and after.
Added -- what a real service needed
Found by rebuilding an invoicing service (E-Cont) on the framework: each of these it had written itself, and each one it got wrong in the same place.
@transactional, for session dependencies of your own. The startup check only knew the framework's three session dependencies, so a service's ownyielddependency -- used on 120 routes in that rebuild -- committed after the response and nothing said so. Marked, it is held to the same rule: a route that depends on it withoutscope="function"stops the service from starting. Unmarked generator dependencies that call.commit(after theiryieldare named in a startup warning.- Row-level security on more than the tenant.
@transaction_setting("app.companies")registers a value that every tenant-scoped transaction sets next to the tenant, andenable_rls_policy(op, table, predicate=...)writes a policy that reads it. Setting names are validated, the framework's own are refused, andNoneleaves the setting unset -- no rows. - Uploads that do not fit in memory.
Disk.put_stream(key, chunks)on the local and S3 drivers -- a temporary file renamed into place, or a multipart upload aborted on failure -- andguard_stream, which runs a disk'svalidatestep on the first 64 KiB and enforcesmax_bytesas the chunks arrive. - XML, JSON, CSV and text are recognised by parsing. They have no magic
bytes, so
validate.allowcould not name them. XML with aDOCTYPEor anENTITYis refused before it is parsed, and an SVG or HTML root is not accepted asapplication/xml. jfastframework.encryption. AES-256-GCM for values the service must read back, on a column (EncryptedString(context=...)) or by hand (SecretBox). The context is authenticated, so a value copied to another row does not decrypt; keys come fromJFAST_ENCRYPTION_KEYSand rotate without a migration.encryptionextra.- Spanish table names.
[scaffold] language = "es"injfast.toml, or--language es:camion→camiones,orden_compra→ordenes_compra.
Added -- recurring tasks, delays on RabbitMQ, calling other services
- Recurring tasks.
@tasks.task(name, every=... | cron=..., timezone=...)andtasks.schedule(...), run by a scheduler loop inside the service when[plugin.queue] scheduler = true. It is safe in every replica and worker because each tick is claimed first -- injfast_schedule_ticks(PostgreSQL, in the same transaction as the job on the PostgreSQL queue) or with RedisSET NX-- and each tick's job has a deterministic id. After downtime the most recent missed tick fires once, never a burst. Cron is a built-in five-field parser with Vixie day semantics and explicit DST rules. - The RabbitMQ queue honours
Job(available_at=...)and retry backoff, through a binary cascade of fixed-TTL queues with no broker plugin, so a long delay no longer blocks a short one behind it. It published every job at once before. jfastframework.httpand thehttpplugin. A client for sibling services with mandatory timeouts and a total deadline; retries only for idempotent orIdempotency-Keyrequests, with full-jitter backoff,Retry-Afterand a retry budget; a per-upstream circuit breaker and bulkhead that fail fast with a 503 problem;X-Request-IDand opt-in bearer-token propagation.httpextra.
Fixed -- RabbitMQ
stats()always reported zero. aio-pika's robust channel hands back the queue object cached at declaration, with its original message count; it asks the raw channel now.
Added -- frontends come in looks, and Nexora is the default
--template nexora|classiconjfast new service --kind spa,jfast startandjfast init(which asks).nexorais the new default: the liquid-glass design system the docs site is built on -- glass panels and sidebar, an island top bar, light and dark, a sign-in screen and a small dashboard of real values (/health, its round trip, the registered views). A WebGL ribbon (three.js, its own lazy chunk, off under reduced motion and without WebGL, paused in hidden tabs).classicis exactly the previous frontend.- An accent picker. Six presets and a custom colour, next to the theme
toggle and on the sign-in page. The whole palette is derived from the one
colour, with text shades that reach 4.5:1 contrast; the choice is kept per
app and applied before first paint.
VITE_ACCENTsets the project's default. - The brand is the project's name (
VITE_APP_NAME),jfastframeworkwhen it is empty. - 3D, 2D or no background, in the same popover: the ribbon, its still
frame without downloading three.js, or the plain page colour. Remembered per
app, applied before first paint;
VITE_BACKGROUNDsets the project default. - The menu button folds the sidebar on a wide screen, and it stays folded
across reloads. It used to be visible there and do nothing: the rule hiding
it lost to
.nx-round-btn, and its click only drove the phone drawer. - JFast Suite as a reference. With
--agent-docs, a nexora frontend gets thenexora-referenceskill: a copy of the suite's pages (about 1 MB, the images as WebP) and how to bring a pattern from them into the project. Templates may now carry files that are not.j2; they are copied byte for byte. - The look is recorded in
.jfast-template, andjfast new viewdraws new pages in it (--templateoverrides). Unknown looks, and a look given to a service without a frontend, are refused before anything is written. - Agents do what the user asked. The generated design-system skill,
AGENTS.mdand the frontend docs say the look the user asks for wins over the default; the skill used to steer every request back to it.
Changed -- modular by default
jfast new modulewithout--layoutgenerates amodularmodule, and the interactive prompt,jfast startandcontracts initstart there too. It waslayered. Existing modules keep the layout recorded for them injfast.toml;--layout layeredstill generates the old shape. The agent guides tell agents to usemodularunless the user asks for another.
Documentation site
- Spanish pages lead to Spanish pages. Every sidebar link, pager link and
landing button on a Spanish page pointed at the English page one directory
up, so the translations were reachable only through the language switch.
es/docs.htmlloaded a stylesheet that is not there and saidlang="en". None of it was seen becausedocs-site/check.pyonly read the top level; it now checkses/too, refuses a link that leaves Spanish for English other than the switch itself, and checks<html lang>. Against the previous build it reports 1,197 problems. - The version is the release. Pages printed the directory they are
published under, so every footer said "JFastFramework latest". They print the
package version now, read from the source, and the picker says
latest · 0.1.0a9. The landing's figures -- plugins, layouts, test functions -- are counted at build time instead of typed into the copy, which had said 17 plugins and 509 tests for a month. - Chrome in both languages. Pager, copy button, footer, docs home, the trade-off label and the theme toggle's label were English on Spanish pages.
- Liquid ruby. Dark by default, glass panels, an island navbar, and on the
landing a glass ribbon drawn with three.js -- pinned and hashed from cdnjs,
with a still glow when it cannot run. The logo sits on the right of the hero
on its own pane of glass. See
docs-site/assets/BRAND.md.
Not done, and named
Distributed tracing, and, in accounts, email verification, password reset
and MFA. Both in PLAN-NEXT.md.
[0.1.0a8] - 2026-09-03
One process is not the shape this runs in.
Every number in this framework describes a single process -- a token store, a
connection pool, an engine map. The Dockerfile it generates ends in uvicorn
--workers $JFAST_WORKERS, one per CPU. Nothing had ever multiplied the two, so
the arithmetic that decides whether a deployment works was never done.
Added
-
[plugin.database] server_max_connections, and ajfast checkfinding that does the multiplication.pool_size = 10plusmax_overflow = 20is 30 per process, and eight workers make 240 against a PostgreSQL whose own default ceiling is 100 -- from one service. The first sign of that wasFATAL: sorry, too many clients already, in production, raised against whichever service connected after the one that took the room.0turns the check off for a managed instance sized from RAM. -
A finding for a database-per-tenant nobody opens. Setting
tenant_dsn_templatesays each tenant has its own database, and nothing generated reads it:jfast new moduledepends onsession_dependency, which is the shared primary, while routing to a tenant's own database istenant_session_dependency. Nothing fails and nothing leaks -- the rows carrytenant_idand the repository filters on it -- but they are all in the primary while the per-tenant databases stay empty. -
A queue job could run twice, by default. The claim is invisible to other workers for
visibility_timeoutand nothing extends it while a handler runs, so a job that outlives the window is claimed again -- by another worker, while the first is still inside it. Both numbers defaulted to 300 seconds and lived in different places ([plugin.queue] visibility_timeoutand the worker'sjob_timeout), so the shipped pair raced at the boundary and raising one without the other made the duplicate certain. A charge taken twice, an email sent twice, and neither run aware of the other. The worker derives its ceiling from the backend now -- 80% of the window, leaving room for thenackto land -- and refuses to start when ajob_timeoutis given that reaches past it. RabbitMQ publishes no visibility timeout, because it redelivers on the connection rather than on a clock, and is left alone. -
mailrefuses a backend that delivers nothing in production. The default isconsole, deliberately -- nobody emails a real customer from a laptop -- and in production it means every message is printed to stdout,sendreports success, nothing bounces and no queue backs up. The verification link, the password reset and the invoice simply never arrive, and the only symptom is a customer saying so a week later. Thesmtpbranch already refused to start with credentials missing, for exactly this reason;consoleandmemorynow refuse for the same one. -
Channels on the memory backend are named in production. The memory backend is process-local, which one worker per CPU turns into a publish that reaches the subscribers in one worker out of N. It is the right choice for a channel each worker should act on separately and the wrong one for a side effect that must happen once; nothing in the framework can tell those apart, so the warning names the channels and leaves the decision there.
websockethas no such ambiguity and already refuses to register without Redis.
Changed
- The default pool is 5 + 5, not 10 + 20. Thirty per process was a number
chosen as if one process were the whole service; it is not, and the check
above failed the tree this framework's own
jfast startgenerates -- which is the right way to find out. Ten per worker is 80 across the default eight, with room beside it for everything else that connects. Ten is not small for an async service either: a connection is held while a query runs, not for the length of a request. Raise it against a server sized for it, and raiseserver_max_connectionsto say the server is.
Fixed
TenantPoolExhaustedis a 503. Every tenant engine being busy is backpressure: the service is healthy, it is at capacity, and the same request succeeds a moment later. As a bareRuntimeErrorit reached the unhandled handler and came back500 "An unexpected error occurred"-- which tells a client to stop and a reader to hunt for a defect, while hiding the one signal that says raisetenant_max_engines.RuntimeErrorstays in the bases.
Audited, and found correct
Written down because "we looked" is worth as much as "we fixed" to whoever reads this next, and because each of these was checked by running it:
- One configured database builds exactly one engine, and the per-tenant map is lazy: nothing is opened for a tenant that never arrives.
- The tenant LRU holds its ceiling under pressure. An engine is never disposed while a request holds it -- the entry leaves the map first and closes on the last release -- and when every engine is busy a new tenant raises rather than opening one more.
- The repository raises on a tenant-scoped model with no
tenant_idcolumn rather than returning every tenant's rows. - JWT verification refuses a refresh token used as a bearer, a wrong signing
key,
alg=none, an expired token and a foreign audience. A token with notypis accepted, which is correct for an external identity provider. - Local storage refuses
../, backslashes, percent-encoded traversal, absolute paths and null bytes, each with the key in the message; none escaped the disk root. X-Forwarded-Forfrom an untrusted peer is ignored, HSTS is sent only over HTTPS, and a body past the limit is a 413 in problem+json.
Fixed -- the session, which was also per process
-
authminting sessions with no shared store now refuses to start in production. Without thecacheplugin the token store is in memory, which is per worker, and both halves of session security are then per worker with it: a logout revokes on the process that served it and nowhere else, so the token keeps working on the others; and a refresh reaching any worker but the issuing one finds no family and is answered401 "this session has been revoked"-- a revocation that never happened, three times in four on four cores. The old answer was a warning at boot, in a JSON log, which is not read. Both failures are reproduced intests/test_session_store.pybefore the refusal is asserted. A service that only verifies tokens minted elsewhere keeps starting: it holds no session to lose. -
jfast checkreports the same configuration as HIGH at any environment. The refusal lands in production, and a service is developed atenv = "local"-- so without this the boot that fails is the deployment.
Fixed
-
A missing extra names the command that installs it. A plugin imports its client library inside
register, so an uninstalled extra surfaced asNo module named 'qdrant_client'-- the distribution's name, not the one anybody types -- fromcreate_app,jfast doctorandjfast checkalike.PluginMeta.extrahad heldjfastframework[qdrant]the whole time and the failure never reached it. -
The default CSP follows what the service renders.
'unsafe-inline'and the HTMX CDN are there for pages: the generated HTMX base carries an inline handler and/docsis an inlineSwaggerUIBundlecall. A JSON API renders neither, and kept both in production with the schema already closed. The allowance now arrives with thewebplugin, the way the docs CDNs already arrived with the docs. -
Upper bounds on what this framework is built on.
fastapi,pydanticandpydantic-settingswere floors only, which is a promise about code that does not exist yet.starlettewas worse: imported directly by nine modules here and declared by none of them, so the version this runs on was whatever FastAPI pulled -- and FastAPI's own requirement isstarlette>=0.46.0with no ceiling. Raising a bound is now a release with a test run behind it. -
A skip caused by a blocked prerequisite is no longer a pass.
jfast check --only pluginsexited 0 on ajfast.tomlthat does not parse: the full battery exits 2 because the config check reports it,--onlydeselects that check, and the skip left behind read as success -- to a pipeline, silently.CheckResult.blocked_bycarries the code of whatever blocked the check, and that code now decides the exit whether or not--ciis on. The distinction is the point: "no contracts.toml here" is an absence and stays exit 0, "the configuration did not load" is a failure this run could not look past.--fail-on neverstill wins over both, because an escape hatch with an exception is not one.
Added earlier, written down here
These five shipped in 0.1.0a6 and never got an entry: the notes for them sat
under [Unreleased] while two releases were cut below them. Recorded now under
the release that noticed, rather than backdated into one that did not say it.
Five commands that move the CLI past the first ten minutes of a project. Each answers a question the framework could already have answered and did not.
-
jfast check— every check that exists, one screen, one exit code. They all existed already; what did not exist was a single thing to run, so CI ran three of them and the two nobody wired up never ran at all. Exit-code precedence is by how much of the report a failure invalidates, not by severity: ajfast.tomlthat will not parse makes everything after it a guess.--jsoncarries the code of every check that failed, because one number is never the whole answer. Under--cia skip fails — in CI a skip means the runner was missing something, and a battery that reports green on checks it did not run is worse than no battery. -
jfast migration check/plan— reads revisions before they run:NOT NULLon a populated table, a rename rendered as drop-plus-add, an index built while holding a write lock, a type change with noUSING. Verified by watchingalembic upgrade headfail against real PostgreSQL and predicting it. Row counts come fromEXISTS ... LIMIT 1andpg_class.reltuples, never acount(*), and with no database it reports "unknown, treat as populated" rather than assuming empty. -
jfast contracts explain— why a rule exists, where it is declared, and what to do instead.contracts checktells you a rule broke; an agent handed a violation with no remedy tends to satisfy the checker rather than fix the design, by deleting the import or turning the rule off. The answer cites the line in yourcontracts.tomland the comment its author wrote there, not invented prose.contracts diffcompares the architecture the contract permits against the imports the code actually has — not a git diff, and the docs say so plainly. -
jfast ai context --jsonandjfast next— everything a model needs about a project in one call. The size is project-dependent and there is no single number: a generated service measures 8.3 KB at one module and 11.5 KB at five, and a five-module service with real findings and contract violations measures 14.8 KB (--brief2.8 KB to 6.1 KB across the same range).jfast ai context --sizeprints the figure for your project — that is the one to plan against. For scale: shippingdocs/instead would have been 555,859 bytes. What it deliberately leaves out is listed in anomittedfield with the command that returns it,jfast migration checkincluded.nextorders steps by dependency, not severity — a module that is not registered comes before its missing tests, because testing an unwired module proves nothing — and on a clean project it says what it checked rather than inventing work. -
jfast upgrade --check— what breaks moving to a newer framework version, filtered to what applies to this project: it reads your models, yourcontracts.tomland your plugin settings and reports only the changes that can affect you. A warning that does not apply is how people learn to skip the output. The manifest is data in the package rather than a parse of the changelog, which is prose, does not ship in the wheel, and breaks silently on a reworded heading.--applyis refused rather than stubbed: rewriting someone's project needs a rollback story this does not have.
[0.1.0a7] - 2026-09-03
The quickstart, made to survive being followed literally.
Four commands end in a panel telling somebody what to run next. On 0.1.0a6
three of those lines did not work on a project the framework had just written:
docker compose up --build had no Dockerfile to build, the api container it
did start read the host's DSN and crash-looped, and jfast start shipped a
module it never mounted, so the endpoints its own generated tests covered were
absent at runtime.
Every piece worked in isolation, which is why 1302 tests were green while the
first ten minutes were not. The release that fixes them also adds the two
checks that would have caught them: tests/test_quickstart.py, which asserts
the sequence rather than the pieces, and scripts/smoke_compose.sh, which runs
the generated compose files and asks the running containers what they serve.
Added
-
InfraService.client_env— how a service on the same compose network reaches a container, declared by the plugin that owns it. The container was always derived from the plugin graph and the connection string never was; that gap is where the crash loop lived. Declared bydatabase(per named connection),cache,mongo,qdrant,queue/RabbitMQ andevents/Kafka. A plugin whose address is per-disk configuration rather than one variable --storage-- declares nothing, and a test fixes that as the correct answer. -
A Dockerfile with every generated Python service. The
.dockerignorehas shipped with the scaffold since0.1.0a6for the same reason: the file is needed before the command that used to write it gets run. -
requirements-dev.txtin generated services.requirements.txtis the deploy list, and a test runner does not belong in a production image. -
scripts/smoke_compose.shand thecomposeCI job.smoke_docker.shbuilds the image but wires the network, the database and the environment by hand -- which is the work the generated compose file exists to do, so nothing ever ran that file. Both generators are covered, because they fail differently.
Fixed
-
jfast startleft its own module unmounted. It renderedmodules/item/and stopped;jfast new modulehad spliced the router intomain.pysince that path existed. Nothing failed: the generated tests passed, the server booted and/items404ed.jfast checkreported it HIGH and exited 1 on a tree this framework had just written. Both paths now call the same two functions. -
No generator wrote a Dockerfile. Both compose generators give the application service
build:, sodocker compose up --build-- the first line of the panel -- stopped atfailed to read dockerfilebefore any container started.jfast deploy dockerfilehad the file all along and nothing said to run it.jfast deploy composenow says so when an older project has none. -
The api container was never told where its database was. It loaded a
.envwritten for a process on the host --localhostand the published port, which inside a container is that container.environmentbeatsenv_filein compose, so the internal address goes there and the.envstays correct for its own reader. The workspace generator had derived this from the resource graph since it existed; the single-service one had nothing. -
pytestwas printed by a scaffold that installed none. The stepjfast new moduleprints answeredNo module named pytest. -
jfast starttold you to overwrite the.envit had just written.cp .env.example .env, "the defaults already match compose": both halves were false. The generated file comes from the resource graph; the example is static and points atlocalhost:8001with a password nobody set. The local path now points atjfast dev, which resolves the host addresses and the workspace secret -- the thing no static file can hold for both readers.
[0.1.0a6] - 2026-09-03
Hardening, in the three places where "it works here" and "it works" are different claims: another operating system, inside a container, and behind a proxy. Nothing in this release is a feature; all of it is a case that was not covered.
Added
-
Windows in CI, with
mypy --platform win32. A path separator, a case-folded glob and a console encoding are all things that pass on Linux and fail on a laptop, and the only way to know is to run there. The type check is run for win32 as well as the host, because a branch that only exists on one platform is a branch the other platform's checker never reads. -
tzdataas a core dependency. Timezone-aware timestamps arrived in0.1.0a5; a zone database is what makes them resolve. On a slim container image there is no system copy, so the code that was made correct in the last release would have raised at runtime on the machines it matters on. -
A generated
.dockerignore, and it survives regeneration. Without one the build context carries.env,.gitand every virtualenv into the image — which is both slow and a way to ship a secret. -
scripts/smoke_docker.sh— builds the generated Dockerfile and checks the result for leaked secrets and image hygiene. A Dockerfile that is only ever read is a Dockerfile whose problems are found in production. -
CONTRIBUTING.md,LICENSE, and packaging metadata.
Fixed
-
JWKS refresh stampede. Every request that arrived during a key refresh started its own. Under load the first slow fetch became many concurrent slow fetches, which is the failure mode that turns a key rotation into an outage.
-
jfast checkandjfast doctornow validate buildability. Both reported on a project they had not established could be built, which is the class of green result this project has spent two releases removing. -
The body-size middleware after the response has started. Refusing a body is only possible while the response has not begun; doing it afterwards corrupts the stream instead of rejecting the request.
-
Gateway forwarding of headers, query strings, cookies and encodings. Several edge cases where what reached the upstream was not what arrived, each with a regression test.
-
Docker builds are multi-stage and run as a non-root user.
Twenty-six files, 1,068 insertions, and eleven of the twenty-six are tests.
[0.1.0a5] - 2026-08-30
Twenty findings from a second external report, this one raised against
0.1.0a4. Nine were verified by hand before anything was touched, two turned
out worse than reported, and two were rejected -- one of them rejected as a
defect and answered as a naming problem instead.
The theme is narrower than last release's. 0.1.0a4 built seven diagnostic
commands in a day and three of them lied: migration check approved three
dangerous statements, contracts check passed on a contract that governed no
file at all, and analyze reported no findings on that same project while
jfast next said the check had failed. A diagnostic nobody believes gets
ignored; a diagnostic people believe and that is wrong costs more than the
defect it was built to catch, because it is the reason they stopped reading the
migration by hand.
Two rules came out of that, and both ship as tests rather than as notes:
- No diagnostic is trusted without a case where it must fail and does.
- A test asserts the half that can break, by the route a user takes -- not
the advertised string when what fails is the port mapping, not
contracts initwhen people runjfast new service, not the middleware in isolation when it runs behind uvicorn.
1,287 tests pass and 7 skip: five want a real Redis, two are one command that is deliberately reachable two ways.
Breaking
- A layer glob's
*stops at/. Layer paths went throughfnmatch, which translates*to.*and crosses directory separators, somodules/*/repository.pyalso claimedmodules/billing/infrastructure/repository.py-- a layer could appear to govern a tree nobody wrote it for, andlayer-unmatched, the finding that exists to catch a contract governing nothing, never fired. Matching is case-sensitive on every platform now as well:fnmatchfolds case on Windows, so one contract passed on a laptop and failed in CI.
A file that matched a layer yesterday and matches none today is governed by
nothing, forbid_packages included. Where the reach was intended, widen the
pattern:
diff
-paths = ["modules/*/repository.py"]
+paths = ["modules/**/repository.py"]
** crosses directories on purpose and says so, and **/ also matches
zero segments, so modules/**/http.py still covers modules/http.py.
jfast upgrade --check lists the files that changed hands under
layer-globs-narrowed, computed against your tree rather than read off the
patterns: a contract written in ** throughout is unaffected and gets no
report.
datetime.now()with no zone is a contract violation. The newnaive-datetimerule rides[rules.async_safety], which every existing contract already enables, so it arrives with no opt-in and a build that passed yesterday fails today, naming a rule the project has never seen.datetime.utcnow()anddatetime.utcfromtimestamp()are the other two -- naive despite the name, and deprecated since 3.12.
diff
-created = datetime.now()
+from jfastframework.time import now
+created = now()
datetime.now(UTC) is equally fine: the rule is about the missing argument,
not about which module you reach for. A *args or **kwargs splat is never
reported, because the syntax cannot say whether the zone is in there. To
defer the whole rule, one line in contracts.toml:
toml
[rules.async_safety]
naive_datetime = false
That leaves the async-blocking half on, which is the half you already had.
- Every database session is pinned to UTC.
[plugin.database] session_timezonedefaults to"UTC"and travels as an asyncpg startup parameter, sodate_trunc('day', ...),CURRENT_DATE,now()::dateand anyAT TIME ZONEwithout an explicit zone stop reading the server'sTimeZone. On a server configured to anything else those queries return different rows than they did yesterday -- which is the point, but it is a change of answer rather than of code, and a daily report is where it shows. To keep the old behaviour, say so:
toml
[plugin.database]
session_timezone = "" # leave the server's TimeZone alone
Sent in the startup packet and not as a SET after connect, deliberately: a
pooled connection is checked out mid-life, so one DISCARD ALL, one
RESET ALL or one pgbouncer server-reset undoes a statement that ran once
and the session falls back to the server's zone with nothing in the logs.
jfast new serviceno longer writescontracts.toml. It had no way to know the layout -- a service is generated before any module exists -- so it wrote the layered contract into hexagonal, modular and screaming services, where it matched no file and enforced nothing whilecontracts checkstill exited 0. The firstjfast new module --layout Xwrites the contract for X now, and never replaces one already on disk. Passjfast new service --layout Xwhen the layout is already decided.
For projects generated before this, the consequence is a new failure:
contracts check reports layer-unmatched (exit 5) for a layer that matches
no file while files it should have claimed go unclaimed. Point the layer at
the folders your modules really use --
diff
[layers.storage]
-paths = ["modules/*/repository.py"]
+paths = ["modules/*/infrastructure/*.py"]
-- and jfast inspect names each module's layout.
jfast contracts init --layout X --force also fixes it and overwrites the
whole file, every layer, rule and waiver the project added included, so it is
the last resort here rather than the first.
TokenStore.rotate_refreshreturns an outcome, not a bool, and takes agracekeyword. A bool could not tell a client retrying apart from a stolen token being replayed -- both are "this one was already used" -- and only the store can answer the two together. Every truthiness test on the old return value also passes for"replayed", which is the one outcome that has to end the family:
diff
-async def rotate_refresh(self, token_id: str, *, family: str, ttl: int) -> bool:
+async def rotate_refresh(
+ self, token_id: str, *, family: str, ttl: int, grace: int = 0
+) -> RefreshOutcome:
RefreshOutcome is Literal["rotated", "raced", "replayed"] in
jfastframework.auth.store, where MemoryTokenStore and RedisTokenStore
are worked examples. A store that cannot honour a grace window returns
"replayed" wherever it used to return False. Only a project that wrote
its own store is affected; the shipped ones are read correctly by the
framework.
-
An
on_refreshhook returningNonerevokes the session family. It used to refuse that one request and leave the access token already in the client's hands good for its full lifetime, so a banned user went on working for up to fifteen more minutes. The hook also runs before the refresh token is consumed now: a hook that raises leaves the token usable, so the client's natural retry is a retry rather than a replay that costs the family for the whole refresh lifetime. -
Page.totalisint | None. This shipped in0.1.0a4and was recorded there as a fix, which was the wrong file: the modes that skip theCOUNTnever knew a total, and reporting one anyway was a number nobody could act on. It reaches clients and not only code that type-checks -- the JSON of every paginated endpoint this framework generates can carry"total": null, and a response model declaringtotal: intfails validation on the page that produces it.
diff
class PageResponse(BaseModel):
- total: int
+ total: int | None
has_more answers "is there a next page" without a total.
jfast upgrade --check lists the call sites under
pagination-total-optional.
-
A service with
storageenabled gets 25 MiB and 120 s from the kernel. The raised pair was written intojfast.tomlby the scaffold and lived nowhere else, so a project that enabledstoragea year afterjfast new serviceran on 2 MiB and 30 s whileupgrade --checkpromised it 25 MiB. The rule lives inJFastSettingsnow, which makes the manifest true by construction. An explicit value under[app]-- including an explicit0-- still wins, so write one to keep a smaller limit. -
Generated compose files pin no
container_name. It is global to the daemon, so a second copy of the same workspace could not start beside the first. Compose derives the running container's name from the project instead, which breaks any script that named one directly:
diff
-docker exec -it shop_postgres psql -U shop
+docker compose exec postgres psql -U shop
Added
- A time-zone subsystem, because "store UTC" was half the problem.
0.1.0a4made what is stored aware. Computing with those values stayed a property of wherever the container ran:date_trunc('day', created_at)answers differently on two replicas whose servers carry differentTimeZonesettings, from byte-identical rows, and nothing fails.
jfastframework.time answers the half that is a business question. now()
is the only clock the framework reads and is always aware UTC; today(tz)
and day_bounds(day, tz) decide which local day a UTC instant belongs to.
day_bounds returns a half-open [start, end) in UTC -- never BETWEEN,
whose closed upper bound either double-counts midnight or drops the last
microsecond depending on the column's precision -- and resolves both edges
with fold=0, which is what makes it right on the local day that has no
midnight (America/Santiago starts DST at 00:00) and on the one that has two.
in_zone() renders and is presentation only; parse() refuses a string with
no offset unless the caller says which zone wrote it.
Three zones, named apart because they are three questions: storage is UTC and
is not configurable; [app] timezone is the business zone, what "today"
means for a report, an invoice period or a daily quota;
[plugin.tenancy.timezones] overrides it per tenant, which is the case one
deployment serving several countries exists to handle, with a tenant_zone
dependency that falls back to the business zone and never to the server's.
Every name is validated at boot, so a typo stops the service instead of
shifting one tenant's reports by a day a week later.
"UTC" resolves to datetime.UTC and reads no file: zoneinfo reads
/usr/share/zoneinfo for that key like any other, and a slim container with
no tzdata raises for every name. Naming a real zone on such an image fails at
boot with a message that says which package is missing.
jfast doctor reports db_timezone as a pair -- what an unpinned client
computes in, and what this service computes in -- read over two connections
because a startup parameter becomes the session's reset value, so
pg_settings.reset_val reports UTC on a server set to anything. A server
that is not UTC is not this service's failure and is still worth one line:
every other client of that database, psql and a BI tool and a migration run
by hand, is reporting a different day. See
docs/timezones.md.
jfast checksays what it does not check. With an unused import, a misformatted file, astrassigned to anintand a failing test all present at once, its output was byte-identical to the clean project's and it exited 0,--ciincluded. Nothing in the report was false. What was false was the impression the name left, and a team acting on that impression deletes its own verification script and loses four gates in one commit.
It still runs none of them, on purpose: pytest executes your code for an
unbounded time and mypy in a tree whose dependencies are not installed
manufactures findings, and either one inside this command turns a pre-commit
hook into a build. So the answer is naming rather than adding. Every run
ends with
not checked here: lint, formatting, types, tests
ruff check . ruff format --check . mypy . pytest
pass or fail, and --json carries the same four under not_covered, each
with what it catches and the command that catches it.
contracts checkprints how many files each layer governs, in the order the contract declares them, on every run:
layers
domain 4 files
application 2 files
infrastructure 3 files
adapters 2 files
shared 2 files
A layer at 0 is the finding, and it is the one this checker reported late:
layer-unmatched fires only once files that layer should have claimed are
also unclaimed, and a layer that drops from 40 files to 3 in a refactor
raises nothing at all. Counted through layer_for rather than by raw
globbing, so a layer whose every match is taken by a more specific pattern
shows as the zero it is.
One fact, one owner, across four commands: analyze reports
contract-governs-nothing by calling check_coverage rather than
reimplementing it -- it used to print "no findings" on a project whose
contract governed nothing while jfast next said the check failed --
contracts diff lists what an empty layer permits under ~ with the reason
instead of selling an outage as ten opportunities to tighten the contract,
and next collapses the pair into one step rather than five lines about one
thing to do.
-
ratelimit,channelsandwebsocketare in the plugin menu. All three shipped as entry points and appeared in no catalogue, so the only way to find them was to readpyproject.toml.tenancywas the mirror image: in the catalogue and absent from every generatedjfast.toml, because the filter that builds that menu required an extra andtenancyneeds none. Plugins with no extra now printno extra neededon their row. Two tests tie the two hand-kept lists together, in both directions -- a catalogue entry with no entry point offers an install that cannot work. -
[plugin.auth] refresh_grace_seconds, 10 by default. Two tabs of one browser refresh at the same instant and one of them loses; with no window the loser's attempt reads as a theft and revokes the session both tabs were sharing. The losing request is still refused -- there is one live refresh token and the winner has it -- but the family survives.0restores strict reuse detection, and longer than a request round trip only buys a stolen token more time. -
A sign-in screen in both generated frontends.
LoginView, a route guard that is public by default with the one line to change marked and explained, and a 401 handler inservices/api.jsthat refreshes once for a whole burst: four panels loading together produce four 401s, and four refreshes present the same rotated token four times, which a backend that treats replay as theft answers by revoking the session. -
jfast new service --layout, for a service whose layout is already decided, andfollowSystem()in both frontends' theme composable.
Fixed -- silent
- uvicorn was resolving the client address before the framework saw it.
There is no
proxy_headersargument anywhere in0.1.0a4's source, and uvicorn ships its ownX-Forwarded-Forhandling on, trusting loopback, running before any application middleware.jfast servebinds127.0.0.1, which is exactly the peer uvicorn believes -- sotrusted_proxiesvalidated locally and was right for the wrong reason, while in a pod every sidecar could pick its own client address. A forgedX-Forwarded-Protoflippedscope["scheme"]too, which is the gate HSTS is decided on.
jfast serve, jfast dev and the generated Dockerfile entrypoint all pass
--no-proxy-headers now: one resolver in the process, and it is the one that
reads jfast.toml. A peer that arrives already substituted is caught rather
than believed -- a proxy appends the address it received the connection
from, never its own, so a genuine transport peer does not appear in the
chain it is relaying -- and such a request is treated as having no client at
all, one bucket an attacker cannot rotate out of, with the scheme downgraded
when the header could have written it. The tests for both halves run against
a real uvicorn on a real socket, started the way it ships.
- Keyset pagination stopped at the first NULL and reported that it had
finished. Worse than the report said: not "the page ends early" but an
empty page with
has_more=False, and every row from that cursor onward unreachable from any cursor -- 180 of 200 on SQLite, where NULLs sort first, 40 of 200 on PostgreSQL, where they sort last.last_message_atandedited_atare exactly the columns a feed orders by.
Every ordering the repository builds now spells NULLS LAST on the nullable
columns, because the default is not one thing -- PostgreSQL puts NULLs last
ascending and first descending, SQLite puts them first either way -- and the
keyset comparison is written against that: IS NULL for a tie, and a step
past a non-NULL value admits the NULL block that follows it. The cost is
stated rather than hidden: a nullable ordering column demotes the index range
scan to an index scan with a filter, measured at 840 buffers against 4 with
NOT NULL columns, one page at depth 100k of 200k rows, against 1,049 for the
same page by OFFSET. Still the cheapest of the three ways to page, no
longer flat.
-
Three parsers could not read this framework's own template.
cli/migrations.pyandupgrades.pyfilteredast.Assign, and the generatedscript.py.makoemitsrevision: str = ...anddown_revision: str | None = ..., which areast.AnnAssign. The same blind spot hid__tablename__: str = "users"-- SQLAlchemy 2.0 style, which is what the rest of a generated model is written in -- fromproject.py, somodule-no-migrationstayed quiet about a table no revision creates. The test that covers it parses a revision generated by the template rather than one written by hand. -
jfast upgrade --checkhanded overALTER TABLEs for tables with no timestamps. It reported one per__tablename__in any file that importedTimestampMixin, and a models file routinely holds both the tables that mix it in and the projection tables that do not. AnALTERnamingcreated_aton a table without one aborts the revision -- after every statement before it has already taken ACCESS EXCLUSIVE and rewritten its own table. Resolved per class now, through the base closure, so a model reaching the mixin through a base inshared/is found and an__abstract__base is skipped. -
The
jfastconsole script could not load a project's own plugins.[plugins.paths]names modules that live in the project rather than in a wheel, andpython -m jfastframeworkputs the project directory onsys.pathwhile the console script does not -- so onejfast.tomlpassed under one spelling and reported its own plugin missing under the other. One insertion, inregistry.discover, and a dotted path that does not import is recorded as broken rather than raising out of discovery.check,analyzeanddoctornow see one set of plugins between them. -
Kafka advertised a port compose did not publish.
host_portmoved the advertised address only; the mapping still came frombase_port + port_offset. A client bootstrapped, reconnected to the advertised address and hung.InfraService.host_portfeeds both now, from one field, and the test asserts theports:mapping as well -- the old one checked the advertised string alone, so it passed for as long as the defect existed. -
jfast deploy composethrew a service's uploads away on the next build. The volume that keeps local storage disks was written by the workspace generator only, so the same service deployed through the other command kept the rows and lost the files they point at. Both generators share one function now, as they already did for the shape of a container.
Fixed -- correctness
jfast migration checkreadsop.execute. The statement forms whose risk the leading keywords settle are analysed, and one matching none of them is reported as unread rather than passed over in silence.- A drop-plus-add with a backfill between them is no longer reported as data
loss. Copying the values is what turns the pair into a rename, and flagging
it anyway is the false positive that leaves
--fail-on neveras the only way to put a correct migration through CI. - The rename banner a generated revision carries says that it speaks for the
moment the file was written and that
jfast migration checkre-reads the file: when the command is quiet and the banner is still there, the banner is the stale one. Nothing updates a comment, and a STOP nobody deletes is a STOP nobody reads. - Every backticked remedy
migration checkprints is asserted to be Python somebody can paste. The SQL and shell fragments in the same sentences are not, and are not marked as though they were. useTheme().resolvedis a module-levelcomputedin both frontends. It was arefbuilt per caller, so only the component that handled the click ever refreshed, under a comment claiming the property it did not deliver. There is afollowSystem()now as well:toggleThemecan only pin light or dark, so without it the first click permanently dropped one of the three states.- The generated
alembic.iniusesversion_path_separator = newline.osis deprecated and warns on every alembic command. contracts explaindocumentsnaive-datetimeand points at[rules.async_safety], so a violation of the new rule arrives with a remedy rather than with a rule name and a line number.- The generated
jfast.tomldocumentstimezone,[plugin.database] session_timezoneand[plugin.tenancy.timezones]where each is set, andAGENTS.mdno longer claims a contract every service is born with.
[0.1.0a4] - 2026-08-30
Twenty defects found by building a real application against 0.1.0a3, plus a
security audit and a review of the CLI. Almost every one of them failed in
silence: a consumer that subscribed to nothing, a refreshed token that
authenticated and was authorized for nothing, a compose file that regenerated
identically while ignoring a plugin, a contract that forbade the pattern its own
documentation prescribes.
Breaking
- Timestamps are timezone-aware.
TimestampMixinmapped toTIMESTAMP WITHOUT TIME ZONE, socreated_atserialised as2026-08-29T20:55:15with no offset and every JavaScript client read it as local time -- a post written now rendered as "in 6 hours" east of UTC. Existing tables need a migration, and theUSINGclause is load-bearing:
sql
ALTER TABLE invoices
ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE timestamptz USING updated_at AT TIME ZONE 'UTC';
Alembic's autogenerate writes the bare form with no USING. That does not
fail -- it converts through the implicit cast, reading every stored value in
the server's TimeZone, and silently shifts the whole table on any server
not set to UTC.
-
Refresh tokens minted by
0.1.0a3and earlier are refused with a 401. Every active session re-authenticates once. Those sessions were already broken: rotating one returned a token with no scopes. -
/auth/logoutends the calling session only. It previously revoked every session of that person. There is no "sign out everywhere" replacement in this release -- a subject-level cursor needs aTokenStorechange and ships separately. -
Generated contracts let every layer import
shared/.contracts.tomlbelongs to the project once generated, so add"shared"to each layer'smay_importby hand.contracts init --forcewrites the corrected defaults and overwrites the whole file, discarding the project-specific lines that are the part worth having. -
max_body_bytesandrequest_timeoutnow have values (2 MiB, 30 s; 25 MiB and 120 s on a service with storage). Both wereNone.Noneand0still mean unlimited. -
contracts checkexits5,doctorexits2or3. Exit codes are standard and documented. -
Access tokens carry a
famclaim. Consequence: revoking a session now invalidates its outstanding access tokens immediately.
Fixed — silent
-
The events consumer subscribed to nothing.
startup()returned bare when no handler was registered: no log, no warning. The consumer started, joined the group and received nothing, ever -- because the registration window was one line wide, between the plugin providing the bus and startup reading it. Handlers are now declared at import time and drained at registration, the same shapeChannelalready used, and both branches log. -
A refreshed token had no scopes and no roles. Deeper than it looked: the refresh token never carried them, so forwarding what
rotatereceived would have forwarded nothing. It now carries the grant undergrt, a claim of this issuer's own -- never the configured scope claim, soverify()cannot read it back as authorization. -
A logout bricked the next login for up to 30 days. The refresh family was the subject, and
revoke_familydenied it for the full refresh lifetime -- so the next login was born into a revoked family. 15-minute access tokens and no working refresh until the entry expired. Families are random per session. -
A refresh token was accepted as a bearer token. Same key, same issuer, and neither
require_authnor the middleware checkedtyp, so a 30-day token opened everyrequire_auth-only route. Contained until now only because it carried no scopes -- which is why the grant went under its own claim. -
jfast workspace composeignored any plugin declaring infrastructure. Byte-identical output with the plugin on or off. Two generators read two sources of truth, and the one real projects use could not express a plugin at all. There is one generator now; a plugin it cannot represent is a warning, never silence. -
Following the documentation violated the generated contract. Every service ships
shared/enums.pyand every layer'smay_importomittedshared, so moving code where the docs, the placement rule and the checker's own message all tell you to move it failed the check. -
jfast startwrote a database password into a file it called gitignored. The rule lived in theworkspace initcommand body, not inWorkspace.save(). -
The first migration of every generated service could not run. Introduced by the timestamp change in this same release and caught before it shipped: Alembic renders a custom type by its dotted path and emits no import, and the revision parses because the name is only evaluated inside
upgrade(). It died atalembic upgrade headwith aNameErroron a line that looked fine.
Fixed — correctness
NOT NULLwith a scalar model default now emits aserver_defaultand drops it again, so an add on a populated table applies and the next autogenerate is empty.- The rename warning is conditional. It was in every revision's docstring, so it was wallpaper -- the person who reported the data loss had it in front of them.
paginateno longer forces an exactCOUNT;paginate_keysetavoids the offset scan (44,925 buffers at offset 10,000 against 588 on the first page).public_base_urlworks on local disks. It was accepted, passed to S3 only, and silently dropped for local -- and it deliberately does not apply totemporary_url, because a CDN in front of a signed URL serves the object after the signature expires.jfast new enumputs the file in the layer the layout uses. On a hexagonal module it landed outside the layout, where no contract glob reached it.- Kafka advertises two listeners, so a host process can reach the broker.
- Compose emits
shm_sizeon PostgreSQL, a volume for storage disks, and a worker count derived from the container's cgroup quota rather than the host's CPU count.
Added
jfast inspect,jfast analyze,jfast graph. The CLI could not tell you what was in your own project:describebuilds the app to answer, which fails exactly when you need it, and it says nothing about modules. These read the filesystem and never import project code.ratelimit— token bucket in a Lua script, so the read/decide/write is indivisible. Fails open, loudly, and reports/readydegraded. The gateway README had promised this plugin for a year.websocket— connection registry, authenticated handshake overSec-WebSocket-Protocol(never the query string), bounded send buffers, heartbeats, and cross-worker delivery over the existing Redis backplane.- Named database connections, a read/write split with primary pinning after a write, and per-tenant engines behind a bounded LRU that never disposes an engine a request is holding.
- An upload pipeline on each disk, with a
validatestep that sniffs the content type from the bytes, and an optionaloptimise-imagestep behindjfastframework[images]. - Security headers — CSP derived from whether the schema is exposed, HSTS
gated on production and HTTPS,
frame-ancestors, and trusted-proxy resolution soX-Forwarded-Foris not a bypass. get_or_setwith stampede protection and hit/miss counters. The cache had no tests at all.- Standard exit codes in
jfastframework.cli.exits. - A theme switch in both generated frontends: three states, no flash on reload, and a mobile drawer where a phone previously had no navigation.
- CI starts PostgreSQL and Redis, and fails if the tests that need them skip. A skip is silent, which is how a concurrency guarantee gets shipped without ever being observed.
- A test asserting the agent-facing documentation is true. For each of the
four layouts it extracts every path
AGENTS.mdand the skills name and asserts it exists.AGENTS.mdhad been describing alayeredtree to every project, so on a hexagonal module all four files it named were absent.
Added
jfast inspect,jfast analyzeandjfast graph. The CLI could not tell you what was in your own project.jfast describeanswers "what is this service" by building the app -- unavailable when a dependency is missing or the code does not import -- and it says nothing about modules at all: generate two and neither name appears in its output. These three read the filesystem instead and never import project code, so they work on a project that is currently broken.
inspect is one screen: modules, how each is shaped, what it serves, whether
it is wired into the app. inspect module <name> goes deeper. analyze
reports eight structural problems worst-first, each with the fix on its own
line -- import cycles, a module main.py never registers, two routers
claiming one prefix, shared/ importing a module, an enabled plugin nothing
provides. graph draws the module dependency graph as text, mermaid, dot or
JSON.
Every check is decidable from the source. Nothing guesses: a checker that is
right nine times in ten gets muted after the second false positive, and the
true findings go with it. module-no-migration fires only once there are
revisions, so a freshly generated project reports nothing.
-
Standard exit codes, in
jfastframework.cli.exits, documented in docs/inspect.md and covered by tests.1validation,2configuration,3environment,4migration,5contract,6usage,7compatibility. CI can now say why it failed without parsing English. -
A theme switch in both generated frontends. There was none:
dark:classes were everywhere and nothing ever set the theme, so the app followed the operating system and a person who wanted the other one had no way to say so.src/style.cssredefines thedarkvariant to read an explicitdata-themefirst and fall back toprefers-color-scheme;ThemeTogglesits in the header; the choice is stored per service and applied by twelve inline lines inindex.htmlbefore the first paint, so there is no flash on reload. -
A mobile drawer. The sidebar was
hidden md:blockand the mobile header held nothing but the service name, which meant that on a phone the generated app had no navigation at all.
Changed
- The frontend templates paint themselves in semantic tokens. The layout
used
slate-*and the components usedzinc-*-- two different neutral ramps, one blue-tinted and one not -- so a card never quite matched the page it sat on, in either theme. That mismatch is most of what "the template looks bland" is: nothing is wrong with any single component, the greys just do not agree.
bg-surface, bg-panel, bg-elevated, border-line, text-ink,
text-ink-soft and text-ink-faint now come from @theme inline, so the
utilities emit var(--ui-*) and one variable swap flips the interface. Not
one component carries a dark: class any more, except the four status
hues on BaseBadge and ToastHost where the colour is the meaning.
color-scheme is set alongside, so scrollbars and date pickers follow too.
-
The full brand ramp (
50through900) ships, rather than six scattered steps. A missing step is not an error --bg-brand-200compiled to nothing and the element silently lost its colour. -
contracts checkexits5instead of1;doctorexits2on a config failure and3on an environment one. Breaking for anything asserting on the number, which is why it happens now rather than after 1.0.
Fixed
- The rule that a router may not import SQLAlchemy was documented but never
enforced.
docs/agents.mdlists it first in the table of rules that catch generated code, and no generatedcontracts.tomlcarried it: the outermost layer declaredmay_import-- which constrains other layers, not packages -- and noforbid_packagesat all. The same hole was in all four layouts,adaptersin hexagonal included. A scaffolded service withfrom sqlalchemy import selectin its router passedjfast contracts checkwith exit code 0.
forbid_packages = ["sqlalchemy"] now ships on layers.http (layers.adapters
in hexagonal) in every contract template. The four layouts still generate and
pass their own contract; the planted import now fails with
layer-package: 'http' must not import 'sqlalchemy' and exit code 1.
Existing projects are unaffected -- contracts.toml is yours once generated.
Add the line to adopt the rule.
Added
- PLAN-CLI.md -- a proposal for the CLI as a lifecycle tool
rather than a generator:
adopt,analyze,inspect,migration check,extract, standard exit codes and--jsoneverywhere. Audited against the current command surface before being written down, which is how the contract bug above was found. Nothing in it is accepted yet.
[0.1.0a3] - 2026-08-29
Documentation
- A landing page separate from the wiki. The front page rendered through the
same chrome as every documentation page -- a sidebar of nineteen links, a
version picker, a pager -- so arriving at the project meant arriving already
inside the manual.
index.htmlis now its own page anddocs.htmlis the documentation home. - The README says what this is for. It opened with a category ("a plugin-based FastAPI framework") and went straight into a fifteen-section feature tour, which is written for somebody who has already decided. It now leads with who it is for, who it is not for, the shape of what gets generated, and four concrete situations it was built for. The reference half is unchanged; the problem was never that it existed.
- A Spanish edition. Every page has a Spanish URL under
/es/, and 24 of 26 are really translated -- the rest render the English source under a notice saying so, rather than serving English silently or leaving dead links in a translated sidebar. A translation lives atdocs/es/<name>.mdand overrides the English one; adding a page is dropping a file there. jfast devand the agent surface are documented (docs/dev.md,docs/agents.md), along with the four module layouts, the base components, the stores, andpython -m jfastframework. Ten things shipped undocumented; a check over the documentation now reports none.- A light/dark switch, replacing "whatever the operating system says". Three states rather than two: an explicit choice, or follow the system.
- The sidebar distinguishes labels from links. Group headings and entries were both muted grey in one column, so a heading read as a smaller link. The headings are now accent-coloured and monospaced with a rule after them, and every entry carries an icon.
- The language a reader picks is remembered, and acted on only at the site root. Redirecting deep links would mean a shared URL lands somebody somewhere they did not click, and a crawler bounced off every page it requests indexes nothing.
Fixed
jfast startwrote a compose file that could not start. Its own next-steps panel said to rundocker compose up --build, and that command failed: no workspace.env, so compose refused to interpolate${SHOP_DATABASE_PASSWORD}rather than defaulting it, and no per-service.env, which the compose file lists as anenv_fileand treats as an error when missing. Both are now written alongside the compose file, so the two agree by construction instead of by instruction.- The HTMX form never submitted.
--ui htmxmounted its HTML router on the same prefix as the JSON one, both declaring the same verbs, so whichever registered first answered: browsing returned JSON and the form POSTed into the API handler. The HTML surface now lives under/ui/<table>. It also passed a plain dict to a service that readspayload.name, and extended abase.htmlthat only ships with--kind web. Three separate faults on one path, none of which generation, import, mounting orcontracts checkcould see -- only sending the form finds them, whichscripts/smoke_htmx.shnow does on all four layouts. - A validation error whose input was bytes returned 500, not 422. pydantic
puts the offending value in the error's
inputfield, and a form posted without a content type puts the raw body there -- which the JSON encoder cannot represent, so serialising the 422 raised inside the handler. The caller got a stack trace aboutjson.dumpsinstead of the field name, and a status that invites a retry of a request that can never succeed.
Added
- Four module layouts, and a prompt that asks which.
layered,modular,screamingandhexagonal. A modular monolith should not force a catalogue and an orders module to the same shape; the point of the boundary is that each side can differ.jfast new moduleasks when--layoutis omitted, and falls back tolayeredwithout asking when there is no terminal, so a script or a CI job does not hang on a prompt nobody can see. jfast.tomlremembers which layout each module used. Asking again each time eventually gets a different answer, and guessing from the folders on disk breaks the moment somebody adds one. Written where the rest of the service's configuration lives, and ignored by the runtime.- A contract per layout.
contracts_modularandcontracts_hexagonalmatch the folders their layouts actually create; without themjfast contracts init --layout hexagonalpointed at a template that did not exist. The hexagonal one is the interesting one:domain/may import nothing -- not the ORM, not FastAPI -- because a domain that imports SQLAlchemy has already stopped paying for the layout. - Modules register themselves in
main.py. The frontend has patched its own routes and menu since the beginning; the backend printed two lines and left them to be pasted, so a generated module was inert until somebody did. A module that is not mounted looks exactly like a module that does not work. Non-fatal by design: a hand-editedmain.pythat lost its markers gets the lines to paste rather than an unwound scaffold. jfast dev. Containers up and waited for, migrations applied, then the API and the frontend together. Every stage degrades and says so; the one hard stop is a failing migration, because a server on a stale schema fails later in a request that has nothing to do with the missing column. It also translates the generated.envfor a host process -- container hostnames becomelocalhost:<published port>and${...}is interpolated -- since neither is true outside the compose network. Ctrl-C and SIGTERM both take the children with them, which needed its own handler: the default SIGTERM disposition kills the interpreter outright, and the children, being in their own process groups, would have survived holding the ports.python -m jfastframework. For when the console script is not reachable: a Windows install whereScripts/is not on PATH, a virtualenv nobody activated. The only module path that worked before printed aRuntimeWarningon every invocation.- Base components and toasts, in both frontends.
BaseButton,BaseInput,BaseModal,BaseBadge,SkeletonLoader,EmptyStateand a toast host, mirrored between Vue and React so the two are the same product. Loading is a skeleton shaped like what is coming rather than the word "Loading", and empty says what would be there and offers the action that creates the first one. - Pinia and Zustand stores, wired up. Pinia was a dependency no generated file imported; React had no state library at all. Auth and notifications now ship as stores, so a toast raised inside a service and one raised in a component land in the same list.
- The spinner has callers.
ui.working()was written, ASCII-safe, and invoked from nowhere -- the function existed, the feature did not.
Changed
- The brand colour is crimson, replacing the placeholder blue, matching the documentation site and the terminal.
Fixed
jfast initandjfast startcrashed on a Windows console. Not a broken character -- aUnicodeEncodeErrorraised bysys.stdoutpartway through writing a project, so the command died with a traceback having already created half of it. cp1252 has noU+2713; cp850 has neither that norU+203A; the banner's block characters are in neither. Every symbol now resolves throughcli/glyphs.pyagainst the encoding the console actually reports, and falls back to ASCII -- panels, tree guides and spinner included. The check is a realstr.encoderather than a list of known-good codepages, because terminals lie about themselves andPYTHONIOENCODINGoverrides all of it.- Three generated stylesheets pointed at a file that was never generated.
frontend_vue,frontend_reactandservice_weball told the reader to consult.jfast/skills/design-system/SKILL.md, and no generated project contained it. A pointer to nothing is worse than no pointer: it costs a reader the trip, and it teaches an agent that this project's instructions are unreliable. The reference is now conditional on the skill being written, and a test walks every generated file to keep the two in step.
Added
- A tree instead of a wall. A scaffold prints forty-odd paths, and in a flat column the one line worth reading -- a file left alone because it already existed -- looks exactly like the thirty-nine that were written. Every generating command funnels through one reporter, so the shape of the output is decided once rather than per command.
- The agent surface, opt-in:
--agent-docs, or a question injfast init. AnAGENTS.mdand a skill under.jfast/skills/, reusing the layout the framework repo already uses rather than inventing a second place to look for conventions. A frontend also gets the design skill, which is what makes the stylesheet reference above true. Off by default, because a project nobody points an agent at owes no agent files -- and every file shipped is a file that can drift.
[0.1.0a2] - 2026-08-28
Added
- A
shared/layer, and a check that says when to use it. Two modules that import each other are one module with a folder between them: neither can be extracted into a service later, and a change to one breaks the other in a way no test covers.jfast contracts checknow reports the cross-import and names the file to move the code to, so the fix does not need a design discussion. The direction is enforced both ways: modules importshared/,shared/imports no module -- without that second ruleshared/becomes the place everything ends up, which is the failure mode of everyutilspackage ever written. jfast new enum, which asks where it goes when you do not say. The placement is the decision; the file is not. Start one in the module that needs it and the check tells you the day a second module wants it, so nobody has to predict it. Generated modules andshared/both ship anenums.pyusingstr, Enum, because a plain Enum serialises asStatus.DRAFTdown some paths and"DRAFT"down others.-
Declared channels (
channelsplugin). Replaces a file of string constants, which fails in three ways: nothing checks the payload, the transport is welded to the call site, and nobody can list the channels a system uses. AChannelvalidates its payload where the message is built rather than in a worker three services away, and carries its own backend -- memory by default and needing no infrastructure, redis for a channel something in another language also speaks, kafka when a consumer that was down has to catch up. Mixing them is the normal case. -
jfast serve. Runs a service locally, and refuses to start when there is nojfast.tomlin the directory -- which is the case that used to boot silently with framework defaults, no database, and no complaint. Binds loopback rather than0.0.0.0, because a development server should not be on the network unless you say so. mailplugin. Templates, three backends, and queued by default: a slow or briefly refusing mail server should not become the latency or the error of the request that triggered it.send_now()is the synchronous escape hatch and reads like one. The default backend isconsole, so nobody emails a customer from a laptop by accident and no credentials are needed to develop. In production with the smtp backend it refuses to start without credentials rather than failing on the first send.jfast addand a capability catalogue. Excel and PDF assembly, HTML to PDF, large XML, dataframes, vision, validation, locale, retries. Nothing is installed by default: a service that serves JSON should not carry numpy, and the plugin graph stops describing the service the moment it does. In a workspace with several backends it asks which one, because adding a heavy dependency to the wrong service is invisible until the image is built.jfastframework.exports.pdf, for assembling many documents.merge()reports what it could not include -- the obvious implementation logs a warning and returns a bundle that looks complete, which for a fiscal or legal bundle is worse than an error. It also merges in batches, becausePdfWriter.append()holds every page untilwrite()and peak memory otherwise grows with the whole job.jfastframework.exports.excel, using openpyxl's write-only mode so a cursor can be streamed to a file without either being held whole.- The installer is rendered with
rich-- which arrives with Typer, so no new dependency. A banner, tabulated choices, a summary before anything is written, and the next steps with what each command does beside it.
Fixed
Six defects that shipped in 0.1.0a1. Together they meant a generated
service could not be installed, could not be built into an image, could not
answer a GET, and could not answer a PATCH. Each is now covered by a test,
and by scripts/smoke_docker.sh, which builds the generated image and runs
it against a real PostgreSQL -- the check whose absence let all six through.
- Every route taking a database session answered 422.
session_dependency(request: Any): FastAPI decides what a dependency parameter is from its annotation, and fromAnyit concluded the only thing left -- a required query parameter. Reproduced from the OpenAPI schema (name='request' in='query' required=True), not inferred. Now annotatedRequest. - Every update returned 500 once a timestamp was serialised.
TimestampMixin.updated_atcarriesonupdate, which SQLAlchemy expires at flush; the next read -- Pydantic building the response -- attempted IO in a coroutine and raisedMissingGreenlet. The mixin now asks foreager_defaults, so PostgreSQL returns the value withRETURNINGin the same statement. Asession.refresh()would have worked too, at the cost of a SELECT on every write, including the writes that never read a timestamp. - The generated Dockerfile could not build.
COPY pyproject.toml ./named a file the generator never writes, and COPY fails when its source is absent. Globbed, like therequirements.txt*line directly below it always was. - A container against an empty database answered 500 to everything.
Nothing ran migrations. The image now has an entrypoint that runs
alembic upgrade headand thenexecs uvicorn:set -estops the container on a failed migration instead of serving a half-migrated schema, andexeckeeps uvicorn as PID 1 so it receives SIGTERM.create_allwas rejected as the fix -- it builds a schema Alembic does not know about, and the first real migration then diverges in silence. - The workspace search walked to the root of the filesystem. Running
jfast startonce in a home directory left a workspace file there, and every project underneath then joined it: one compose file, one port space, unrelated services registering against each other, and nothing failing. The search now stops at the home directory and at a.git, because a repository root is where a project ends. - A service started from the wrong directory booted misconfigured in
silence.
session_dependencyreached intorequest.app.state.jfastdirectly and raisedKeyError: 'jfast'on any app this framework did not build. It now goes throughget_context(), which says so.
Added
-
scripts/smoke_docker.sh, gated in CI. It builds the image the generator writes, runs it against a real PostgreSQL, and asserts three things: a failed migration stops the container with the database's own error, a successful one leaves analembic_versiontable behind, and/readyreports the database healthy from inside the container. -
Every generated service shipped a
requirements.txtpip could not satisfy. The template carried a literaljfastframework[...]~=0.7, which survived the renumbering to0.1.0a1, sopip install -r requirements.txtin a scaffolded project failed with No matching distribution found. The pin is now derived from the framework's own version byframework_pin().
A pre-release is pinned exactly, because ~=0.1 does not match
0.1.0a1 either: a compatible-release clause normalises to
>= 0.1, == 0.* and 0.1.0a1 sorts below 0.1.0, so it is out of range
even with --pre. Once the framework reaches a final release the pin
becomes ~=major.minor on its own.
A test now fails if any requirements template hardcodes a version again. The resolution itself is deliberately not checked in CI: at release time the version being pinned is not published yet, so that check would fail on exactly the commit that is correct.
Added
- The documentation site has the project's own identity. A monogram
(
mark.svg) and favicon in black and crimson, replacing the letters-in-a-box placeholder and the emoji favicon.docs-site/assets/BRAND.mdsays where the owl goes; the site falls back to the monogram when it is absent, so a missing binary cannot break the build. - The sidebar is grouped into Start here, Build, Run, Guard and Project. Twenty-two flat links is a list nobody scans.
- An "on this page" index on any page with three or more sections, previous/next links in reading order, and a copy button on every code block.
- A Maturity page, rendering
STATUS.md. It is the most useful page on the site for anyone deciding whether to depend on a part of this. -
docs/local-setup.mdopens with one copy-paste block that goes from nothing to a running stack. It is executed end to end before shipping, not written from memory. -
The resource graph. Datastores are named instances the workspace owns (
[[workspace.resources]]), and a service binds to one under a variable (uses = [{ resource = "core-db", as = "JFAST_DB_DSN" }]). Two databases of the same type, and one cache shared by two services, are both now expressible; neither was before. - The DSN is generated.
jfast workspace envwrites each service's.envfrom its bindings. The compose file used to emit a datastore container and leave the connection string to a human, which is where drift came from. - One password per resource, generated into a gitignored workspace
.envand never overwritten once set. It replaces the single workspace-widePOSTGRES_PASSWORD, where a leak anywhere was a leak everywhere. jfast workspace resource,jfast link,jfast unlink,jfast workspace validate,jfast workspace migrate-resourcesandjfast workspace graph(mermaid or dot, edges labelled with the variable).- Binding two resources to one variable is refused, and
validatereports a port claimed twice, a binding to a resource that does not exist, and a resource nobody uses.
Fixed
- The site's hero advertised
pip install jfastframework, which does not resolve because nothing has been published. It now shows the clone-and-install that works today, and says why it is not on PyPI yet. - A mangled em dash in the hero copy, which had been rendering as
âsince the page was written.
Fixed
-
Every smoke script reported success when it failed.
trap 'rm -rf "${WORK}"' EXITends with a successfulrm, and bash hands the trap's status to the script -- so a failed assertion exited 0 and CI went green. All nine now preserve the failing code. -
The Redis queue had no visibility timeout.
visibility_timeoutwas stored and never read, and recovery only drained the worker's own processing list -- under a key that includedid(self), a memory address. A worker that died came back under a different name and never recovered its own in-flight jobs, so the guaranteequeues.basedocuments for every backend did not exist here. Workers now register in a hash with a heartbeat on server time, and any worker returns the jobs of a consumer whose heartbeat has gone stale.close()hands work back immediately, so a rolling deploy does not park jobs until the timeout expires. /readyran its checks serially and without a timeout. A dependency hanging at the TCP level held the probe open until the socket gave up. Checks now run concurrently underreadiness_timeout(default 2s), and a timeout is reported astimeoutrather thanfail-- one means the dependency said no, the other means it never answered.BaseRepository.paginate()emitted noORDER BY. Pages were not stable: a row could appear twice while another was never returned. Ordering defaults to the primary key and is overridable per repository.- The tenant filter failed open. A repository given a
tenant_idfor a model with no such column silently returned every tenant's rows. It now raises at construction; a genuinely global model declarestenant_scoped = False.
Added
- Edge protections in the kernel, all off unless configured: CORS,
TrustedHostMiddleware, a request body size limit answering 413, and a request timeout answering 504. Caddy covers these when it is in front;jfast deploy functionputs a service on Lambda with nothing in front. Wildcard CORS origins combined with credentials is refused at boot, because browsers reject that pair and it would otherwise fail silently. safe_identifier()validates any table name interpolated into SQL, at the point it enters, so the interpolation that follows is provably safe.pip-auditandbanditrun in CI as hard gates. Every existing finding is waived explicitly with its reason, or fixed.- Tests for the Redis queue against an in-memory double of the commands it issues, and for the repository against SQLite. 380 tests total.
Changed
/docsand/openapi.jsonare closed whenenv = produnless set explicitly./infoalready did this; the three are now one rule.- The PostgreSQL claim query moved to a named
CLAIM_SQLconstant, built once per call rather than assembled inline. RabbitMQQueueno longer takesvisibility_timeout. The broker redelivers unacknowledged messages when a channel closes, so the parameter never did anything, and one that does nothing is a promise the caller believes.
Added
async-blockingcontract rule.jfast contracts checknow reports calls that stall the event loop from insideasync def: the standard-library cases, the synchronous clients this framework ships with (boto3, pymongo, psycopg2, sync redis, sqlite3), a blocking client stored onself, and one hop into a synchronous helper defined in the same file. Correct offloading throughasyncio.to_threadand friends is recognised and left alone. Configurable under[rules.async_safety]; waivable inline.
Changed
- Ruff's
ASYNCruleset is enabled for the framework.ASYNC109is ignored with a reason: it wants a cancel scope instead of atimeoutparameter, anddequeue(timeout=...)maps onto a broker primitive.
Fixed
webplugin: the readiness probe ran two blockingPath.is_dir()calls on the event loop, once per probe per replica. Now offloaded.
[0.7.0] - 2026-08-28
Files, tenants, and the three cloud services a deployed app reaches for.
Added
storage plugin
- Named disks with a visibility, modelled on Laravel's: code writes to
storage.disk("private") and where that lives is configuration.
- Local and S3/MinIO drivers behind one StorageBackend protocol. A local disk
writes atomically (temp file + replace) so a reader never sees a partial
object.
- A private disk refuses to produce a permanent URL. temporary_url() signs
the key and the expiry with HMAC, compared in constant time — signing only
one of the two makes a single valid link a key to the whole disk.
- Every key is validated before it reaches a filesystem or a bucket: traversal,
absolute paths, backslashes and null bytes rejected, .. resolved first.
Local disks re-check after resolution, because a symlink inside the root can
still point outside it.
- Downloads are Content-Disposition: attachment + nosniff. An uploaded
.html or .svg served inline runs the uploader's script on your origin.
- Expired and forged links return the same 403 with the same message.
- MinIO in the generated compose file at port offset +6, opt-in.
tenancy plugin
- Resolves the tenant from a token claim, a subdomain, a path prefix or a
header, in that order of trust. header is not in the default list and
warns in production: X-Tenant-ID: acme is one curl away from another
tenant's data.
- Subdomain parsing rejects multi-label hosts, the bare base domain, and a
reserved list (www, api, admin, …). base_domain is required, or every
hostname looks like a tenant.
- require_tenant returns problem+json 403, with health, metrics and docs
exempt so probes still pass.
- jfast workspace caddy --wildcard-tenants emits a wildcard site block with
on-demand TLS and the ask endpoint that gates it. Without ask, anyone
pointing DNS at you can burn your certificate rate limit.
Social login (auth)
- Google, Microsoft and GitHub presets; any other provider by its endpoints.
- /auth/{provider}/start and /auth/{provider}/callback, with the state and
nonce carried in an httponly, samesite=lax cookie and both verified on the
way back.
- ID tokens verified for audience and issuer. Without the audience check, a
token minted for anyone else's Google app logs in here.
- @auth.on_identity is where a verified identity becomes your user. Missing
it is a 500, not a cheerful 200.
- OIDCIdentity.federated_id is provider-qualified, because subject ids are
unique per provider and not globally.
Secrets
- load_secrets() populates os.environ from AWS Secrets Manager or Google
Secret Manager before create_app(). An existing environment value wins
unless overridden; only names are logged, never values; nested JSON is
refused rather than given an unpredictable flattened name.
Serverless
- jfast deploy function <name> --target aws|gcp writes the handler, the
Dockerfile and a deploy script — and does not run them.
- Both targets run the same ASGI app the container runs. Private by default on
both clouds; --public opts in and warns.
notifications plugin
- Firebase Cloud Messaging over the HTTP v1 API, with a console backend that
logs instead of sending for development and tests.
- Unregistered device tokens are reported back so they can be deleted.
- Not verified against a real FCM project in CI; the payload construction is.
Fixed
- The
observabilityplugin no longer overwrites a resolved tenant. It trustedX-Tenant-IDunconditionally and clobberedrequest.state.tenant_idon the way past, so a tenant resolved from a signed claim was replaced byNonebefore the handler ran. It now fills the gap only when nothing else resolved one. tenancyruns innermost.add_middlewareputs middleware outermost, which ran tenancy before auth and left the signedtokensource permanently unreadable. It is appended instead.secrets.parserefuses a JSON array instead of falling through to theKEY=valueparser and silently loading nothing.
Added (internal)
errors.problem_response()for middleware, which runs outside FastAPI's exception handlers and would otherwise surface a 500 with a stack trace.
[0.6.0] - 2026-08-27
JWT authentication, and Kubernetes manifests derived from the service contract.
Added
auth plugin
- Verification in three modes: jwks (fetch the issuer's public keys — the
default, and the only sane one across services), public_key (a pinned PEM),
secret (HMAC, for a single service).
- require_auth, require_scopes(...), require_roles(...), optional_auth
as FastAPI dependencies. 401 for "who are you", 403 for "you may not".
- JWKS client with caching, rotation on an unknown kid, and a rate limit on
refresh so forged kids cannot be used to hammer the identity provider.
Cached keys keep working through a JWKS outage; /ready reports staleness.
- Token issuance for a service that owns its own login, with refresh
rotation and reuse detection: a replayed refresh token revokes the whole
session family.
- Revocation: POST /auth/logout denies the jti and its refresh family,
backed by Redis when the cache plugin is on. The in-memory fallback
reports itself as not shared rather than pretending.
- GET /auth/me returns identity and permissions — never the token, never the
raw claims.
Security decisions, each with a test
- Algorithms are pinned by configuration and passed explicitly to the decoder,
so alg: none and RS256→HS256 confusion are both refused. Configuring
symmetric and asymmetric algorithms together is rejected at startup: that
combination is the attack.
- aud and iss are verified — off by default in most libraries, and without
them a token for a sibling service is accepted here.
- Expiry leeway is 30 seconds, not minutes.
- Rejection reasons go to the log; the client gets a plain 401.
- tenant_id now comes from a signed claim, not the forgeable
X-Tenant-ID header. This is the main security reason to enable auth.
Kubernetes
- jfast workspace k8s — a kustomize tree: Deployment, Service, ConfigMap,
HPA and PodDisruptionBudget per service, one Ingress, dev/prod overlays.
- jfast init asks whether you need it.
- Liveness probes /health, readiness probes /ready — the two-endpoint
contract is what keeps a database blip from restarting every healthy pod.
A startup probe allows 150s for a slow first boot.
- Non-root, read-only root filesystem, dropped capabilities,
maxUnavailable: 0, and a PDB so a node drain cannot take every replica.
- Ingress serves /api, the same shape as the generated Caddyfile, so the
frontend build is identical locally and in the cluster.
Not generated, deliberately
- Databases. A StatefulSet for PostgreSQL from a scaffolder is how people lose data. The manifests read a DSN from a Secret.
- Real secrets.
*-secrets.example.yamlholds placeholders. - A login endpoint. Checking a password against your user table is the
application's job;
auth.issueris provided for your own route. - NetworkPolicies, ServiceMonitors, migration Jobs, Helm. Each needs a decision about your system that a generator should not guess.
Notes
- The manifests are validated as YAML and asserted structurally in
tests/test_kubernetes.py. They have not been applied to a real cluster in CI. Treat the firstkubectl applyas the test.
[0.5.0] - 2026-08-27
Per-project contracts, and a quickstart that CI actually executes.
Added
Contracts
- contracts.toml in every generated service: scope (owns /
does_not_own), layer boundaries, forbidden calls, required structure,
declared interfaces, and invariants no checker can verify.
- jfast contracts init | check | show --json | render | waivers.
check exits non-zero, so it fails a build rather than printing advice.
- A static, AST-based checker: layer boundaries (relative and absolute
imports), per-layer forbidden packages, forbidden calls with the reason
attached, and required files per module.
- Inline waivers — # contracts: allow <reason> — with the reason required
and jfast contracts waivers listing every one.
- The contract is validated before the code: two layers claiming one path, or
a may_import naming a layer that does not exist, are reported as contract
errors rather than producing confident answers to the wrong question.
- CONTRACTS.md generated from the same file, so the document and the
enforced rule cannot disagree.
- .jfast/skills/respect-contracts/SKILL.md, and AGENTS.md now opens with
jfast contracts show --json.
Getting started
- docs/local-setup.md — installing from a checkout, generating a project,
the loop you actually use, and the failure modes worth knowing.
- scripts/smoke_docs.sh runs those commands exactly as documented, in
CI. Documentation that has never been executed is a guess.
Fixed
- A generated service with
queueenabled could not start without PostgreSQL.setup()raised at startup, so the process crash-looped with an asyncpg traceback instead of serving. It now starts, logs the reason, and reports itself unready — an orchestrator handles "not ready" gracefully and handles a crash loop by paging someone. Same fix forrag'sauto_migrate. - The layered contract defaults claimed
modules/*/schemas.pyfor two layers. Caught by a freshly generated service failing its own contract, which is exactly the check that should catch it. - Layer matching ranked patterns by string length, so the screaming layout's
catch-all
modules/*/[!_]*.pybeatmodules/*/http.pyand classified every router as domain code. It now ranks by specificity — fewest wildcards.
[0.4.0] - 2026-08-27
Polyglot services, queues and events, one-command start, Caddy at the edge, and a documentation site — plus real build verification for everything that had until now only been verified by grep.
Added
jfast start
- One command for the opinionated default: a Python modular monolith with
PostgreSQL + pgvector, Redis, background jobs and a starter module, a Vue
frontend, a Caddyfile and a workspace compose file.
- A monolith rather than three services on purpose: splitting later is a move,
un-splitting is a rewrite.
Polyglot services
- docs/service-contract.md — the contract every JFast service satisfies
regardless of language: /health, /ready, X-Request-ID, problem+json,
JFAST_* config, ten-port blocks, JSON logs on stdout.
- jfast new service --language go — a Go service with zero third-party
dependencies, implementing the contract in ~300 vendored lines. CI runs
go vet, go test, go build, starts the binary and curls it.
- jfastframework/languages.py — the language registry. You only need the
toolchain for the languages you actually use.
gRPC
- --grpc generates the .proto contract (health, problem, a domain service)
and reserves port offset +9. Contract only: no stubs are generated and no
server is wired, because pinning a protoc version inside a scaffolder makes
generated stubs disagree with whatever CI has. See proto/README.md.
Queues
- queue plugin with a QueueBackend protocol and three backends: PostgreSQL
(FOR UPDATE SKIP LOCKED, transactional enqueue), Redis (BLMOVE into a
per-worker processing list), RabbitMQ (dead-letter exchange with a TTL for
delays).
- TaskRegistry and Worker: bounded exponential backoff, dead-lettering,
job timeouts, in-flight draining on shutdown, immediate wake on stop.
- GET /queue/stats.
Events
- events plugin — Kafka publish/subscribe with partition keys, offsets
committed after handling, and KRaft-mode infrastructure (no ZooKeeper).
Edge and workspace deployment
- jfast workspace compose — one compose file for every service, whatever its
language, with per-service datastores.
- jfast workspace caddy — a Caddyfile putting the workspace behind one
hostname. Backends live under /api with or without a gateway, so the
frontend's production build keeps working the day one appears.
- Frontends gained .env.production with VITE_API_URL=/api — relative, so
no CORS and no rebuild per environment.
Documentation site
- docs-site/build.py renders the repository's own markdown into a static,
versioned site; docs-site/check.py validates links, anchors, assets, theme
tokens and unrendered template artifacts.
- .github/workflows/pages.yml publishes it, rebuilding every released minor
version from its own tag so older versions keep working.
Fixed
- The generated Vue and React routers were not valid JavaScript. The marker
comment
/*nuevaRuta*/sat inside a/* … */block comment, whose inner*/closed the comment early. Every grep-based check passed — the marker was there — and onlyvite buildcaught it. Both frontends now install and build in CI. - The worker busy-waited on any non-blocking backend. PostgreSQL polls and returns instantly when the queue is empty, so the loop never yielded: it burned a core and starved the event loop, which meant the HTTP handlers in the same process stopped responding while "the worker is running".
jfast start's frontend called a dev port that Caddy served under a different path. Both now agree on/api.
Changed
PLUGIN_CATALOGgainedqueueandevents;--with queuepulls in a backend the service can actually reach, the same wayragdoes.SERVICE_KINDSand the installer offer Go.- CI gained three jobs: Go (
setup-go), frontend (setup-node) and the docs site. The frontend job exists because of the router bug above.
Not done, deliberately
- Angular is not generated. A hand-rolled
angular.jsonthat has never run underng servelooks finished and fails in a way that is hard to attribute. - React Native is not generated, for the same reason.
- RabbitMQ and Kafka are written against documented APIs but have not been round-tripped against real brokers in CI.
- Laravel and .NET extensions are not started. The service contract is the
extension point; a language needs a
LanguageSpec, a template tree and a CI job that builds what it generates.
[0.3.0] - 2026-08-27
Multi-service workspaces, an API gateway, frontend generation, and the migration/testing setup that the previous release only documented.
Added
Workspaces
- jfast.workspace.toml and jfastframework.workspace: services register
themselves, take the next free ten-port block, and the file records what the
frontend should call.
- jfast workspace init | list | gateway | env.
API gateway
- gateway plugin: prefix-based reverse proxy with hop-by-hop header
stripping, X-Request-ID propagation, and 502/504 as problem+json.
- Generated automatically once a workspace has more than one backend. One
backend deliberately does not get one.
- Not a catch-all: only configured prefixes are proxied, so the gateway keeps
its own /health, /ready and /metrics. Readiness does not probe
upstreams, so one restart does not fail the whole system.
Frontends
- jfast new service <name> --kind spa --frontend vue|react — Vite +
Tailwind v4 project with a working home page that calls the backend's
/health on load.
- jfast new view <Name> — the Modulo<Name>/{Components,Pages,Routes,Services}
structure, registered in the router and the sidebar at marker comments.
- jfastframework.cli.patcher: idempotent, loud, marker-preserving patching.
Re-running the generator does not duplicate; a missing marker raises with the
path instead of silently doing nothing.
- Framework auto-detected from the project, so --frontend is not repeated.
Migrations and tests in generated services
- alembic.ini, migrations/env.py, script.py.mako and versions/.
env.py reads the app's own JFAST_DB_DSN and auto-imports every module's
models, so autogenerate cannot silently emit an empty migration.
compare_type and compare_server_default are on.
- pytest.ini and a conftest.py with app / client fixtures.
Datastore selection from the terminal
- jfast init — interactive installer: kind, frontend, datastores, port.
- jfast new service --with database,cache,qdrant,rag — the plugin list, the
[plugin.*] blocks, the .env keys and the pinned extras all derive from it.
- rag's store is inferred from the datastores chosen, so --with qdrant,rag
cannot generate a service configured for pgvector.
Changed
- Breaking:
jfast new service --portnow defaults to the next free block in the workspace instead of 8000. SERVICE_KINDSgainedspaandgateway..vue,.jsxand.tsxtemplates render through the square-bracket Jinja environment, so Vue interpolation and JSX braces survive scaffolding.
Fixed
- CI:
mypy --strictfailed onredis.asyncio.from_urlbeing untyped in some redis releases and annotated in others — a strict run that passed locally and failed in CI on nothing we wrote. Optional third-party packages are nowfollow_imports = "skip", which is honest about types we neither control nor can rely on across the support matrix. - CI:
scripts/smoke.shhardcoded.venv/bin/python, which does not exist in a CI job. It now falls back toPATH. - Dropped the dead
tomlidependency marker (requires-pythonis already>=3.11).
[0.2.0] - 2026-08-27
Datastores became a choice, and a frontend became a service.
Added
Datastores
- VectorStore protocol in jfastframework.vectors, with Chunk and
SearchHit as the shared vocabulary. Every store normalises its score to
cosine similarity in [0, 1].
- qdrant plugin — client, health check, container with HTTP and gRPC ports.
- mongo plugin — Motor client and database handle.
- rag now selects its store from config: pgvector, qdrant, or a dotted
path to your own class. Same for the embedder.
- InfraService.extra_ports for containers exposing more than one port.
Server-rendered frontends
- web plugin — Jinja2 templates, static files, and render() with HTMX
partial rendering: a browser navigation gets the page, an hx-get gets the
fragment, from one handler.
- HTMX-aware error handling: a JFastError raised during an HTMX request
returns an HTML fragment instead of problem+json, which HTMX would
otherwise swap into the DOM as raw text.
Generator
- jfast new service <name> [--kind api|web] — scaffolds a whole service.
- jfast new module <name> [--layout layered|screaming] [--ui api|htmx].
- module_screaming layout: framework-free domain, one file per use case,
storage and HTTP at the edges, domain tests separated from use-case tests.
- ui_htmx overlay — composed onto either layout rather than duplicated, so
three template trees cover all four combinations.
- Two Jinja environments in the scaffolder: .html.j2 templates use [[ ]]
for scaffold-time values so the runtime {{ }} the browser needs survives.
- Table names are pluralised, which also dodges the SQL reserved words that
singular nouns keep landing on (order, user, group). Override with
--table.
Docs
- docs/modules.md, docs/datastores.md.
Changed
- Breaking:
jfast new <name>is nowjfast new module <name>. - Breaking:
[plugin.rag] tablerenamed tocollection— it names a Qdrant collection just as often as a PostgreSQL table now. ragno longer hard-requiresdatabase. It declaresafterand validates the store it was actually configured with, naming the missing plugin.- Module templates moved to
module_layered/; both layouts now exportbuild_service(session, tenant_id), the seam the HTMX overlay consumes. - mypy no longer pins
python_version; it checks against the interpreter it runs on, which CI varies across the support matrix.
Fixed
chunk_textemitted a final sliver already contained in the previous chunk whenever the text did not divide evenly — a wasted embedding call and a duplicate in every result set.
[0.1.0] - 2026-08-27
First alpha. Kernel and built-in plugins.
Added
create_app()with plugin resolution, registration and lifespan orchestration- Typed configuration:
JFastSettings,JFastConfig,jfast.toml+ env AppContextwithprovide/requireindirection between plugins- Plugin contract:
PluginMeta,PluginSettings, lifecycle hooks,infra(),describe() - Registry: entry-point discovery, allow/deny lists, dependency ordering, cycle detection, duplicate-provider detection
- RFC 7807
application/problem+jsonerror model /health,/ready,/infosystem endpoints- Built-in plugins:
observability,metrics,sentry,database,cache,rag jfastframework.db: declarativeBasewith a pinned constraint naming convention,TimestampMixin,TenantMixin, genericBaseRepository- Deploy generation:
docker-composeandDockerfilederived from the plugin graph'sinfra()declarations jfastCLI:new,describe,doctor,plugins list,deploy- Jinja2 module template,
jfastframework.testingfixtures - Agent surface:
AGENTS.md,.jfast/skills/with four starter skills
Notes
- Multi-tenancy is a convention enforced by
BaseRepository, not a guarantee. Row-level security is phase 2. Do not describe it as isolation until then. - The v0 prototype is preserved under
legacy/for reference.