Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Added¶
- ...
Changed¶
- ...
Fixed¶
- ...
0.20.0 - 2026-11-01¶
Added¶
- A
RoutedDuckDbClienttenant's lake can be a DuckLake.catalog_for_tenantmay return aDuckLakeTenantCatalog: a Postgres metadata schema read with the tenant's role, and a local ors3://data path. The session is locked and attested, grants included; on DuckDB 1.5 the engine does not bound Postgres egress.
Changed¶
-
A set-based
upsert_manyon Postgres writes 2,000 rows per chunk by default, set byset_based_batch_size(behaviour change). It usedbatch_size(200), so a statement-level trigger now fires a tenth as often. In the bundled benchmark a 4,000-row call runs 16–30% faster; small calls are unchanged. -
A write gateway's
upsert_many_set_basedtakesset_based_batch_size(Breaking). The command passes it besidebatch_size, which now chunks only a fallback to the domain path. A gateway implemented outside forze must accept it. -
A document's invariants are checked on create after its own model validators (behaviour change), as an update checks them. A class without invariants runs no check, so validating a subclass instance through it re-checks nothing. Names starting
_forze_invariants_on_createare reserved.
0.19.0 - 2026-11-01¶
Added¶
- A
Referencecan declare a composite foreign key.Reference(fields=("stage_id", "source_id"), references=("id", "source_id"), ...)pairs each field with a parent field, andset_null_fields=names the onesSET NULL (…)clears. The undeclared-key warning suggests these forms, so it can be silenced.
Changed¶
-
Reference.fieldisNoneunless the reference is one field holding the parent's id (behaviour change). Readfieldsandreferences. The mock's restrict refusal and Postgres's reference refusals carryfieldsas a list in their details instead offield. -
Mock state snapshots written by 0.18.0 are refused at load (behaviour change). The references recorded with the rows now carry composite keys; delete the old file to start fresh.
-
ModelCodec.transform_manytakesupdate=, fields set on each source's dump before it is validated (Breaking). A codec implemented outside forze must accept it: a bulk write given ids passes each row's id through it. -
A set-based
upsert_manyon Postgres spends less time in Python. In the bundled benchmark (4000 rows at 40, 200 and 2000 per call, local database) a fresh load runs 19–33% faster and an unchanged reload 12–20%. A bulk ensure or upsert builds rows faster on every backend when validation doesn't read the id.
Fixed¶
-
Postgres startup no longer warns about every history document on the
databasebookkeeping strategy. It warns only when no trigger on the write relation fires onINSERT, so created documents would get no history row. -
Postgres bookkeeping validation counts only enabled
BEFORE UPDATE … FOR EACH ROWtriggers (behaviour change). Startup refuses adatabase-strategy write relation with only aBEFORE INSERT, statement-level, disabled or replica-only one. On a partitioned table, one counts on the table or on every partition. -
A hub search aggregate's metric filter can name an
extra_aggregatable_fieldscolumn. It was refused as off the read model. Postgres checks the filter's operators against the hub relation's column type; the search's ownfiltersstay on the read model. -
Postgres
timestamp(p)andtimestamptz(p)ignore the session'sTimeZone(Breaking). An aware value stores its UTC wall clock and a naive one reads as UTC, in writes and filters.numeric(p,s)andchar(n)coerce as their bare types: a float tie may round differently, and NaN and infinity are refused. -
Postgres no longer rewrites an unchanged value its column rounds or pads. Updates and upserts compare a value as its
timestamp(p),timestamptz(p),time(p),timetz(p),numeric(p,s),realorchar(n)column stores it, arrays too. A resend no longer bumpsrevor conflicts at a stale revision. -
A Postgres
SerializedBykey locks on the value its column stores. Two writes whose key values the column rounds or pads alike take one lock, a create's sent value and an update's stored one included, and so doeshold.
0.18.0 - 2026-10-31¶
Added¶
-
SerializeOperation(group=...)shares one lock between operations. Operations naming one group take turns per key and tenant, keyed apart from operation names. Freeze refuses a group spread over several transaction routes (tx_lock_group_spans_routes), found through the newSharesTransactionLockprotocol. -
runtime_lifespan(nested=...)runs mounted apps' lifespans inside the runtime's scope. They enter in order once the scope opens; on shutdown the runtime drains, then they exit in reverse, then it tears down. An app mounting an MCP HTTP app passesnested=(mcp_app,)instead of writing its own lifespan. -
A mock derived source can aggregate child rows. A
MockDerivedRegistrysource with two required positional parameters gets(row, view), the view analytics resolvers get, soview.rows(spec)counts the caller's own uncommitted children. Existing(row)sources keep working. -
GuaranteeImplyinglets a domain model declare the guarantees its fields need. A tuple of guarantees in the model'simplied_guaranteesclass attribute is added to every document spec over it.implied_guarantees_of(model)reads it and refuses anything else asimplied_guarantees_invalid. -
A
Referenceguarantee declares a foreign key'sON DELETE, which the mock applies.Reference(field=, spec=, on_delete="cascade"|"restrict"|"set_null")on the child spec. Postgres schema validation checks the key and warns about undeclared cascading ones; Mongo and Firestore refuse it. -
A durable recovery worker can claim only some functions, so slow ones get their own worker. Register one with
queue="llm", then passqueues={"llm"}todurable_recovery_background_lifecycle_step. Existing tables add the new(status, name, created_at)index first. -
run_cpu(..., isolated=True)runs pure-Python compute outside the event loop's GIL. The runtime holds a lazyProcessPoolCpuExecutorbeside its thread pool (isolated_cpu_workers=), or anisolated_cpu_executor=such asInterpreterPoolCpuExecutoron Python 3.14. Inputs must pickle; unbound, it refuses. -
The isolated executor has its own slot:
bind_isolated_cpu_executor,hold_isolated_cpu_executor,current_isolated_cpu_executorandisolated_cpu_executor_bound. They mirror the default executor's, but nothing bound or held givesNone.InlineCpuExecutor(pickled=True)adds the pickle round trip, for tests. -
attach_realtime_sse_routetakesroute_optionsandack_route_optionsto document its routes. Each sets the route'soperation_id,description,tags,openapi_extraand the like; other keys are refused. The query parameters now ship described, and the stream's 200 is documented astext/event-stream. -
attach_realtime_sse_routeserves a live-only stream without a mailbox. Leave outmailbox_factoryandcursors_factoryand it serves the hub's live tail with no replay, ignoresLast-Event-IDand attaches no ack route. It needs a hub, and one factory without the other is refused at attach time.
Changed¶
-
configure_cursor_signer(None)andconfigure_cursor_cipher(None)clear the context's own key (Breaking). The key an open runtime scope holds then applies again.bind_cursor_signer(None)andbind_cursor_cipher(None)still turn protection off for a block. -
A spec over a
NumberIdMixindomain declaresnumber_idunique by itself (Breaking). The mock refuses a duplicate, and startup needs the unique index, per tenant on a tenant-aware route. A declared uniqueness overnumber_idreplaces it; on Firestore, setimplied_guarantees = ()on the model. -
DocumentSpeckeeps whatguarantees=passes in a field nameddeclared_guarantees(Breaking). Passingguarantees=and readingspec.guaranteesstill work; the latter adds what the domain model implies. A numbered domain whose read model does not storenumber_idis now refused when the spec is built. -
The mock's
kill_manydeletes all of its rows or none (behaviour change). A missing id or a declaredrestrictnow refuses before any row is removed, as on Postgres. -
Mock state snapshots written before this release are refused at load (behaviour change). A snapshot now carries the declared references with the rows; delete the old file to start fresh.
-
DurableRunStorePort.claim_abandonedtakesfunctions=(Breaking). A run store implemented outside forze must accept it. The Postgres recovery indexes(status, created_at)and(tenant_id, status, created_at)are replaced by(status, name, created_at)and(tenant_id, status, name, created_at). -
run_cpureserves the keywordisolated(Breaking). A call that passedisolated=through to its function now selects the isolated executor instead; pass that argument withfunctools.partial. -
The Redis cache returns pre-encoded
bytesvalues verbatim, as the mock does (Breaking). It parsed them as JSON. Replicas on different releases miss or parse each other'sbytesentries, so flush the cache namespace once every replica runs the new release.
Fixed¶
-
run_cpuin a request offloads to the runtime's pool instead of running inline. A runtime scope holds its pool process-wide while open, the oldest scope's when several are. Newhold_cpu_executor; a closedThreadPoolCpuExecutorrefuses work (cpu_executor_closed). -
Postgres schema validation no longer warns that declared derived read fields are missing from the write relation. A field in
derived_read_fieldscomes from the read view, so the startup warning now names only read fields that are neither derived nor omitted. -
Startup accepts an index over
tenant_idplus the fields for a guarantee on a tenant-aware route. It keeps the guarantee per tenant, as the mock does, so Postgres and Mongo print that index when it is missing. The Postgres undeclared-index warning no longer suggests namingtenant_id, which a spec cannot. -
A Postgres hard delete refused by a foreign key raises
conflict, notnot_found(behaviour change).kill/kill_manyon a row a plain foreign key still references no longer reads as "Reference document not found";ON DELETE RESTRICTmaps toconflicttoo. -
A document cache entry that does not decode is a miss. The read refetches the document and logs a warning instead of failing. Crypto and configuration errors still raise.
-
The Postgres pool no longer hands out connections the server has ended (behaviour change). Each checkout now pings the connection first, one round trip, so a restart or failover no longer fails one request per pooled connection.
PostgresConfig(check_on_checkout=False)turns it off. -
Outbox staging follows the transaction, not the task (behaviour change). A root transaction reopens the routes a flush closed; rows a committed one left unflushed stay staged for the task's next flush. A rolled-back scope drops what it staged and stages again what its flush wrote.
-
Outbox events a child task stages stay in that task (behaviour change). A task started from another begins with a copy of its staging and no longer adds to the parent's buffer, so one task's rollback cannot erase another's events. Flush in the task that stages.
Security¶
- Cursor signing and encryption set on an
ExecutionRuntimeapply to every request (Breaking). Code outside the scope's task now mints and requires protected cursors, refused (cursor_protection_ambiguous) while open scopes hold different keys. Newhold_cursor_signerandhold_cursor_cipher.
0.17.0 - 2026-10-30¶
Added¶
-
Counters can expire: every write verb takes
ttl. An increment that creates a counter starts a fixed lifetime no later write extends, andresetreplaces it; a lapsed counter reads as absent. Postgres needs an optionalexpires_atcolumn, andcounter_capabilities(port).expiryreports support. -
LockoutConfig.lock_forkeeps a locked login locked past its window. The failure that reaches the threshold stores an unlock instant on a separate lock key. The login unlocks at the later of the window's end and that instant, andRetry-Aftercarries the later one. -
postgres_counter_purge_lifecycle_stepdeletes lapsed Postgres counter rows on an interval. It callspurge_expired()for each route inspecs, and binds each tenant in turn withtenants=. -
An agent or MCP tool can advertise and accept fewer query fields than the spec.
operation_tools,register_toolsandbuild_mcp_servertakequery_policies, a narrowerQueryFieldPolicyper operation. The description names only its fields, the field guard checks calls against it, and widening fails at build. -
Federated search takes
member_filters, a filter for one member's leg alone. It joinsfilterswith$andon that leg, and a hub refuses it. NewSearchMemberGrant,bind_search_member_grant,OperationDescriptor.membersandAuthorizesMembersback member authorization. -
MockDepsModule(row_locks="block")makes a held row lock wait under simulation, as Postgres does. Two transactions taking rows in one order now both succeed. Opposite orders abort one withdeadlock_detected, and DST reports ano_lock_order_deadlockviolation naming the rows. -
A document write can refuse any stale revision, history or not.
update(..., merge=False)andupdate_many(..., merge=False)raiserevision_mismatchfor anyrevbut the stored one, even for a no-op, instead of merging it.touch(pk, rev=...)andtouch_many(pks, revs=...)bump only rows still at it. -
A handler can take a spec's
SerializedBylock before it reads.hold({"order_id": order_id})on the document command port takes the lock its writes take, without writing, until the transaction ends. Refused outside a transaction, for a datetime, validator-rewritten or serialized key, and on Mongo and Firestore. -
SerializeOperation(key=...)makes invocations of an operation that share a key take turns, each until its transaction ends. It holdsctx.tx_locks(route):pg_advisory_xact_lockon Postgres, theSerializedBylocks on the mock. Freeze refuses it without a transaction route, and resolve on Mongo or Firestore. -
DuckDB serves trusted dynamic reads over a per-tenant Iceberg lake.
RoutedDuckDbClientgives each tenant a locked-down instance holding its catalog principal and the S3 credentials vended per statement, and serves nothing from one that fails its attestation. Wire routes withDuckDbDepsModule(dynamic_reads=...). -
attach_readiness_routetakes a runtime provider. Pass a zero-argument callable returning the runtime, orNonebefore it exists; it is asked on every request. No runtime, or a provider that raises, answers503 unavailable. -
runtime_lifespan(build=...)builds the runtime when the app starts. An app factory then imports without the runtime's environment, and each worker process builds its own. Wire routes withctx_dep=lifespan.get_contextand the probe withlifespan.runtime; a build that raises fails the startup. -
runtime_lifespan(drain_budget=...)drains onSIGTERMin every uvicorn worker. Underuvicorn --workers Nor gunicorn's uvicorn worker, each worker drains its own runtime while it still serves, then lets its server exit. An app that does not useruntime_lifespangets the same fromdrain_ahead_of_uvicorn. -
bind_max_in_sizeraises the$insize limit for one block of trusted code. Application code matching an id list it built itself no longer chunks it under the spec's limit, which still bounds every caller-supplied filter. A backend's own cap, such as Firestore's 30, still applies. -
A hand-written upload route can cap its read like the generated one.
forze_fastapi.routes.read_upload_capped(file, max_size)returns the multipart file's bytes, or refuses a file over the cap with the generated route's 422upload_too_large. -
serve_runtimedrains a process that serves no HTTP onSIGTERM. The counterpart ofserve_uvicornfor relays, consumers and workers holds the runtime's scope open, runsruntime.drain()on the firstSIGTERMorSIGINT, or whenstopis set, then tears down. -
Generated routes describe the parameters they declare in OpenAPI. A document's
idandrev, a storagekey, and the tenancy and API-keyids need noapp.openapipatch. ARouteBindingof yours takesparam_descriptions. -
apply_openapi_conventions(app, tag_groups=...)writesx-tagGroups, the sidebar groups Scalar and Redoc render. A listed tag no operation uses is left out with a warning, and tags no group lists join a trailingOthergroup. -
attach_tenancy_routestakes thecookies=carrierattach_authn_routesdoes. A tenant switch on cookie auth then rotates both session cookies and strips the tokens from its body, behind login's CSRF gate, so the browser's session takes the new tenant. -
A declared HTTP service can stream a response.
HttpServicePort.stream(op, args)yields the body as it arrives, governed asinvokeis, and raises a rejection before the first chunk.HttpClient.streamandRoutedHttpClient.streamdo the same at the client. -
A vendor SDK's requests can go through a declared HTTP service.
http_service_transport(ctx, spec)gives anHttpServiceTransportforhttpx.AsyncClient: requests stay on the service's route, its headers replace same-named ones, and deadline, trace and egress tag apply. CustomHttpClientPorts must addsend. -
Agent tools can state the filter grammar once.
operation_tools(shared_filter_grammar=True)strips it from tool schemas as MCP does;OperationToolset.filter_grammarholds it for the system prompt.forze.application.contracts.queryinggainsfilter_grammar,without_filter_grammar,takes_filter_expression. -
An
openai_chatroute can serve a host that refuses a schema constraint, such as DeepSeek.structured_output="json_object"puts the output model's schema in the system message, asks for valid JSON, and refuses a reply that does not fit asinference_output_mismatch.json_schemastays the default. -
forze_identity.tenancy.applicationexportsTenancyResourceName. It matchesAuthnResourceNameinforze_identity.authn.application. -
A throttled HTTP error tells the client when to come back. Its
retry_after(seconds) or elseretry_at(awaredatetime) detail becomes aRetry-Afterheader, whichAllowlistCORSMiddlewareexposes; other details stay withheld. No header when neither key is usable. -
A locked login's 429 tells the client when to try again. The
login_lockedthrottle carries the window's end asdetails["retry_at"], which FastAPI sends asRetry-After; the body still withholds details.LoginLockoutGuard.locked_until(digest)returns that instant, orNonewhen the login is not locked. -
An
unavailableerror kind answers 503 for something switched off right now.exc.unavailable(...)is retryable, keeps its summary and code on every transport, and is not logged as a server error; details stay withheld. Aretry_afterorretry_atdetail setsRetry-After, as on a throttle. -
Retries wait out a
retry_athint too. A throttled or unavailable error's awareretry_atdetail now delays the resilience, saga and durable retries until that instant, read as theRetry-Afterheader reads it.retry_hint(error)inforze.application.execution.resilience.backoffreturns the hint as given. -
RoutedDuckDbClient(ca_cert_file=...)lets a lake session use HTTPS on DuckDB 2. The file must hold a PEM certificate, and any statement on the instance can read it. A dynamic read calling a table function other thanrange,generate_seriesorunnest, naming a path, or naming an unattached catalog is refused. -
A cancel after an operation's commit point is recorded on every surface. The operation logs a
commit_ambiguouswarning carrying its name and invocation ids, adds aforze.commit_ambiguousspan event, and re-raises the cancellation.forze ops callstill reports it as an error. -
A transaction manager can say its store refused writes in a read-only root. Implement
ReadOnlyAware.read_only_enforced(), as Postgres and the mock'sjournalandstrictmanagers do; that root then marks no commit point. -
A derived read field can resolve an expanded reference.
DerivedReadField(source=..., via=..., fields=("id", "name"))makes the mock build a nested object from the one joined row, underfield's one-hop andoptionalrules. An optional join that finds no row yieldsNonefor the whole object. -
run_derived_fidelitychecks the mock's derived fields against the real view. It loads one seed into a real backend and the mock, reads the same ids through both, and reports each derived field that differs, per row. Import it fromforze_dst.conformance. -
forze_fastapi.run_startup_checks(app)runs every FastAPI startup check in one call. It checks the app and each Starlette or FastAPI app mounted beneath it, andruntime_lifespancalls it. An app with its own lifespan calls it there, on the outermost app: Starlette never runs a mounted app's lifespan. -
Postgres startup warns about a unique index or
EXCLUDEconstraint that no guarantee declares. The in-memory store does not enforce it, so a test run accepts writes the database refuses. It suggests the declaration; the primary key, an index over its columns and framework-owned specs are left out. -
postgres_document_schema_specs(registry, deps)validates every bound Postgres document. It pairs each route with its cataloguedDocumentSpecforschema_specs, and refuses a route with no such spec or a read-write route over a read-only one.exclude=skips routes; a per-tenant one is skipped with a warning. -
The mock can compute an analytics query from its current documents. Register
fn(view, params)withMockDepsModule(analytics=MockAnalyticsRegistry().on(spec, query_key, fn));view.rows(spec)reads stored rows as the caller's transaction sees them. A query with no resolver keeps its seeded rows. -
forze_dst.conformance.compare_analytics_querychecks a mock resolver against the SQL it stands in for. It loads one seed into both engines, runs the query on each and lists every differing row.query_invariant(spec, query_key, holds=...)turns the same query into a DST oracle. -
SSE- and WebSocket-only apps get offline delivery without Socket.IO.
RealtimeMailboxWriterand its lifecycle step fill the mailbox those routes replay from, using the gateway's store code. They and the signal sources live inforze.application.execution.realtime;forze_socketiore-exports what moved. -
A
SystemInvariantcan compare two read-sets per scope.against=Measure(...)bounds a sum by another sum or by a parent's field, andholdstakes both aggregates. Every scope with rows on either side is checked, so an order left with no lines breaksorders == 0 or lines >= 1. -
Stored files can stream and presign downloads, opt-in.
StoredFileKitSpec(stream_downloads=True, presign_downloads=True)registersdownload_streamandpresign_download; guard each likedownload. Generated surfaces refuse the sensitivepresign_download. A route streams withstreamed_download_response. -
OperationDescriptor(streams=True)keeps a byte-stream result off JSON surfaces. MCP andforze opsleave such an operation out by default; they, agent tools andattach_operation_routesrefuse it by name. The storage and stored-file kits'download_streamset it (behaviour change).
Self-hosted durable functions:
-
A cron trigger declares a timezone and an input.
DurableFunctionCronTrigger(expression, tz=..., input=...)registers its schedule with both; an unknown zone or an input not of the spec'sargs_typefails the declaration. A changed input alone keeps the next fire; Inngest mapstztoTZ=and refuses an input. -
Finished runs can be purged past a retention window.
DurableRunAdminPort.purge_finished(older_than, ...)deletes up tolimitruns that landed before the window, with their step journal, freeing their idempotency keys.durable_run_retention_lifecycle_stepsweeps; a hand-built store needsstep_config. -
An enqueue can coalesce onto the run waiting unclaimed.
enqueue(..., coalesce_key=...)joins apendingrun never claimed under that key, dropping the submit's own input andrun_at, and records a new run once it is claimed. Migration: add thecoalesce_keycolumn and its partial unique index first. -
An operation-backed function can run as a service principal.
register_operation_functions(..., identity=DurableServiceIdentity(...))binds it around the operation, soAuthnRequired, authz and audit see it; the identity must be acknowledged as unverified. Without one, a guarded operation refuses the run. -
A presigned download can name the file it saves as.
presign_download(key, expires_in=..., response_filename=...)on the storage query port signs an RFC 6266Content-Dispositioninto the URL on S3, GCS and the mock. The stored-file kit'spresign_downloadpasses the stored filename. -
Stored files can serve byte ranges, opt-in.
StoredFileKitSpec(range_downloads=True)registersdownload_range, withdownload's row checks. JSON surfaces leave it out.attach_stored_file_download_routeservesGET {id}/contentand answersRangeand conditional requests as the storage download route does. -
Stored files can upload straight to the bucket, opt-in.
StoredFileKitSpec(direct_uploads=True)registersinitiate_upload,presign_upload_partandcomplete_upload: a key the kit mints, then a create-only presigned PUT or parts. Completion checks size, type andmax_bytes, and fails the row on a mismatch. -
stored_file_pending_sweep_lifecycle_stepfails abandoned stored-file uploads and frees their bytes. A rowpendingwith no update forolder_than(24 hours) is markedfailedat its revision, then its multipart uploads, object and key go; a failed cleanup is retried.presign_upload_parttouches the row. -
A presigned upload and a multipart completion can refuse to replace an object (Breaking).
create_only=True(never withif_match) makespresign_uploadsignIf-None-Match: *orx-goog-if-generation-match: 0, andcomplete_uploadrefuse an existing key. A customObjectStorageClientPortmust accept it. -
A durable service identity can be an API key verified on every run.
DurableServiceIdentity(api_key=SecretRef(...), authn_spec=...)runs the body as the key's principal, a member of the run's tenant. A revoked, expired or delegated key fails the run, within cached key states'ttlif a revocation's write is lost.
forze ops for people at a shell and for backfills:
forze opsgains typed flags, JSONL batches and shell completion.call OP --field VALUEtakes a flat-scalar input as flags;--batch @PATH|-runs one call per line in one runtime, stopping at the first failure;OPcompletes in the shell. An unknown option afterOPnow exits 2 with an error object.
Changed¶
-
Postgres schema validation reports every refused document in one
postgres_schema_documents_refusederror (behaviour change). Itsdetails["failures"]lists each; a single refused document still raises its own error. A connection or partition error stops the checks and raises the refusals found before it. -
incr(by=0)no longer creates the counter it reads (behaviour change). An absent counter reads 0 and stays absent, solist_countersand a portable export no longer show it at 0. -
A draining instance refuses new work with a 503, not a 429 (behaviour change). The drain gate now raises
unavailablewithcode="draining", so a load balancer retries elsewhere, and no resilience retry repeats it, whatever itsretry_on.is_draining_refusalstill matches it, by code. -
HTTP error bodies carry the error's
codeandkind(behaviour change). The body is{"detail", "code", "kind", "context"}, so a client tellsrevision_mismatchfrom other 400s; a whole-body comparison breaks. An unhandled 500 now also sendsX-Error-Code: core.internal. -
The DuckDB lake session no longer disables any filesystem (behaviour change). On 1.5 as on 2 it relies on an allowlist of the tenant's prefix, the catalog's tree and the token endpoint, plus attested settings. That tree is shared by every tenant, and analytics SQL can send it unauthenticated requests.
-
The
duckdbextra takes DuckDB 1.5 or 2 (behaviour change). It now requiresduckdb>=1.5.6,<3, so releases before 1.5.6 no longer install with it.DuckDbClientandRoutedDuckDbClientrun on both majors with the same code. -
HttpServicePortrequiresstream(behaviour change). A hand-written implementation or test stub must add it, or it fails type checks andisinstanceagainst the runtime-checkable port. -
A durable body never inherits the caller's principal (behaviour change).
run_nowinside a request now runs an untagged run as no one, as recovery always did: audit,CreatorIdMappingStepand progress jobs record no actor, and guarded saga steps refuse. Name a principal withDurableServiceIdentity. -
Durable store ports gain members (behaviour change).
DurableRunAdminPortrequirespurge_finished,DurableScheduleStorePortrequiresset_input, andDurableRunStorePort.enqueuetakescoalesce_key, whichDurableFunctionRunner.enqueuealways passes. A hand-written store must add them. -
local_identity_depswires no tenant resolver for a config that maps no tenant (behaviour change). There,forze ops call --tenantgets400 tenant_request_unverifiable, not401 tenant_mismatch. A tenant on an API key counts and resolves, also in a config built directly. -
MCP and agent tool descriptions group filterable fields by type (behaviour change).
describe_query_discoverystates the operators every field shares once, then each further operator set once, with its fields under their own types.QueryDiscoveryandx-forze-queryare unchanged. -
A
SystemInvariantsums aDecimalfield exactly (behaviour change).holdsgets aDecimal, not afloat, so float arithmetic in it (total * 1.1) raisesTypeError; the per-commit oracle sums one instead of refusing it. A count is anint, printed3not3.0; other sums stayfloat. -
The dynamic-read write refusal no longer names a READ ONLY transaction. The message is engine-neutral and the details carry the engine's
statement_typewhere it has one. ADuckDbClientstatement timeout now carries the codeduckdb_query_timeout. -
An encrypting storage route refuses the stored-file kit's direct operations at resolve (behaviour change).
presign_downloadand the direct uploads resolve storage as the newStorageSpec(direct_transfer=True), which a client-side-encrypting route refuses (configuration), socheck_wiringreports them. -
The stored-file kit's buffered
downloadsetsstreams=True(behaviour change). JSON cannot carry a binary file, so MCP andforze opsfailed on one. Both now leavedownloadout by default, and MCP,forze ops, agent tools andattach_operation_routesrefuse it when named. -
StorageSpecgainsdirect_transfer, so every storage spec's inventory fingerprint changes (behaviour change). Portability archives andmigratefingerprints taken before the upgrade no longer match; take them again after it. The new field defaults toFalse. -
StorageUploadSessionPortrequireslist_uploads(Breaking).list_uploads(key)returns the sessions open at a key, so a cleanup that lost their handles can abort them; S3, GCS and the mock have it. A custom port, or anObjectStorageClientPort(list_multipart_uploads), must add it.
Fixed¶
-
A storage download no longer fails with a 500 on an odd
Range. A position in non-ASCII digits is malformed and serves the whole file; one too long to convert reads as past the end, so it answers 416 or clamps. An upload'sContent-Lengthis read the same way. -
Firestore
update_manyandtouch_manywork inside a transaction. They read every row before writing any, as a Firestore transaction requires; a second row used to fail with a read-after-write error. -
Postgres schema validation accepts the required fields of a view-backed read model. Postgres records
NOT NULLon tables only, so a read relation that is a view or a materialized view no longer has its nullability checked. Write and history relations keep the check. -
The login lockout writes a key only for a failed attempt or a reset (behaviour change). Each lives a window plus a minute from creation; Redis drops it, Postgres, Mongo and Firestore need a purge or TTL setup. On a Postgres table without
expires_atkeys get no ttl and pile up, with a warning per table. -
The login lockout no longer grows its counter store without bound (behaviour change). A check writes no key, and a window's bucket lapses a minute after the window; Redis, the Postgres purge step, a Mongo TTL index or a Firestore TTL policy reclaims it. Without Postgres
expires_atit writes no ttl and warns. -
S3 and GCS streams and encrypted ranged reads no longer mix two versions of an object (Breaking). A replacement mid-read, or a GCS metadata-only change, fails the next read with
conflict(READ_PRECONDITION_FAILED_CODE). A customObjectStorageClientPortmust acceptif_matchondownload_range_bytes. -
HttpClientreads a compressed response. A gzip or deflate body, and with no cap a zstd or brotli one, no longer failsrequest, or a rejected response fromstream, as an infrastructure error.RoutedHttpClientand declared services are fixed with it. -
A federated result snapshot replays only for the same members and member filters. Its fingerprint now covers each member's weight and leg filters, and a thin replay drops a row its member's filters no longer match.
-
A datetime
SerializedBykey locks its UTC instant (behaviour change). One owner's create and update no longer lock apart by offset; naive reads as UTC (right in a UTC session), an out-of-range instant is refused. Drain writers of naive or non-UTC keys while deploying, or accept a brief unlocked window. -
An operation span stays clean on a failure the caller can handle (behaviour change). OpenTelemetry's own handling marked every failed operation's span
ERRORwhatever its kind and recorded a server error's exception twice. Now only a server-side failure setsERROR, with one exception event, as port spans do. -
A durable schedule with no timezone fires in UTC whatever zone
nowis given in. A non-UTCnowpassed toDurableScheduler.putortickmoved its fires by that zone's offset. -
A timezoned durable schedule no longer fires twice when the clocks fall back. A wall time the clocks repeat fires at its first occurrence only, unless the expression runs every hour; a wall time they skip still fires once, when the gap ends.
-
A warning logged before
configure_loggingcarries the invocation's ids. The fallback renderer now merges the bound logging context, as the configured chain does. -
register_scalar_docsloads the document the app actually serves (behaviour change). It fetched/openapi.jsonwhateveropenapi_urlsaid, so a versioned app got an empty/docs. It now defaults toapp.openapi_urland takesopenapi_url=; an app serving no document fails at startup unless it passes one. -
forze[duckdb]works on a fresh install of DuckDB 2.0. 2.0 removedfetch_arrow_table, so everyDuckDbClientquery failed; the client now fetches withto_arrow_table, which 1.5 has too. -
A principal in several tenants reaches the tenant selector after a password login (behaviour change). With no
tidthe resolver binds no tenant, as for no membership, instead of refusing every request withtenant_ambiguous. Tenant-scoped work still refuses it withtenant_required. -
An authz route with
require_invocation_tenantrefuses a caller with no tenant bound with401 tenant_required. It answered500, unlikeTenantRequiredand the tenant-aware stores. -
TenancyDepsModulerefuses atenant_resolversoverride for a routetenant_resolverdoes not name (behaviour change). It registered nothing, so the first request failed withTenant resolver is not registered; now the module fails when built. -
AuthnDepsModulerefuses an override that would never be registered (behaviour change). That is one for a routeauthndoes not name, or a verifier override for a method its route does not enable. It registered nothing, so the route silently kept its default. -
A query interrupted after its read no longer reports
commit_ambiguouson Postgres or the mock'sjournalandstrictmanagers (behaviour change). When its read-only transaction was the only one it entered, it reportsdeadline_exceeded(CLI exit 6) orinterrupted, and is safe to re-run. -
A Postgres dynamic read no longer collapses duplicate column names.
SELECT a.id, b.idcame back as oneidholding the second value. A result with rows now fails withdynamic_read_statement_invalid, naming the duplicates; an empty one still returns no rows. -
A timed-out
DuckDbClientstatement returns at its timeout. The caller no longer waits for a hung HTTP request to give up, about two minutes on the Iceberg path; the worker keeps its thread until DuckDB unwinds, andclosestill waits for it. -
A cancelled
DuckDbClientcaller now interrupts its statement. The statement ran to completion and kept its executor thread; it now stops as a timed-out one does. -
Two quick
SIGINTs toserve_uvicornnow give the drain phases up. A second one handled in the same loop turn as the first cancelled the drain before it started, so the forced exit still ran every phase at length in teardown. -
Agent tool names are ones model providers accept (behaviour change).
operation_toolsnamesorders.listasorders__list(provider_safe_tool_name) or asname=says; the old name, the op key, still dispatches. A name outside^[A-Za-z0-9_-]{1,64}$, or one two operations share, is refused at build time. -
An aliased input field no longer loses the caller's value.
forze ops calland agent tools refuse a name the DTO's config does not read, which pydantic dropped, running the operation on the default. MCP reads each field by the name its tool signature advertises, so an aliased field is no longer dropped there. -
Each Inngest function call mints its own execution id (behaviour change). The producer's id becomes the causation; the correlation id is kept, or derived from the run. An idempotency claim belongs to one handler entry, so one a crashed entry left pending blocks a retry until its TTL, as on Temporal.
-
A malformed Forze id header no longer fails a Temporal task. An execution or correlation id header that is not a UUID is dropped, and the workflow or activity runs under freshly minted metadata. Principal, actor and tenant headers decode as strictly as before.
-
Duplex realtime commands get their own invocation metadata (behaviour change). A
cmdframe onattach_realtime_ws_routeand a Socket.IO command event each mint a fresh execution id and, with no caller id to carry, a fresh correlation id, so the operation's logs, spans and integration events carry them. -
A Socket.IO command event runs under its connection's tenant (behaviour change). On a connection
attach_realtime_connectionadmitted, an event bound the principal but no tenant, so a tenant-aware operation failedtenant_required. It now binds the tenant resolved at connect, as the WS route does. -
A Postgres update with a changed
jsonbfield no longer answers500on the generated update route (behaviour change). Its diff holds the values the caller set, as on the other backends: noJsonbwrapper, aStrEnumstays a member and a datetime is as given, not coerced to the column type. -
A Postgres document field stored in a native
jsonb[]orjson[]column can be written. Create, update and upsert, single and batch, failed withcannot adapt type 'dict'; each element is now sent as JSON, aNoneone as SQLNULL, and reads back as written. -
A Postgres locking read works when the read relation differs from the write relation (a view, typically).
get,find,projectandselectwithfor_updatefailed on a view that joins withLEFT JOIN. They now lock the row on the write table, honouringnowaitandskip_locked, then read. -
Postgres startup accepts a plain unique index for a
skip_nullguarantee. Postgres already treats a tuple holding a null as distinct, so no predicate is needed, and a deferred constraint keepsskip_nullat commit. Awhereruling out nulls in a column no longer demandsNULLS NOT DISTINCT. -
Postgres startup no longer warns about a
tenant_idcolumn the document model maps itself. Such a table stores the tenant as data, as identity's sessions and tenant memberships do, so the column is not an unused isolation column. -
A filter on an
Annotatedfield is checked against the type it wraps (behaviour change).Annotated[str, ...] | Noneand its list elements resolved to no type, so discovery advertised every operator and$gtpassed. They now get their type's operators; a string bound on an annotated number or date is cast. -
Filters and sorts see through
Annotatedfields (behaviour change).Annotated[str, ...] | Noneand list elements took any operator unchecked; now they get their type's and a string bound is cast. A sort into an annotated submodel resolves; Postgres compares annotatedDecimalelements to an int numerically. -
Backends and spec checks see through
Annotatedandtypealiases (behaviour change). A Postgres filter viaAnnotated[M, ...] | Nonereturned 500; BigQuery parameters andforze opsflags misread them. A schema, index, null or graph-key mismatch fails at startup; a mock seed nulls an aliased optional link. -
The mock matches a plain
Enumfield by its value, as Postgres does. A filter naming"open"matched no row holdingStatus.OPENunlessStatuswas aStrEnum. Equality, membership, ordering and set operators now compare the value, in arrays and nested objects too, as do the DST oracles. -
A plain
Enumdocument field is stored as its value on Postgres, MongoDB and Firestore. Postgres wroteStatus.OPEN, as it did for a(str, Enum)class, which no read could decode; MongoDB and Firestore refused it. AStrEnumorIntEnumis stored as before. -
Analytics ingest stores a plain
Enummember as its value, as a document row does (behaviour change). Postgres wrote its name (OPEN), and ClickHouse refused it. Rows ingested earlier keep the name until rewritten. -
A download's
Content-Dispositionencodes/infilename*. The generated storage download routes andstreamed_download_responseencode every character outside RFC 5987attr-char, so a name such asq3/rëport.pdfstays valid and clients keep it. -
GCS
list_partsreports each part's stored size. It reported0for every part, so a caller resuming or bounding a multipart upload could not tell how much had landed. -
S3 presigned URLs are always signed with SigV4 (behaviour change). With no region configured, botocore fell back to legacy SigV2, which newer AWS regions refuse and floci serves as anonymous.
S3Config(signature_version=...)still overrides. -
If-None-Match: *answers 304 for an object without an ETag. The streaming storage download route served it a 200; per RFC 9110 the wildcard matches any existing object, on the stored-file download route too. -
A password change or reset racing its principal's deactivation no longer fails on
revision_mismatch, nor does the deactivation. In a Postgres transaction they lock the account (areadview must acceptFOR UPDATE), so the second waits; elsewhere the deactivation rereads a stale credential and closes it. -
In-place search and analytics read a document's sealed fields. Name the document with
sealed_byon the search, hub or analytics spec; underbinds_record_ideach row must also carry itsid. Meilisearch and analyticsingestrefusesealed_by, since they seal their own rows. -
Spec-time encryption refusals carry the documented code. Analytics
binds_record_idwithoutsealed_by, orsealed_bywithingest, raisescore.analytics.encryption_wiring. Procedurebinds_record_idgetscore.procedures.encryption_wiring; a hub member's othersealed_by,core.search.encryption_wiring.
Security¶
-
An untagged caller can no longer match a tenant's durable idempotency key or schedule id (Breaking). Every untagged key and id, deployments without tenancy included, is now stored as
:{key}; rows written before keep the old form. A resubmit of an in-flight key written before the upgrade starts a second run. -
A document query policy checks
$havingand aggregate sorts against their source fields (Breaking). Refused now:$havingover a field that is not filterable, and a sort by an alias whose field is not sortable. Allowed now: a sort by a$countalias, or by an alias whose field is sortable. -
A guarded hub or federated search authorizes each member it reads (Breaking). New
AuthzSearchMemberschecks each member's read action; the adapter then refuses a request naming a forbidden member and skips one it only reaches. The registry refuses to freeze a guarded search without that search's hook. -
AuthzDocumentScopeWrapin an operation's outer scope applies its row filter (behaviour change). A wrap bound withbind_outer().wrap(...), as the authz recipe does, now hands the handler the arguments it passes on, so a scoped list returns only the rows the policy allows. -
max_response_bytesbounds a compressed body's decoded size. (behaviour change) Decoding stops one byte past the cap, so a small compressed body cannot expand beyond it. A client with a cap asks only forgzipanddeflate, and refuses any other content coding or a truncated compressed body. -
A realtime connection needs a tenant once tenancy is wired (behaviour change). Socket.IO, WebSocket and SSE refuse one without a tenant (
tenant_required), and Socket.IO an anonymous one, instead of admitting it to the untenanted rooms. Socket.IO checks this at the connect when given the newctx_dep. -
A stale access cookie could keep a cookie-mode logout from ending the session.
runtime_lifespannow refuses an app whoseanonymous_pathsmisses a cookie-mode login, refresh or logout route (check_authn_cookie_paths, behaviour change). -
bypass_pathsandallowed_websocket_pathsadmit more paths (behaviour change). On a mounted FastAPI app or under a server root path, an entry matches the full path or the path relative toroot_path, asanonymous_pathsdoes.root_pathmust come from server configuration, never from a request header. -
The FastAPI startup checks cover every mounted Starlette or FastAPI app (behaviour change). The boot fails on a bypass reaching a mounted generated route, on a bypass or websocket allowlist entry that could reach a mounted app they cannot inspect, and on a mounted FastAPI app overriding its
root_path. -
Postgres dynamic-read routes refuse an unknown
provenancevalue (Breaking).PostgresDynamicReadConfigaccepts only"trusted"or"untrusted"(postgres_dynamic_read_provenance_invalid); a config with any other value now fails at startup. Newvalidate_dynamic_read_provenance. -
The storage kit's
presign_downloadis sensitive (Breaking). MCP,forze ops, agent tools and generated routes refuse it, soattach_storage_routesno longer serves it; an MCP server orOpsAppover the storage registry needsoperations=without it. Trace capture masks presigned URLs. -
A tenant-sharded realtime consumer refuses a stream route that is not
tenant_aware(behaviour change).TenantShardedSignalSourceandrealtime_sse_sharded_tail_lifecycle_stepfail withtenant_aware_required, so a signal is never delivered or stored under another tenant. Newrequire_tenant_aware. -
A nested identity scope no longer logs the outer principal.
bind_identityandbindnow clear the principal, actor and tenant log fields they do not set, and restore them when the scope ends.
0.16.2 - 2026-10-26¶
Added¶
forze opsruns your operations from a terminal, cron or a Kubernetes Job, governed as over HTTP. Declare anOpsAppwith a registry, aSERVERLESSruntime factory and an identity.callverifies an API key, resolves the tenant, and prints one JSON document with a stable exit code.
Fixed¶
-
Postgres
jsonb[]andjson[]arrays treat a SQL NULL element as JSON null. ANonemember of an element$innow matches it, and one of$ninnow excludes it, as in the in-memory evaluator. -
MCP reads an API key and its tenant as the HTTP edge does. A bearer ending in
:is a prefix with an empty secret, not the bare key before it, and a verified key's tenant is bound when the tenancy resolver has no binding for the principal. -
An outbox relay wired to a missing or mismatched destination fails at wiring. The background relay step checks its destination when built, and the refusal is now a
configurationerror codedoutbox_relay_misconfigured(HTTP 500) for directOutboxRelaycallers too. It was aprecondition, logged every batch.
0.16.1 - 2026-10-23¶
Added¶
-
A Postgres vector search can rank by stored vectors alone:
VectorEngine.embeddings_nameis now optional. Without it no embeddings provider is resolved, and the search ranks by aquery_vectorand refuses a text query with codesearch_query_vector_required. -
A Mongo vector search can rank by stored vectors alone:
MongoVectorEngine.embeddings_nameis now optional. Without it no embeddings provider is resolved, and the search ranks by aquery_vectorand refuses a text query with codesearch_query_vector_required.
Changed¶
-
A Postgres vector search no longer formats its query vector for the fingerprint: without result snapshots the fingerprint is skipped, and with them a
query_vectoris keyed by a digest, saving about 0.25 ms per 1536-wide search. Snapshots taken for aquery_vectorbefore the upgrade miss once. -
A Postgres ranked search reads its relation once: when it is both read relation and heap and no highlights are asked for, the ranked CTE carries the page's columns instead of a join back, so a view is planned once. A filter-first text search with no snapshot to write keeps only
offset + limitcandidates. -
Postgres element quantifiers read their array once.
$any,$alland$nonename the array column a single time, so a view column computed by a correlated subquery runs once per row, not once per reference.$anyon a JSON value that is not an array no longer fails withcannot get array length of a scalar.
Fixed¶
-
Postgres compares a boolean or a number in an untyped JSON array. A quantifier field the read model leaves untyped (
list[dict]) compares it as JSON instead of failing ontext = boolean, and refuses aDecimal, date or UUID operand;nested_field_hintstypes such a field by its full path (offers.price). -
Postgres element quantifiers agree with the in-memory evaluator on missing keys and non-object elements.
$allnow fails an element whose predicate is NULL (a missing field, a null element), and$neq,$ninor a nested$all/$noneno longer match a scalar or null element. -
On an untyped JSON array element, a
Nonemember of$in/$ninmatches a stored JSON null and an integer past int64 compares instead of failing; a NaN or infinite float is refused withquery_feature_unsupported. -
Postgres filters a scalar array stored as JSON. A quantifier on a
jsonbcolumn holding["a", "b"]compares each element as JSON by the operand's type instead of failing with invalid json orjsonb = smallint. ADecimalarray no longer fails on a non-numeric element, and a datetime operand is refused. -
Postgres native arrays treat a NULL element as the in-memory evaluator does. A
Nonemember of an element$innow matches it, and one of$ninnow excludes it. -
A Postgres
undefined_functionerror (SQLSTATE 42883) now says no function or operator matches the argument types, not that the statement names an object that does not exist. It keeps thepostgres_schema_mismatchcode.
Security¶
- A validation error no longer echoes the caller's input: FastAPI request validation, assembled route payloads and dynamic-read row checks scrub validator messages, and an invalid Inngest event no longer sends its payload to the platform. A filter refusal still names the bad key, as type
query_expression_invalid.
0.16.0 - 2026-10-22¶
Added¶
-
CoreException.expose_detailswithholds an error's details from the client envelope, whatever its kind. -
CircuitBreakerStrategytakesfailure_onandignore_on: which error kinds count as breaker failures and which are not recorded. The defaults keep the previous classification: infrastructure and timeout fail, throttled and concurrency are ignored. -
A durable run can retry a retryable failure.
retry=RetryStrategy(...)onDurableFunctionRunner,durable_kits_depsor one registered function sends a run raising aretry_onkind back toPENDINGafter the backoff, counted asretrying; its journal replays. Breaking for own run stores (mark_retry). -
Failed durable runs can be requeued.
DurableRunAdminPort.requeue_failed(run_ids=, name=)returns the namedFAILEDandTIMED_OUTruns in scope toPENDINGunder their own id, so their journaled steps replay, on Postgres, Mongo and the mock. Breaking for own admin ports. -
A warm API key skips the eligibility read too. With revocation states on the authn and authz kernels, a key whose cached state recorded its principal's current epoch is admitted without reading Postgres, as a token is. New
ApiKeyEligibilitySkip. -
Vector search can rank by a vector you already hold.
SearchOptions(query_vector=...)ranks pgvector or Atlas Vector Search with a blank query; other searches refuse it. A custom vector adapter passescapabilities=,backend=andvector_dimensions=tosearch_options_for_simple_adapterto accept it. -
Durable schema reference. A new page lists the Postgres tables and Mongo collections the durable stores use, with their required and recommended indexes and the migrations between releases. The concurrency page now states that a stale-revision precondition is not retried for you.
-
Hub searches aggregate.
aggregate_search/aggregate_search_pageon a Postgres or in-memory hub group every merged match of its members, legs uncapped. NewHubSearchSpec.extra_aggregatable_fieldsnames hub-relation columns outside the read model that an aggregate may also group by or measure. -
upsert_manycan mirror a source and keep values a null would erase.UpsertItem.mirror(id, payload)updates with the create payload;skip_none=Trueleaves out each top-level field an update sets toNone. A set-based upsert is now judged on the fields the items' updates write. -
IssuedApiKey.secretreturns the raw key of a freshly issued API key. It stays out ofrepr()andstr(). -
attach_authn_routesno longer needsns=. Withoutns=orresource=it uses the one namespace in the registry holding itspassword_loginorrefresh_tokensoperation,spec.default_namespaceforbuild_authn_registry(spec), and refuses to guess. -
Overlapping refreshes need not revoke a session.
RefreshTokenConfig(reuse_grace=...)refuses a token rotated within the grace, its successor unused, or one losing a concurrent rotation, asrefresh_superseded(eventREFRESH_SUPERSEDED), revoking nothing. The default0keeps strict reuse detection.
Changed¶
-
forze prints nothing to stdout until structlog is configured (behaviour change): until
configure_loggingor your ownstructlog.configure, its events go to the same-named stdlib logger at its level, reaching only stdlib handlers, or stderr from warnings up by default.configure_loggingkeeps info output. -
A Temporal schedule describes and lists with its cron expressions as written (behaviour change), not the equivalent ones rebuilt from its calendars. Each is kept as its calendar's comment (
forze:cron=…); schedules from earlier versions or rewritten outside forze still report the rebuilt cron. -
UpsertItem.updateis now derived from the update passed andskip_none. Constructors are unchanged;attrs.asdictoutput and__match_args__differ. An override ofDocumentSpec.require_set_based_upsertmust accept the newupdatesargument. -
anonymous_pathsentries may be relative to the mount. WithSecurityContextMiddlewareon an application mounted under a prefix or served with aroot_path,/auth/loginnow matches/v1/auth/login; full paths keep working. -
A set-based
upsert_manyon Postgres takes less time: about 13% for calls of a few dozen rows and 30% for calls of thousands, on a fresh load or a reload. The rows written are unchanged.
Fixed¶
-
A
CoreExceptioncan be copied and pickled.copy.copy,copy.deepcopyand apickleround-trip raisedTypeError; they now keep every field, a subclass's own fields,expose_detailsset after construction, and the notes. -
Firestore raises a stale write's preconditions as Postgres and Mongo do (behaviour change). A future revision is
preconditionwithrevision_mismatch, and a revision whose history snapshot is missing ispreconditionwithhistory_not_found_retry, notnot_found. -
A refused Socket.IO connect renders its error as an error ack does (behaviour change). Its data is now
{"detail", "code", "kind"}, withcontextwhen exposed, and a non-disclosing denial posture applies: a denial or not-found about a covered resource type is refused asNot found. -
A failed Firestore history query is raised, not skipped. A
NotFoundfrom the API, such as for a missing database, was taken for a missing snapshot, soread_manyreturned fewer snapshots or none. -
A set-based
upsert_manyis refused for a domain with serializers of its own (behaviour change), as for validators: afield_serializer,model_serializer,PlainSerializerorWrapSerializerthat runs in python mode now raisesset_based_upsert_unsupported. -
The mock hub search sorts and projects as the Postgres hub does (behaviour change). It merges its legs' relevance, not their row positions, so ties follow the sort or
default_sort;project_searchandproject_search_pagework. Mock hub and federated streams refuse withquery_feature_unsupported. -
A circuit breaker whose half-open probes recorded no outcome (an ignored error kind, a cancelled call) no longer rejects every call until restart: after
break_durationit admits a new probe round. -
A logout everywhere followed by a deactivation in one transaction no longer deadlocks with a concurrent login (revocation states, policy principal on Postgres; deactivation through SQL or its document port).
-
PostgresConfigsession options no longer empty the pool. Withapplication_name,statement_timeout,lock_timeoutoridle_in_transaction_session_timeoutset, every new connection was discarded, and queries failed as a busy pool. They now take effect on every pooled connection. -
Postgres schema and programming errors are no longer retried (behaviour change). A missing object (
postgres_schema_mismatch), a refused privilege (postgres_privilege_denied), a syntax error or a PL/pgSQLRAISE EXCEPTIONis nowinternal, not retryableinfrastructure. -
Upstream throttles are
throttled, rejectionsinternal(behaviour change).forze_http: 429throttled(retry_afterindetails), 401/403infrastructure(now retried), 3xx, other 4xx, bad bodiesinternal. GCS, BigQuery, ClickHouse, S3, SQS, KMS throttles too;transientretries none. -
A bulk update or upsert mixing patch types no longer drops fields. Each patch in an
update_manyorupsert_manywas encoded as the first one's type, so the fields a subclass or other type adds were silently not written, on Postgres and Mongo. -
A document update keeps the nulls inside a mapping or
Anyvalue (behaviour change). ANonethere is stored as null, as create stores it, or refused where the value type admits no null. An update can no longer remove a key from a mapping. -
Resending an unchanged instant no longer bumps
rev. A documentupdate,upsertorupsert_manycompares a naivedatetimewith an aware one as UTC, either way round; on Mongo, finer than a millisecond is no change either. -
A stale-revision update no longer reverts a concurrent change (behaviour change). With revision history on, a field it resends with the value it read is not written; only what it changes lands. New
Document.historical_update. -
update_manywith revision history no longer fails on a row it leaves unchanged. Postgres raised a unique violation and rolled the batch back; Mongo wrote a duplicate snapshot. -
Cookie-mode
/refreshaccepts an empty JSON object. A{}body now reads the refresh token from its cookie, as a body-less request does, instead of answering 422. A token in the body still takes precedence. -
The Socket.IO rebalance step sheds fewer connections during a connect storm: a replica's load is now the lower of its live count and its own last report, so connections since that report are not counted against its peers' reports.
-
A hub's
search_count="approximate"total counts its search text (behaviour change): with a query it is the merged rows the page reads instead of the cap: exact under the cap, the cap above it, zero for no match, a lower bound once a leg is cut atper_leg_limit. -
A filtered Postgres search no longer fails with
postgres_schema_mismatchundersearch_count="approximate"or a PGroongaplan="auto". -
A Postgres hub page with
limit: 0reports its total instead of 0. -
A missing or closed Temporal workflow, or a missing schedule, is
not_found; a taken workflow idconflict(behaviour change), notRPCErrororWorkflowAlreadyStartedError. A taken schedule keptconflict, now coded. NewWORKFLOW_NOT_FOUND_CODEetc.; the mock raises them and refuses a taken schedule. -
FleetView.excessleaves a negative load out of the mean (behaviour change), as it does a non-numeric one: a load is a count. A negative peer load no longer inflates a member's excess, and a member whose own load is negative gets 0. -
A retry waits out a throttle's
retry_after(behaviour change). Resilience policies, durable runs and durable saga steps retry athrottlederror no sooner than itsdetails["retry_after"]; a policy or saga whose longest backoff is shorter gives up at once. -
Mock workflows and schedules answer as Temporal does (behaviour change): cancel or terminate of a missing run, terminate of a closed one, and delete, pause or unpause of a missing schedule are
not_found. A start conflicts only with a running run, whichraise_on_already_started=Falsekeeps. -
A mock workflow
startreturns an unpinned handle (behaviour change): itsrun_idisNone, as Temporal's, so it follows the workflow id's latest run. Once the run is replaced, a signal, cancel or terminate through it reaches the new run instead ofnot_found. -
The isolation battery's predicate cases no longer count another case's rows on a reused store, so
forze_dst.conformance.collect_verdictsover one store no longer reports a falsepredicate_write_skewdivergence.
Security¶
-
An error an integration translates from its backend no longer shows the failed call's context to API callers (behaviour change): such an error carries no
context, whatever its kind. The details stay on the exception and are logged at debug. A stream's failure between items is now translated too. -
A cookie read by several
CookieTokenAuthningresses keeps every CSRF gate they declare: one ingress'scsrf=Noneno longer lets the cookie through ungated. -
saga.step_failedno longer shows the failed step's error text to API callers (behaviour change): itscausedetail is now the cause'scodeandkind, and only when the cause is aCoreException. The original error stays chained as__cause__. -
An error translated from a backend keeps a fixed summary (behaviour change): the backend's message moves to
details["error"]in the Neo4j, Mongo, Firestore, Vault, inference, Meilisearch, GCS and container-sandbox integrations, secret resolution and credential rotation, sostr()no longer includes it. -
A container sandbox error summary no longer names the daemon host (behaviour change): the host, which can carry credentials, stays in
details["docker_host"]. -
Cookie-mode
/login,/logoutand/refreshapply the cookie CSRF gate to every request (behaviour change), with or without a cookie and wherever the refresh token rides, under the access cookie'sCookieTokenAuthn(csrf=...). A client sending neitherOriginnorRefererneedsallow_missing_origin=True. -
Cookie-mode logout revokes from the refresh cookie without a live access cookie (
logout_by_refresh_token).runtime_lifespanrefuses an app whose refresh cookie path misses logout (check_authn_cookie_paths). Breaking for own token lifecycle ports: addrevoke_tokens_by_refresh_token.
0.15.1 - 2026-10-19¶
Fixed¶
-
A leadership body resumed after a pause stops at its next
Term.live()check.live()now ends the term once its soft deadline has passed on the clock, instead of letting the body run one more unit past it. Since it may now end the term, call it on the step's event loop, not from a worker thread. -
The Socket.IO expiry sweep and presence heartbeat skip a socket already on its way out, instead of logging a server error for it. A connection the drain closes while another close is under way no longer leaves its presence entry behind, and a presence store failing a leave no longer strands the connection.
0.15.0 - 2026-10-18¶
Added¶
-
The Postgres outbox can log what it consumes.
PostgresOutboxConfig(storage="consume", delivery_log=("app", "outbox_delivered"))appends each published row to an application-owned table in the statement that deletes it, so a log row proves a committed delivery. A failure to write it fails the pass. -
Token verifications can skip the eligibility read too. Pass the authn kernel's
RevocationStatestoAuthzKernelConfig(revocation_states=): a deactivating policy-principal write then raises the epoch in its transaction, and a fast-path token skips the read. Postgres only; needskey_mac. -
DocumentCommandHookDepKeywraps a document's command port wherever it is resolved, registered routed under the document's name. -
API keys can be verified from Redis, and cached states signed.
RevocationStates(key_mac=StateMacKeys(...))checks a key from a MAC-signed state, reading its row only on a miss, and signs cached epochs and session states. A custom states store then needsApiKeyStatesPortandraise_epoch(state=). -
An API key admitted from Redis cannot mint past its revocation. With
key_macset, any credential minted in a request or socket command an API key authenticated re-reads that key's row first, whoever it is for, and fails withapi_key_revokedonce the key is revoked or expired. -
The revocation-states fast path is counted.
instrument_revocation_states({"default": states})exports hits, misses, fallbacks, failed write-throughs and clock-skew refusals, for sessions and API keys.
Fixed¶
-
Mongo searches and counters, tenant-aware Mongo outboxes and Firestore counters on a per-tenant collection now meet a
namespacefloor. A Mongo search's index name, scoped to its collection, no longer counts. -
Mongo no longer advises a tenant-aware outbox on a per-tenant collection to drop
tenant_aware, which would put every tenant's records in one collection.
Security¶
-
A route is
namespace-isolated only when every relation it names resolves per tenant (Breaking): anamespacefloor refuses one with a static relation, and a per-tenant Postgres inbox, idempotency store or outbox that is not tenant-aware. New:TenancyRouteGroup(other_namespaces=),resolves_per_tenant. -
Postgres idempotency stores and federated searches are held to a declared floor (Breaking): an idempotency store on a shared table rates
tagged, and a federated search ratesnoneunless every leg is a hub leg, tenant-aware or per-tenant. -
ensure_principal(..., is_active=False)creates the principal inactive in one write, so a failure can no longer leave it active;CreatePolicyPrincipalCmdgainsis_active(defaultTrue). -
With revocation states, a revocation or a deactivation in a transaction that does not hold all of its writes (the epoch's, the credentials', the principal's) is refused before it writes (
authn_revocation_requires_transaction), not committed part-way; Postgres stores reportis_transactionally_enlisted(). -
A credential minted by a WebSocket or Socket.IO command re-checks the token that authenticated the connection. Both transports carry what the handshake or last reauth recorded (
current_authn_admission) to each command; once that session is revoked, or rotated under"session"binding, mints fail until a reauth. -
Deactivating a principal deactivates every API key it holds. The keys were read with no limit, so past a store's implicit read limit (10,000 rows on Postgres, Mongo and Firestore) the rest stayed active; deactivation now pages through all of them.
-
With revocation states, a credential minted from an access token re-checks that token whoever it is for, including a password account or invite an operator issues for another principal.
-
A tenant-aware Postgres inbox keeps tenants apart on a shared table. It carries the tenant in its dedup key, as the Mongo inbox does. After upgrading, a message redelivered to a tenant-aware Postgres inbox is processed once more.
0.14.0 - 2026-10-17¶
Added¶
-
A replica can withdraw readiness before refusing work, and drain while it still serves.
runtime.begin_draining()fails readiness, operations still admitted;runtime.drain(budget, announce_delay=)runs the phases steps put onctx.drain_phases.shutdown()drains first if nobody did, withindrain_budget. -
An embedded uvicorn drains the runtime on
SIGTERMbefore it stops.forze_fastapi.serve_uvicorn(config, runtime, drain_budget=)runsruntime.drain()on the firstSIGTERMorSIGINT; aSIGINTduring it forces the exit. A finite graceful timeout is set when none is, so an open stream cannot hold the exit. -
A leaving replica closes its socket.io connections spread over a window, and turns new ones away.
connection_drain_lifecycle_step(sio, window=, tail=)closes them withclose_spreadin a drain phase, before the server stops;attach_realtime_connection(draining=)closes the transport of connects meanwhile. -
FastAPI's SSE and WebSocket live tails drain the same way.
realtime_sse_drain_lifecycle_step(hub, window=, tail=)ends them spread over a window; a new stream is told to retry, a new upgrade gets a 1012 close.spread_drain_lifecycle_stepis the phase for connections of your own. -
Apps can admit or delay socket.io connects, and connections are counted.
attach_realtime_connection(admission=)runs before authentication;instrument_realtime_connections(RealtimeConnectionStats)exports open connections, refused connects by reason, and drain closes. -
The worker probe listener can check dependencies, and needs no runtime.
probe_listener_step(checks=, check_timeout=)answers throughevaluate_readiness, the evaluatorattach_readiness_routenow uses, so both answer alike from the same probes. Steps readctx.lifecycle.startedand.announced. -
A fenced writer can recover after Redis loses its counters.
FenceAdvancing.advance_fence(key, past=...), on Redis and the mock, lifts a fencing counter to the store's highest fence;requires_fence_advancefails closed without it. The documented rule now claims a token first, so a repeated one is acted on once. -
Work can run on one replica at a time for the life of the process.
leadership_lifecycle_stepelects a leader on a renewing lease, ends its term inside the lease, hands it over on stop or on request, and fences it: a token, a claim, andfence_floorto recover after Redis loses its counters. -
Replicas can see which peers are alive, and split work over them.
ctx.fleet(FleetSpec(...)), on Redis viaRedisDepsModule(fleets=...), gives aFleetPortwhosebeatwrites a replica's state and report and returns theFleetView; its policies agree for all who hold it.instance_id()names a process. -
A replica keeps its fleet entry current from startup to shutdown.
fleet_membership_lifecycle_step(spec, every=)beatsstarting, thenservingonce startup finishes, thendrainingonce a drain is announced, and leaves the fleet or lingers on stop. -
An overloaded socket.io replica sheds connections toward the fleet's mean.
connection_rebalance_lifecycle_step(sio, fleet=, every=, movable=, per_minute=)closes movable connections at a bounded rate once the fleet has settled;connection_report(sio, movable=)fills the membership report it reads. -
The Postgres outbox can delete what it relays.
PostgresOutboxConfig(storage="consume")runs a relay pass in one transaction bounded bypass_deadlineandpass_statement_timeout: the claim locks rows, publishing deletes them, nothing is left to prune. A failed pass backs its route, or tenant, off. -
A relay can bound each publish, and a run.
publish_timeout=onOutboxRelayand the background step makes a slow publish a transient failure;OutboxRelay(publish_until=)ends a run's publishing. A store opts into transactional passes withTransactionalOutboxPasses;outbox_pass_scope()enters one. -
The delivery conformance battery covers a relay pass that rolls back.
forze_dst.conformance.run_rollback_crash_deliveryruns on the mock, on Mongo, and on Postgres in both storage modes. -
Tokens can be verified from cached revocation states, not the session row.
AuthnKernelConfig(revocation_states=RevocationStates(...))checks newfamandpepclaims againstRedisDepsModule(revocation_states=)andPostgresDepsModule(principal_epochs=). Opt-in; issue and revocation then need a transaction. -
Durable runs recovery gave up decrypting can be requeued.
DurableRunStatus.QUARANTINEDmarks a run the scan no longer claims;DurableRunAdminPort.requeue_quarantined(run_ids=)returns a bound tenant's or named ones, on Postgres, Mongo and the mock. Breaking for exhaustive status matches and own admin ports.
Changed¶
-
A durable mock snapshot written by an earlier release is refused on load (
mock_state_snapshot_incompatible), since the mock state gained a store. -
The
socketioextra boundspython-engineiobelow 5. The connection drain and a draining replica's turn-away close connections through its internals, and refuse at assembly a server that lacks them. -
Readiness waits for startup, and
runtime.drainingturns true when a drain is announced.runtime.ready, the readiness route and the probe listener answer503until startup finishes;ctx.drain_gate.drainingalone means refused. Breaking for a startup probe on/readyzsized for the earlier200. -
The readiness route logs a failed dependency check under the application logger, as "Readiness check … failed". Alerts keyed on the
fastapi.probeslogger no longer match. -
The tenant-isolation floor leaves out routes that belong to the deployment. Redis fleets, and lock and pub/sub routes marked
tenant_global=True(a lifecycle lock, an outbox wake), no longer count towardrequired_tenant_isolation, so a module of only those meets it. Every other route is still held to it. -
The outbox relay step resolves its outbox route at startup even with
tenants=. A route that is not wired fails startup instead of every tick. -
A durable run whose key is gone no longer resumes by itself once its recovery hold reaches a day. Quarantined, it waits for a requeue even after the key is back; one already claimed nine times, crash reclaims included, is parked on its first failed open. Breaking for deployments relying on a restored key.
Deprecated¶
probe_listener_step(runtime=...). The step reads its scope from its hook'sctx, so it can sit in its own runtime's plan; a runtime passed in is still watched, with aDeprecationWarning.
Fixed¶
-
The crash-recovery delivery scenario marks the rows it re-publishes.
forze_dst.conformance.run_crash_recovery_deliverymarked them by event id, which matched no row, so they stayedprocessing; the outcome never read the mark. -
Recovery no longer lands a saga asked to stop unread when its holder died first. It landed
CANCELLED, abandoning forward steps past the pivot and skipping the rollback before it. Such a saga now runs again and decides by its journal at its next step, as a live one does. -
Durable recovery no longer stalls on runs it cannot decrypt. On Postgres and Mongo, a claimed run whose tenant's key was gone failed the whole claim. Now only that run is skipped and held out of the scan, from two leases doubling to a day; a passing KMS fault keeps the lease. A missing keyring still fails the scan.
-
A durable run whose key is gone no longer fails the run listing. On Postgres and Mongo,
list_runsreturns it without its payloads, with the newDurableRunRecord.payloads_unreadableset; a cancel asked of it is decided once it can be decrypted. A passing KMS fault or a missing keyring still fails the listing. -
A realtime stream whose client disconnects mid-wait now leaves its presence rooms. An SSE stream or WebSocket tail cancelled while waiting kept its member counted in a shared presence store such as Redis until the TTL; the leave now runs shielded, bounded by 2 s.
-
A readiness check whose client is registered only per route says so, as
registered per route only, instead of reporting itnot registered. -
A retry past its 1,024th attempt no longer fails with
OverflowError. Resilience retries and outbox redelivery keep their backoff at the cap however many attempts came before, and a zero-delay saga step or client read keeps retrying. -
Ensuring a Redis consumer group that already exists no longer fails, so the realtime group-ensure step survives a restart. A
BUSYGROUPreply now maps to a conflict codedredis_busygrouprather than a generic busy infrastructure error. -
A
SERVERLESSruntime now refuses the durable recovery and scheduler steps at assembly, as it does the realtime sweeps: each runs a background loop a frozen function cannot host. Breaking for serverless apps that assembled them. -
A lock spec with
requires_fencing_tokenno longer refuses a capable backend whose ports are wrapped by OTel port spans, tracing, interceptors or a resilience port policy; resolving it failed withdlock.fencing_unsupported. -
A lock spec's fencing requirements are judged against the backend it hands out. With the port cache off, the check resolved a second backend and judged that one instead.
implements(port, Protocol)inforze.application.contracts.depsasks a protocol check of the backend behind a port's guard and wrappers. -
An inbox on another pool than its transaction is refused under port logging or OTel port spans too. The exactly-once check passed any wrapped inbox, so its dedup mark could commit apart from the handler's writes.
-
A stream spec with
requires_transactionsno longer refuses an exactly-once backend whose port is wrapped by OTel port spans, port logging, tracing or a resilience port policy; resolving it failed withstream.transactions_unsupported. -
Exporting or migrating a graph no longer refuses a backend whose query port is wrapped by OTel port spans, port logging, tracing or a resilience port policy, as one that cannot surface edge endpoints.
-
A document's L1 cache subscribes to invalidation push when its cache port is wrapped by OTel port spans, port logging, tracing or a resilience port policy, instead of falling back silently to TTL-only staleness.
-
graph_read_capabilitiesreports the streams of a wrapped graph port. Under OTel port spans, port logging, tracing or a resilience port policy it reported none, and a stream guarded by it was refused. -
capabilities_ofanddurable_run_control_capabilitiesread the backend behind a wrapped port, as the other capability checks now do, instead of reporting no storage guarantees and no run control. -
A Redis lock acquire that fails after taking the lock no longer leaves it held, as on a corrupt fence counter. Tokens stop at 2⁵³ − 1 on Redis and the mock: an acquire past it is refused with
dlock.fence_exhaustedrather than issuing a token Redis would round onto another. -
Socket.io connects replaying at the same time all get their offline replay. Replay and
realtime.ackrun on the live context ofattach_realtime_connection(runtime=)rather than opening a scope per connect. Breaking: pass the serving runtime, with its scope open. -
The documented fenced write no longer refuses the lock holder's own second write. The Redis lock example now accepts an equal token on a holder's later writes, the same lock generation, as the contract does.
-
The singleton guard and leadership refuse, at startup, a lock no tenant-less call can use: tenant-aware, or on a routed client with no tenant to route by. It failed startup with
tenant_requiredor never led; nowtenant_global_required(newrequire_tenant_global). Breaking for such a leadership step. -
Background steps run without
tenantsrefuse a tenant-scoped port at startup: the outbox and realtime relays, durable recovery and scheduler,job_staleness, stream trim and mailbox retention, which failed every tick, and group ensure. So does a relay whose wake is on a routed client. Breaking for such apps. -
The outbox relay and the durable recovery and scheduler loops stop on a wiring error, logged once, such as no keyring wired, instead of logging it every tick. One tenant's own configuration error, such as its key's state, still leaves the rest of the shard running.
-
The Temporal schedule bootstrap, the S3 bucket step and Mongo index validation refuse a tenant-scoped client or schedule at startup, as
tenant_global_required, instead of failing withtenant_required: they run with no tenant bound. -
permission_providers_lifecycle_stepwithouttenant=refuses a tenant-scoped permission catalog at startup, astenant_global_required, instead of failing withtenant_required. -
A tenant-aware Kafka route refuses a call with no tenant bound, with
tenant_requiredas other tenant-aware adapters do, instead of resolving its namespace for no tenant. The commit-stream consumer refuses such a group at startup. Breaking for apps that used one unbound. -
The queue and notification consumer steps refuse a tenant-scoped queue at startup, as
tenant_global_required: they receive with no tenant bound, so they failed every poll. Breaking for apps that ran one so. -
The plain SSE tail and the socket.io gateway's stream-group and pub/sub sources refuse a tenant-scoped port at startup, as
tenant_global_required: they read with no tenant bound, so every read failed. The sharded tail and source bind their tenants. Breaking for apps that ran one so. -
A readiness check on a client scoped per tenant says so, as
routed per tenantortenant-aware, instead of a bareCoreException: a probe has no tenant bound. -
Realtime presence refuses a store on a client routed per tenant, as
tenant_global_required:attach_realtime_connection, the gateway step and both heartbeat steps use it with no tenant bound, so connects, live counts and refreshes failed. Breaking for apps wired so.
Security¶
-
A socket.io connection acts only under the identity it authenticated. Upgrade if
attach_realtime_connectionorForzeSocketIOAdapter(identity_resolver=)authenticates your socket.io connections. -
A tenant-aware Kafka route with a fixed namespace no longer counts as tenant-isolated; a declared isolation floor now refuses it. Kafka resolves a per-tenant namespace as the other backends do. Breaking for such a floored module, and a per-tenant route that is not tenant-aware may name new topics.
0.13.0 - 2026-10-14¶
Added¶
-
A socket.io client opening several namespaces can authenticate once.
attach_realtime_connection(share_identity=True)reuses the identity one Engine.IO connection resolved for its other namespaces with the same credential and resolver, until it expires or the connection closes; another credential is checked again. -
An outbox relay can deliver a row as soon as it commits.
OutboxSpec(wake=OutboxWake(route=...))publishes a wake on a pub/sub route after each flush's transaction commits, and the background relay runs a pass when it hears one; its poll stays the fallback for a lost wake. -
The outbox relay reclaims stale claims on its own cadence. The reclaim runs at most once per
reclaim_every, by default once per poll tick, rather than on every pass a wake starts. -
A Redis call can wait for a free connection instead of failing.
RedisConfig(pool_timeout=...), also onRedisSettings, queues a call for up to that long once allmax_sizeconnections are in use; a retried read waits again. When unset, a full pool still refuses the call at once, as it does past the wait.
Changed¶
-
An emit to a large socket.io room takes far less of the event loop. The manager
build_socketio_serverbuilds no longer schedules a task for every socket in the room, only for a send that has to wait. Passclient_manager=AsyncManager()to keep python-socketio's manager. -
A reused refresh token raises
RefreshTokenReuseError, still the uniform refusal; refresh commits the family's revocation after rolling back, so it refuses a transaction its caller opened. Breaking for those callers and for token lifecycles, which must raise it on reuse and implementrevoke_chain_of_tokens.
Fixed¶
-
A failed deactivation, password change or reset no longer half-applies. Login, refresh, logout, password change and reset, and deactivation commit whole on
build_authn_registry(tx_route=), the tenant switch onbuild_tenancy_registry's. Breaking: each needs a transaction manager,"default"unless set. -
On Postgres, a login racing a deactivation can no longer leave the principal a live session. Login, refresh and the tenant switch read the principal with a row lock until the session commits. Breaking for eligibility ports:
require_authentication_allowedtakesfor_update. -
Logout, deactivation and a password change or reset revoke every live session, however many. Revocation stopped at the store's implicit 10,000-row cap, or failed when a refresh rotated a session mid-way; it now revokes in batches, rereading a stale one.
-
The mock's principal deactivation revokes the principal's sessions, emitting
LOGOUTbeforePRINCIPAL_DEACTIVATED, as the real one does.
0.12.0 - 2026-10-13¶
Added¶
-
OpenAPI can list the response fields an app always sends as required.
apply_openapi_conventions(app, require_sent_fields=True)requires every field of a schema only null-keeping routes return, and only the non-nullable ones where a route drops nulls. Schemas requests also use are left as they are. -
A cross-origin browser can read forze's response headers.
AllowlistCORSMiddlewareis Starlette's CORS middleware answering anOriginAllowlist(loopback port ranges included) and exposingEXPOSED_RESPONSE_HEADERS:X-Error-Code, the invocation ids, and a download's filename,ETagand range headers. -
An administrator can revoke any principal's API key.
build_authn_registry(admin_guards=...)addsrevoke_principal_api_key(DELETE /admin/api-keys/{id}) behind those guards, likedeactivate_principal; unguarded, neither is registered. Both act in every tenant. Breaking for API-key lifecycle implementers. -
An administrator can list any principal's API keys, to find one to revoke.
list_principal_api_keys(GET /admin/principals/{id}/api-keys) returns the non-secret descriptors, revoked keys included, behindadmin_guardsonly, and in every tenant. Breaking for API-key lifecycle implementers. -
admin_guardsmust include a step that declares its permission keys. Unless one implementspermission_keys(), asAuthzBeforeAuthorizedoes,build_authn_registryrefuses them;trust_admin_guards=Trueskips the check. -
A revoked API key emits an
api_key_revokedauthn event, from self-service and admin revocation alike, with the key id and who revoked it. The principal-deactivation cascade, which also revokes keys, does not emit it. -
A hub search can choose how an unasked page counts its total.
HubSearchSpec(default_search_count="approximate")(or"none") applies when a request sets nosearch_count, as it does on aSearchSpec. The request option still wins and the members' own defaults don't apply; Postgres honours it. -
A Temporal worker can deliver a cancel to a heartbeating activity within a second.
temporal_worker_lifecycle_step(max_heartbeat_throttle_interval=...)caps how long the SDK holds back heartbeats, and a cancel arrives only in a heartbeat's reply; uncapped it waited up to 0.8 × theheartbeat_timeout. -
A search can group and measure the rows it matches.
aggregate_searchandaggregate_search_pagetake theAggregatesExpressionthe document port'saggregate_manydoes and read every match, uncapped. Postgres FTS, PGroonga and the mock serve it; the others refuse viaSearchCapabilities.supports_aggregates. -
A bulk upsert can write set-based.
upsert_many(items, return_new=False, set_based=True)inserts the missing rows and patches the stored ones, only where the update changes them, reading nothing back; a Postgres reload of unchanged rows ran about 4× faster. A spec whose updates need the domain model refuses it. -
An MCP server can expose a chosen set of tools, and smaller ones.
register_toolsandbuild_mcp_servertakeoperations=[...], an allowlist refusing an unknown name;output_schemas=False; andshared_filter_grammar=True, stating the filter grammar once in the instructions, whichbuild_mcp_servernow takes.
Changed¶
-
A listed API key always carries
is_activeandcreated_at(Breaking for API-key lifecycle implementers).AuthnApiKeyListItemDTOrequires them, so generated clients type them as present;hintstays optional, as a key minted before hints has none.ApiKeyInfo.created_atis required. -
/deactivateanswers 404 after upgrading: passadmin_guards(Breaking).deactivate_principalis registered only behind them, so guards an app bound on the operation itself leave it absent; pass them asadmin_guardsinstead (bound twice, a step fails atfreeze()). -
AuthnKernelOphas members a default registry does not register (upgrade note). Aninclude=built from the whole enum fails at startup withoutadmin_guards; with them, MCP's default read-only tools gainlist_principal_api_keys. -
A query operation may hold a write port it never uses (behaviour change). A handler built with a command port failed to build under
as_query(); it now gets a stand-in that refuses when used, so a read service carrying write ports can be a query. A write in a query is still refused. -
A forze model's
Decimalgraph key and stored text are fixed point (behaviour change). A vertex keyed earlier by aDecimalthat wrote in scientific form ("1E+3") keeps that key; portability archive digests differ for such values.
Fixed¶
-
A federated search over members named by a
StrEnumruns, in Postgres and Meilisearch. Each search keys its snapshot by its members' default sorts, and aStrEnumname as that key failed it with a 500. A fingerprint now hashes aStrEnumkey as its value; any other non-string key is still refused. -
Revoking an API key the caller does not hold is a 404 (behaviour change).
revoke_api_keyanswered a malformed, unknown or another principal's key id with a 401, which a browser client reads as its session ending; it is nownot_found(api_key_not_found). A revoked key still answers 204. -
An access token is checked on the clock that issued it.
issue_tokenstampsiatandexpfrom forze's time source, but PyJWT checked them on the wall clock, so underFrozenTimeSourceevery token was expired or not yet valid.exp,iatandnbfare now checked againstutcnow(), with the leeway. -
An operation can declare an output that may be nothing.
OperationDescriptor(output_type=Model | None)(orlist[Model]) is served as200 nullover HTTP and{"result": null}over MCP; its catalog schema and fingerprint no longer fail. A model's schema, and so its fingerprint, is unchanged. -
A read-only operation cannot write through a port a command built and cached (behaviour change). A query reusing a handler or adapter that a command built first held its real write port. A command accessor now always returns
GuardedWritePort; in a test,forze.testing.unguardedreaches the port. -
A filter, sort or aggregates expression refuses a key it does not define, naming it (behaviour change). A request DTO dropped it, so
{"$values": {…}, "$bogus": 1}returned a wider page and{"$bogus": …}failed as{}; the port ignored one beside$values, a second combinator, or a mistyped$having. -
An unknown option is refused, naming it (behaviour change). A search request refuses one in
options, highlight orsnapshotoptions, orresult_snapshot(a 422), as search, analytics, dynamic-read and inference ports now do. A single-index request refusesmembers/member_weights; its port only warns. -
A request filter can nest an element quantifier, as the port can. A quantifier inside another, or under an object element's
$values, failed a request DTO or tool schema with a list of union errors; the filter types now express it. The nesting counts towardmax_depth. -
A filter's
max_depthbounds how deep its$and/$or/$notnest, not how many it has (behaviour change). Every combinator counted toward it, so 40$notside by side under one$andwere refused as deeper than 32. Siblings now each sit one level down;max_clausesstill bounds the width. -
A Temporal schedule created from cron expressions can be described and listed. The server keeps each expression only as a compiled calendar, so the timing came back empty; it now reads as an equivalent cron expression. A timing it cannot state exactly, such as one skipping periods, is refused; a listing skips it.
-
A blank PGroonga search decrypts encrypted fields and refuses a sort on them, as a ranked search does. A projection or
select_searchreturned the ciphertext, and a sort on an encrypted field ordered the rows by it. -
A blank Postgres FTS or vector search reads its read relation, as its cursor does (behaviour change). Its page and count read only rows with an index-heap match, so a view with more rows paged fewer than it walked; a blank page without a limit also stops at
max_resultsnow. -
A Postgres
$havingon a date or time output compares as a filter on that column does. A threshold was sent untyped, so under a session time zone other than UTC it kept the wrong groups; it now coerces by the output's type, and any numeric output takes a fractional bound. -
$havingrefuses an operator its output cannot take before the query runs.$likeon a$countfailed in Postgres and matched a stringified number in the mock; a text, set or hierarchy operator on a numeric measure is now a 400 on every backend. -
A Postgres aggregate sorts its groups as every other read sorts rows (behaviour change). A null group comes first ascending and last descending, or where
nullsputs it;"ASC"now sorts ascending rather than descending, and an unknown direction is a 400 rather than a descending sort. -
An update to a mapping or nested-model field keeps the keys it does not name, in Postgres, Mongo and Firestore (behaviour change). The merge patch was stored, so
{"k": 2}into{"k": 1, "j": 5}stored{"k": 2}; the field is now written whole.update_matching, which cannot merge, refuses such a field. -
A search with result snapshots can return projected hits.
project_searchandselect_searchon a snapshot-enabled spec failed in Postgres and Meilisearch, because the snapshot was filled from the projected rows; it now keeps whole records, and only the page returned is projected. -
A
$havingbound on a time bucket compares with the instant the bucket starts at. In another zone, Postgres read an aware bound as UTC wall time and the mock matched no datetime at all; a naive bound is wall time in the bucket's zone, and a Postgrestimestampcolumn is read as UTC before it is bucketed. -
A string range bound in
$havingor a metricfiltercompares as its field's type in the mock and Mongo, as in Postgres. A$gtbound like"40"was compared with a number as text;$likeon a numeric or time output is refused everywhere, and Mongo takes aDecimalbound. -
A string bound on an integer column filters in Postgres.
{"age": {"$gt": "28"}}failed withInvalid int; it compares as the number, as on the other backends. -
uuid7ids keep their nanosecond order. The variant bits overwrote two bits of the sub-millisecond time, so ids minted under 256 ns apart could sort backwards, asFrozenTimeSourceand the DST clock mint them 1 ns apart. Existing ids stay valid;high_precisionreads them within 256 ns. -
A search page with
limit=0is empty when it writes or replays a result snapshot. It returned one hit. -
An encrypted field of any type can be updated in Postgres, Mongo and Firestore (behaviour change). The patch was sealed before the domain merged it, so an encrypted
int, mapping or date failed validation; it is now merged open, then sealed. Diffs report encrypted fields open; the mock rewrites a named one. -
The in-memory mock sums
Decimalvalues exactly, as Postgres sumsnumeric. An aggregate's$sumoverDecimalfields came back as a float, dropping digits a ledger total keeps. -
A
Decimalfield is written in fixed point in JSON (behaviour change). A forze model wroteDecimal("0.00000000")as"0E-8"and1000as"1E+3"; both now come out as written, up to an exponent of 100 either way. A graph property filter matches aDecimal, at any depth, in either text. -
A
setorfrozensetfield is written in one order in every process. A forze model failed on a mixed-type set and wrote afrozenset(or a set of sets) in hash-seeded order. Sets now sort naturally when their items allow, else by canonical JSON text;pydantic_model_hashdigests of set-bearing models change.
0.11.1 - 2026-10-07¶
Security¶
- Tenant-aware Postgres search returns only the current tenant's rows in every configuration. Upgrade if a Postgres search route is configured with
tenant_aware=True.
0.11.0 - 2026-10-05¶
Added¶
-
A hub search can raise the limits its filters are parsed under.
HubSearchSpec(filter_limits=...)bounds the hub's filter, parsed once for its hub rows on Postgres and in the in-memory hub alike; a member's own limits do not apply to it. -
A search spec can choose how an unasked page counts its total.
SearchSpec(default_search_count="approximate")(or"none") applies when a request sets nosearch_count, so a large log need not runCOUNT(*)on every page. The request option still wins; Postgres honours it. -
Authorization can remember a principal's catalog grants.
AuthzKernelConfig(grants_cache=GrantsCache(ttl=...))caches roles and permissions by principal, tenant and scope; active status and provider grants stay live. Off by default; role assignment clears it on commit; other changes wait for the TTL. -
Deployment configuration can grant a permission, and be its only source.
ConfigGrantsProvider(keys=...)grants each key to the principal ids aConfigGrantssettings model lists and denies it to everyone else, so a catalog binding of the key grants nothing, and the startup step refuses one. -
A route that runs no operation can require a permission.
require_permission(key, spec=, ctx_dep=)is a FastAPI dependency making the authz hook's own decision, delegated actors included, and raising its denial, whichresource_type=lets a non-disclosing posture collapse. -
DST can check that a deactivation closes a derived permission.
no_permission_after_deactivation(deactivate, guarded)flags a guarded operation that succeeds after the deactivation returned. -
An allowed origin can be a dev server's port range.
CookieCsrfand the realtime WebSocket'sallowed_originsaccepthttp://localhost:5173-5199orhttp://localhost:*on a loopback host. The WebSocket route now refuses an entry that is not an origin (null,*) when attached. -
The stored-file kit's write wiring leaves room for your guards.
bind_stored_file_writes(kit, tx_route=...)binds upload and delete to their transaction, outbox flush and after-commit stages and returns the registry unfrozen, so authn and authz hooks can bind before it freezes. -
A document can turn off hard deletes.
DocumentSpec(hard_delete=False)drops the generatedkilloperation, so no route or tool reaches it, and the command port refuseskillandkill_manywithhard_delete_forbidden. A provisioner'sdrop_on_deprovision=Trueand raw statements can still delete rows. -
A soft-deleted row can still be read by id.
soft_delete_wiring(spec, get_deleted="read"), or the same option onAggregateKit, makesGETreturn the row withis_deletedset instead of a 404. Lists still leave it out. -
A delete or restore can set its own markers in the same write. A model on the soft-deletion mixin may list
soft_delete_companions, such as a cascade marker: fields that change only in a write that flipsis_deleted, so no other update can set or clear one. -
Stored-file delete can keep the object, and uploads can be capped.
bind_stored_file_writes(..., purge_on_delete=False)soft-deletes the row and keeps its blob, dropping only the search entry;StoredFileKitSpec(max_bytes=...)refuses a larger upload withupload_too_largebefore a row is written. -
A spec can raise the limits its filters are parsed under.
DocumentSpec(filter_limits=...)andSearchSpec(filter_limits=...)take aQueryFilterLimits, so an internal read can match more than 1,000 ids. Backends and the mock apply it to filters, aggregate filters and$having, as do generated routes. -
A backend can declare caps its server enforces on a filter.
QueryCapabilities(max_in_size=..., max_disjunctions=...)bound an operand list and the disjunctions a filter expands to; a filter past either is refused withquery_feature_unsupportedbefore it is sent.
Changed¶
-
Federated searches that merge full hits use 18–32% less CPU in total. Results and scores are unchanged; exact duplicate records keep the first occurrence's highlight rather than the last.
-
Federated search merges thin by default (behaviour change).
FederatedSearchSpec.thin_mergenow defaults toTrue: members return ids and only the page is re-read, bounding memory. Existing result snapshots miss once, and a row deleted between ranking and re-read shortens its page. -
Already-normalized text skips normalization when it is read back.
normalize_string, behind the kits'StringandLongString, first checks whether it would change anything: a 500-row read of three such fields went from 16 ms to 1.3 ms. Text that needs work is normalized as before. -
A debug call below the configured level costs a comparison. While
configure_logging's level is above debug,Logger.debugreturns before building the structlog logger, about 2 µs to 0.1 µs. Unconfigured, afterstructlog.reset_defaults()or under another wrapper class, debug reaches structlog as before. -
Authorization and tenant listing read in batches, not row by row. A decision reads once per kind of row and hierarchy level, plus once per 30 roles or groups: 7 reads, not 27, for a role, its parent and 18 permissions. Tenants are read together, not per membership. Firestore scans need an index on field and
id. -
A generated route with no output type writes a model result as a typed route does (behaviour change), at a fraction of the CPU:
Nonefields drop underexclude_noneand_sa…keys are kept. Any result writes inf/nan asnullinstead of failing. -
Sorting by a
NOT NULLPostgres column, or by the recordid, can read from a plain index. TheORDER BYnow leaves out the null placement a plain btree does not hold, so a document list or blank-query search sorted by it, through a view too, no longer sorts the whole filtered set first. -
Meilisearch's
ensure_indexmakes thedefault_sortfields sortable (behaviour change), as an unsorted browse sorts by them; on an index without them that browse fails with a configuration error naming the attribute. A port whose pinnedsortable_attributeslacks them refuses to build. -
A search spec whose
default_sortplaces nulls where its backend cannot is refused when the port is built, on Mongo and Meilisearch, and on a Postgres search cursor as the spec's error, not the caller's. -
An explicit null placement no Postgres search cursor can keep is refused (behaviour change), as Mongo does; offset pages still honour it. Meilisearch refuses one on any page. Before, the placement was dropped and the cursor walked a different order from the offset page.
Fixed¶
-
A RabbitMQ
receivewhose window ends as a message arrives returns messages it can ack. The deadline could cut off the consumer cancel sent on exit, which closed the channel: the messages just returned could not be acked, and the broker redelivered them later. -
A blank Meilisearch native federated search follows the members' shared
default_sort(behaviour change: page order). When every member resolves to the same sort and primary key, an unsorted browse orders by that sort, then the id, instead of index order; members that differ keep the engine's order. -
A PGroonga search uses its index for any supported index expression (behaviour change). An index on
ARRAY[title, content]or(title)never served it, so each search scanned the table. Matches and order now follow the index's tokenizer and normalizer, as for acoalesce-declared index. -
A PGroonga index mixing columns with an unsupported expression is refused, not half-used (behaviour change). A search over an index such as
(title, lower(content))matchedtitlealone and silently ignored the rest; it now fails, naming the element it cannot serve. -
get_manyon Firestore finds a document by its name, asgetdoes. It queried theidfield in the document body, so a document written without that field (by the console, a migration or another service) was found bygetand reported missing byget_many. -
Permission providers declaring more than 30 keys work on Firestore. The check that their keys exist in the permission catalog named them all in one
in, past Firestore's limit of 30 values, so every decision failed. It now reads 30 at a time. -
Normalized text drops control characters (behaviour change).
normalize_string, behind the kits'StringandLongString, kept NUL, ESC, DEL and other controls that are not whitespace; Postgres refuses NUL. They are removed now; an accent a removed character separated from its letter is stored composed. -
A kits text field answers 422 to a value that is not text.
String,LongStringand the metadata mixin'sdisplay_nameanddescriptioncalled string methods on any input, so a JSON number, list, object or boolean answered 500. Pydantic now refuses it; UTF-8 bytes are decoded and normalized. -
Deactivating a principal works without password or API-key accounts wired. The cascade resolved both credential stores and failed on a deployment with no route for one. It now closes every store the application wires, from any authn module, and refuses a store wired with only one of its ports.
-
Authorization and tenant membership read every binding on Firestore. The grant resolver and tenancy management paged bindings by offset, which Firestore refuses past the first page, so a principal with more bindings than one page holds, or a tenant with more members, failed. They read by cursor now.
-
OpenAPI descriptions drop a relative target's leading dot. A docstring's
:class:`.Foo`renders asFoo, as Sphinx shows it, instead of.Foo. -
A read without a limit no longer repeats or drops rows tied on its sort (behaviour change). Ties now break by
idand each batch seeks past the last row. A projection without a sort key, a model withoutid, or a non-idsort on MongoDB pages by offset; on Firestore, which refuses offsets, it is one query. -
Firestore refuses a filter it cannot run before sending it. An
$inpast 30 values, or a filter past 30 disjunctions once expanded ($invalues multiply under AND, add under OR), failed at the server with a validation error; it is now a cleanquery_feature_unsupportednaming the cap. -
A Firestore query it cannot run is no longer reported as a transaction conflict. A query needing an index, or a shape Firestore refuses, failed as a retryable
concurrencyerror; it is now aconfigurationerror naming the reason. -
A cursor continues from the right row whatever the model does to a sort key. A token took each key's value from the decoded row, so a key the model excluded, aliased, serialized or rewrote on read (
EmailStrlowercasing a domain) made the next page skip or repeat rows. It now takes the stored value. -
A pagination offset that is not a whole, non-negative number is refused as the caller's mistake. A document read sent
-1to the backend, which answered with a server error;"abc"raised one itself; andTrue,1.9or""were read as 1, 1 and 0. Each is now aprecondition, on every backend and the mock. -
A membership filter's size limit holds for every operand collection (behaviour change). A
frozensetor other iterable given as a$valuesshortcut skipped themax_in_sizecheck. A list, tuple, set or frozenset is bounded now, and any other iterable is refused. -
A cursor sorted by
idfirst pages inidorder (behaviour change). A sort such as{id: asc, x: desc}movedidto the end and ordered byxfirst;idnow stays put and later keys are dropped, so MongoDB and Firestore accept it. A token minted for such a sort earlier is refused; start over. -
An aggregate read without a limit pages its groups in one order. With no sort it asked for the read model's
id, which no aggregate returns, and failed; with a sort that tied, groups could repeat or go missing between batches. The group keys now break every tie. -
A MongoDB aggregate read honours an object-form sort direction. A sort written
{"n": {"dir": "asc"}}returned the groups in reverse, as anything but the string"asc"read as descending. A null placement MongoDB cannot express is now refused, as on other MongoDB reads. -
A membership operand given as a set or frozenset works on every backend. The parser handed it on as is: Postgres could not bind one inside a JSONB element and answered with a server error, and Meilisearch refused a set. Every operand now reaches the backend as a list.
-
A search page without a sort follows the spec's
default_sort, thenid(behaviour change: page order). Offset search ignored it on Postgres, Mongo, Meilisearch and the in-memory search. On Meilisearch only a blank query takes them: with search text, relevance ties keep the engine's order. -
A blank Postgres full-text or vector search sorts every matching row. Its candidate cap kept the first rows the scan met, so a sorted page over a table larger than the cap could miss the rows that belonged on it.
-
A ranked Postgres search cursor reaches rows whose sort key is null. The page's order put them last on an ascending key while the cursor's seek reads them as the smallest value, so walking the pages skipped them.
-
A Postgres hub search page breaks ties by
id. Rows of equal rank and sort keys had no final key on an offset page, so paging could repeat one row and skip another, and a cursor walk listed them in a different order than offset pages did. -
A search result snapshot replays only for the order it was taken in. Its key named the request's sorts but not the
default_sortan unsorted request follows, or a federated member's, and on Mongo not even the sorts, so a re-sorted request or a changed default could replay the old order. -
A capped Postgres vector search keeps the nearest rows. The candidate cap, and a hub's per-leg cap, kept the lowest scores, which are the farthest rows, so on a table larger than the cap the nearest matches never reached the page.
-
A capped Postgres search page holds the rows the uncapped order puts there. The candidate cap, and a hub's, kept rank ties at its edge in scan order (a hub's also put nulls last), so an offset page could differ between requests and from the cursor. The cap now orders as the page does, on every engine.
-
A Mongo search cursor walks past rows whose sort key is null. Its seek compared with
$gt/$lt, which never match a null, so walking a sort over a nullable field stopped at the first or last null. -
A search sort that names
idends there, so offset and cursor pages order by the same keys; a key after the id changed nothing but the cursor's order. -
Meilisearch sorts by the primary key wherever a sort names the id. With a custom
primary_key, an unsorted browse failed because theidtie-breaker named an attribute the index does not sort by. -
A search sort naming a field the read model lacks is a 400 on every backend, wherever it sits. Postgres answered with a database error, and a key after
idwas dropped unchecked. -
The in-memory search orders a ranked page by relevance before the sort, as Postgres, Mongo and Meilisearch do, instead of sorting first.
Security¶
-
Firestore reads and deletes by id check the tenant first. A tenant-aware
get,get_manyorkillwithout a bound tenant is refused before it reads the store, as the filtered reads already were. -
A request no ingress authenticates is refused by default (behaviour change).
AuthnRequirement(required=True)answers 401auth_requiredoutside the middleware'sanonymous_paths; list login, refresh and public pages there, or passrequired=False. CORS preflights pass. -
The documented authn → authz hook chain freezes.
AuthnRequired.to_step()under its default id now provides theauthn.principalcapability thatAuthzBeforeAuthorize.to_step()requires by default, so authorization runs after authentication;AuthzBeforeAuthorize.to_step()no longer needs astep_id. -
Words with "uri" inside them are no longer read as secrets. The scrubber matched
urianywhere in a name, so an audit spec refused metadata such asmanufacturingorsecurity. It now matches only at either end of a segment (uri_template,db_uri,dburi), so a word ending in "uri" is still masked.
0.10.0 - 2026-09-30¶
Added¶
-
An app's own routes come from a binding table.
forze_fastapi.routesexportsattach_operation_routes,RouteBindingand the endpoint builders, plusquery_endpointfor a GET whose input arrives as query parameters. Each route is named by its operation key, e.g.stock.add; an unregistered binding is refused. -
The OpenAPI schema can document what a Forze app serves.
apply_openapi_conventions(app)documents the Forze error envelope in place of FastAPI's 422 body, adds adefaulterror to every operation that lacks one, and renders reST in descriptions (roles, literals, field lists) as Markdown. -
Wall-clock times convert to instants without guessing.
CivilZone,to_instant,local_day_bounds,month_bounds,spanned_local_daysandelapsed_minutesinforze.base.primitives: conversions refuse a repeated time withoutfold, a skipped one, or a result past years 1–9999;AwareDatetimerefuses naive. -
Permissions can be derived from state per decision. A
PermissionProvider(declared onAuthzKernelConfig(permission_providers=…)) grants or denies keys from documents or config. A denial outranks every catalog binding, an unknown key is refused, and a late provider denies its keys; the mock decides with them. -
A confirmation can refuse when its preview has changed.
PreviewBinding(name=…, projector=…, exclude=…)returns a fingerprinted preview withreviewed(), andbind()makes the confirming operation recompute it inside its transaction, at snapshot isolation, and refuse withpreview_changedon a mismatch. -
An aggregate kit can audit the operations it generates.
AggregateKit(audit={kernel op: Audited(...)})binds the audit hooks and runs an audited write in a transaction, so its row commits with it.audit_row_per_effectchecks under simulation that each committed audited write carries exactly one row. -
S3 buckets can be created at startup.
s3_bucket_lifecycle_step(buckets=[...])runs afters3_lifecycle_stepand creates each missing bucket, leaving existing ones alone; a bucket it cannot create fails startup.s3_lifecycle_stepnow providess3.client, so a plan with two of them is refused. -
An aggregate kit takes your mappers and DTOs, and can run its writes in a transaction.
AggregateKit(mappers=…, dtos=…)composes your mappers under soft deletion and versioning;transactional_writes=Truebinds every generated write totx_route;update_returns="record"returns the updated record. -
The number-id step can name what it numbers.
NumberIdMappingStepFactory(spec, name_field="name")also sets that field toname_format("{name} #{number}"by default), so"Order"becomes"Order #12"; an empty value is left alone.
Changed¶
- Soft deletion's read mappers compose with the ones you pass.
SoftDeleteWiring.read_mappers(base)runs its exclusion after each list-family mapperbasecarries instead of replacing it, so your own restriction still applies.
Fixed¶
-
A malformed Inngest envelope no longer fails every retry. An identity that does not decode is ignored when the function does not bind it, and stops one that does with a
NonRetriableError; malformed tracing ids are dropped. -
A multi-tenant app no longer refuses authorization in every tenant but the first. Grant resolution compared a requested tenant with the tenant bound when its port was first built, then cached for the process, so other tenants' decisions failed with
authz.scope_tenant_mismatch. -
Sorting and keyset paging order aware datetimes by instant. The shared comparator ordered datetimes by their ISO text, so different UTC offsets, or a repeated hour, sorted out of order in the mock and the search merges. A cursor keeps its ISO text and compares as the instant it names.
-
The in-memory store compares aware datetimes as instants, as a real store does. Filters, the mock's aggregates and its unique guarantee read a zoned value by the wall clock through a repeated hour, so two instants an hour apart counted as one value and one instant written in two zones could match nothing.
-
A
Periodwhose endpoints carry a DST zone compares instants, not wall clocks. Through a repeated hour a one-hour period read as empty, and a valid one could be refused as backwards;no_overlapping_periodsand the mock's non-overlap guarantee missed overlaps there and reported false ones. -
The temporal and versioned kits' reads are marked as reads.
effective_on,timeline,historyandas_ofnow run under the read-only guard and are listed as read-only in the operation catalog, so an MCP projection that includes only reads now includes them.
Security¶
-
Audit rows and versioned corrections record every principal of a delegation.
actor_idstays the nearest actor;actor_idsholds the whole chain, and a correction gainssubject_id; its spec must store and read back all three. Required migration on Postgres (see docs); until then those writes fail closed. -
A delegated correction's
actor_idis the agent that made it (behaviour change). Rows written by 0.8.0 to 0.9.0 hold the user it acted for there; later rows hold the agent, and the user is insubject_id. -
An idempotency key reused by another delegation chain or tenant runs its own operation. A claim is scoped to the subject and its whole actor chain, and a tenant-aware Postgres store scopes it by tenant, as the other stores do; direct callers' claims are unchanged.
-
A delegation key's agent is checked, and cannot drop the operator's. It must be another eligible principal (a
serviceone withauthz_routeset), checked at issue and rotation. Over MCP it is chained under the operator's agent; with enforced grants that needsmay_act(operator, agent). -
A delegated identity cannot open a realtime connection. WebSocket, Socket.IO and SSE refuse an identity with an actor at connect and at reauth (
delegate_denied), since a connection is the principal's own stream, mailbox and cursors. -
Tenant provisioning reaches the tenant it provisions.
ObjectStorageTenantProvisionercreates an onboarded tenant's bucket on that tenant's backend with a routed client, and works with a plain one;PostgresSchemaTenantProvisionerrefuses a routed client, as the Mongo provisioner already does. -
A delegated call on a scope-guarded route is scoped for every principal in its chain.
AuthzDocumentScopeWrapscopes each actor too: a denied actor refuses the call, its row filters narrow the subject's, and an enforced delegation grant is checked. So does the sensitive-resource check. -
A delegated caller cannot manage the account it acts for. Issuing or revoking an API key, minting a session, logging out, changing the password, and switching or leaving a tenant refuse a delegated identity (
delegate_denied). -
Work started by a delegated caller keeps its delegation. Temporal headers carry the actor chain, as does the Inngest
_forzeenvelope wherebind_identity_from_eventbinds it, and an MCP subject that is already delegated keeps its chain under the server's agent.
0.9.0 - 2026-09-28¶
Added¶
-
An operation can leave an audit row, and only the metadata it declared.
Audited(spec=AuditSpec(...))records allowed, denied and failed outcomes with the actor from the authenticated identity; an undeclared metadata key raises.forze_kits.integrations.auditstores the trail as a document collection. -
A deployment can refuse to boot with development settings.
ProductionPosturedeclares rules over your own settings (required, HTTPS-only, loopback, development-only values) and refuses atcheck_wiringand when the runtime is built, naming keys, never values. An unset environment is production. -
A guarantee can hold at commit.
UniqueTogetherandNonOverlappingtakeholds="commit": a transaction may pass through a violation it resolves, such as an insert at the top of a positioned list. Postgres keeps it with aDEFERRABLE INITIALLY DEFERREDconstraint; the in-memory store checks it at commit.
Changed¶
- A pre-0.7
$fieldsfilter says it belongs under$values. A value operator, a literal on the right, a non-path value, or a string naming no field is refused with that pointer. A string or string enum that names a field still reads as a field compare, the one shape that cannot be caught.
Fixed¶
-
The HTTP cleartext-credentials warning reads what is actually sent. A credential in
default_headers(Authorization,Cookie, an API key) now counts; anHttpAuthConfigthat sends no header does not. -
Returning to a scope on an operation binder no longer drops what was bound to it. A second
bind_outer()orbind_tx()on one binder kept only its own steps, silently discarding earlier guards and the transaction route. -
A deferred Postgres constraint no longer satisfies a guarantee that holds after every write. Startup accepted a
DEFERRABLE INITIALLY DEFERREDconstraint, and the in-memory store then refused writes the database took. It is refused now, naming the deferral.
0.8.0 - 2026-09-27¶
Added¶
-
A refusal about a row can stop saying whether the row exists.
DenialPosture(mode="non_disclosing", resource_types=…)onbuild_runtimerenders a denial and a not-found about a covered type as one404on every transport, while the server keeps the real kind. Off by default, since clients see the change. -
A read by id can require the row to belong to the caller.
getandget_manytakeowned_by=OwnedBy(field=…, value=…): a foreign row is not found, exactly like a missing one, on every backend, and a locking read never locks it. -
A spec can declare that writes for one key do not interleave.
SerializedBy(key=…)among a spec's guarantees serializes writes per owner, in memory onforze_mockand through a transaction-scoped advisory lock on Postgres. Mongo refuses the declaration. -
An aggregate can be effective-dated.
AggregateKit(..., temporal=TemporalPolicy(...))addsvalid_from/valid_to,effective_on(key, on)and a paginatedtimeline(key, start, end). It requires the matchingNonOverlappingguarantee and composes withversionedfor the bitemporal case. -
A store can keep periods under one key from overlapping.
NonOverlapping(key=…, period=…, bounds=…, where=…)is enforced in memory byforze_mockand by anEXCLUDE USING gistconstraint on Postgres, which startup validates and prints the DDL for, never creating it. -
An aggregate can be corrected instead of overwritten.
AggregateKit(..., versioned=VersionedPolicy(corrections=spec))keeps a version chain:correct(...)retires the current version and inserts its successor in one transaction, andhistory/as_ofread the chain. -
A spec can declare what its store must guarantee.
DocumentSpec(guarantees=(UniqueTogether(...),))states a property of the data: a backend that cannot keep it refuses at wiring,forze_mockenforces it, and Postgres and Mongo check the live catalog at startup, printing the DDL they need. -
An outbound OAuth2 grant can be acquired, not just stored.
build_authorize_url,read_authorization_callbackandcomplete_authorizationinforze_identity.oauthcover the redirect and the callback, andOAuth2TokenClientexchanges the code and serves every later rotation. -
An outbound HTTP operation can send a form body and read its own error responses.
body_encoding="form"sends a form,error_type=validates a rejected response intodetails["response_error"], andHttpAuthConfig(kind="basic")presents HTTP Basic. A credential over plaintext HTTP warns at wiring. -
An outbound route can declare that data leaves your trust boundary.
HttpServiceConfig(egress_sensitive=True, acknowledge_data_egress=True)makes the egress a reviewed wiring fact, and declaring it without acknowledging fails closed. The inference configs share the gate. -
An in-process agent's tools can be your operations.
operation_tools(registry, include=…)projects allowlisted operations into tool definitions, anddispatch_tool_useruns a call throughrun_operation, so tenancy, permissions, deadlines and audit apply. Read-only by default. -
A model call can stay inside the plane.
HttpInferenceConfig(protocol="openai_chat")or"anthropic_messages", with aPromptTemplate, calls a chat model under the operation's deadline, tenant, credentials and egress declaration, can constrain output to a schema, and simulates throughMockInferenceAdapter. -
An application can catalogue the identity planes it actually wires.
forze_identity.spec_contributions(planes=["authn"])narrows the contribution, so an authn-only app passesbuild_runtime(specs=…)reconciliation. No arguments still catalogues all three. -
A read model may declare the fields its backend produces. Mapping a name to
NoneinDocumentSpec.derived_read_fieldsmarks it derived whatever its shape, andSpecSeed(derived=…)supplies it, so a view-backed aggregate is readable fromforze_mock. -
A view-backed aggregate can be simulated.
MockDepsModule(derived_values=MockDerivedRegistry().on(spec, source))supplies a derived field per row, including rows a workload creates, so an invariant can run over such an aggregate underforze_dst. -
A span of time says which end is in force.
Period(start, end, bounds)inforze.base.primitivescarries its bounds convention,"[)"by default, intocontains,overlapsandintersects, andno_overlapping_periodsinforze_dst.invariantsasserts it over a history.
Changed¶
-
A document query port must accept
owned_by.DocumentQueryPort.getandget_manygained the keyword; the shipped adapters and the mock take it, and an implementation of your own fails when a caller passes it. -
Idempotency claims are stored under per-caller keys. A key claimed before the upgrade does not replay after it, so a retry spanning the deploy runs again, and an
encrypt_resultrecord sealed before it no longer opens. -
A Postgres exclusion-constraint violation is
conflict. It wasprecondition, unlike the unique violation it sits beside.
Fixed¶
-
A locking read by id takes its lock when the document has a read cache.
get(pk, for_update=True)on a cached spec returned the cached row, or a row fetched without a lock, and locked nothing; it now always reads the database. -
A container sandbox run killed at its memory ceiling could come back as
exited. The daemon does not always flag that kill, so aSIGKILLunder a memory ceiling that the adapter did not send is nowkilled_oom, and a non-zero exit's detail names its status. -
An outbound HTTP reply came back as a bare
BaseModel.HttpServicePort.invokenow also takes the operation spec itself and returns the model it declares, so a strict type checker reads the response's fields. The name form is unchanged. -
Two mapping fields on one deps module could not be passed together under a strict type checker. A shared key type variable rejected every
StrEnum-keyed argument but the first, as inPostgresDepsModule(client=…, rw_documents=…, searches=…). -
A parallel DST sweep died under a coverage session.
parallel_sweepfailed before any seed ran underpytest --cov; it now runs on amultiprocessing.Pool, which pickles only the caller'srunand the seeds. -
HttpAuthConfigwith no token sentAuthorization: Bearer None. A config declaring no token still sent the header; it no longer does, and the same fix coversHttpClientLifecycleStep.auth_tokenand Meilisearch'sapi_key.
Security¶
- Two principals using one idempotency key no longer share its claim. A claim is scoped to the tenant and the acting principal, so a caller reusing another's key runs its own operation instead of being served that caller's stored result. A custom store inherits
ClaimPrincipalMixin.
0.7.0 - 2026-09-10¶
Added¶
-
Untrusted code finally has a tier that can hold it.
forze_sandbox.containerruns a sandbox route in a container with no network, no capabilities and a non-root user; the workspace crosses as tar, a memory over-run comes back askilled_oom, and killing a run reaps everything it started. -
Sandbox routes can bound the child. Memory, CPU and open-file ceilings plus a dropped user move a
SubprocessSandboxroute to theprocessisolation tier; the kill takes the child's process group, andrun_streamyields output as it arrives. -
Code can run out-of-process under governance. The sandbox plane ships
SandboxSpec/SandboxPort, aSubprocessSandboxthat declares no isolation, and a scriptable mock. Any provenance buttrustedfails the boot on an adapter that cannot contain it, and resolved secrets are masked out of captured output. -
Realtime connections have a shipped resolver.
build_ws_connection_resolverand its Socket.IO twin verify one credential ladder — reauth payload, cookie,Authorization, opt-in query — filling principal, tenant, device andexpires_at. Cookie mode will not build without attesting the Origin allowlist. -
The in-memory mock can survive a restart.
MockStatePersistenceplusmock_state_lifecycle_stepload a snapshot at startup and write one at shutdown, so an MVP onforze_mockkeeps its data without a container. Off by default; one process, no crash durability, still all in RAM — a snapshot, not a database. -
Mongo implements the inbox.
MongoDepsModule(inboxes={...})withMongoInboxConfigregisters a consumer-side dedup store: one atomic_idupsert per message, no index migration, riding the ambient transaction so the mark rolls back with the handler (exactly-once effect). -
Mongo tenant offboarding holds a lock. A
dropDatabaseauthorised by a stale read could destroy a tenant onboarded in between, so teardown now takes a document lock kept outside the database it drops, and onboarding reads back its own marker. Both refusals areconcurrency, so a retry treats them as transient. -
Mongo implements tenant provisioning.
MongoDatabaseTenantProvisioneronboards by writing a marker document into the tenant's own database, so a connection missing rights there fails the onboarding rather than a customer's first request. Teardown is opt-in and refuses a shared, system or unmarked database. -
Mongo implements the rotating-credential store. Mongo cannot block on a document, so exclusion is a lease rather than a row lock and the plane needs no replica set. A worker inheriting an expired lease re-exchanges only where the previous holder demonstrably never reached the provider.
-
Mongo implements the HLC checkpoint. One
$maxupsert persists the runtime's clock high-water mark, riding the ambient session so it commits with the rows it stamps. No index to migrate. -
Mongo implements durable execution.
MongoDepsModule(durable_step=…, durable_run=…, durable_schedule=…)registers the step-memo journal, the run store (claims, leases, fences, run control) and the cron schedule store. The run collection needs a partial unique index onidempotency_key. -
Mongo implements idempotency, co-located.
MongoDepsModule(idempotencies={...})withMongoIdempotencyConfigregisters a store whosecommitrides the caller's session, so the result record and the business writes commit atomically; claims and releases run detached. -
Archives carry a logical identity beside the byte one. Each data file records an order-independent
content_digestof its rows;compare_contentsays whether two builds are the same content, refusing files it cannot compare;run_manifestbinds a run's report to the commit and lockfile that produced it. -
Three reflection gates in
forze.testing.assert_pure_module(import allowlist plus a named forbidden list),assert_scope_first(every Protocol method takes the ownership key first: named, typed, positional-only, undefaulted),assert_operation_namespaces(edges disjoint). Each refuses an empty discovery. -
Identity spec selections are enumerable and validated.
identity_document_names(specs=...)returns validated names for a selection of identity documents; five feature groups ship beside it (grant resolution's nine tables, decisions, delegation, password lifecycle, tenancy), each pinned to its factory by test. -
AuthzDocumentScopeWrapcan explain empty pages (explain_empty=True): no policy restriction makes an empty pageno_match; otherwise one single-row probe of the caller's own filters decidesnot_permittedvsno_match. The probe's rows never reach the caller; off by default. -
bypass_pathsonInvocationMetadataMiddlewareandSecurityContextMiddleware— exact mounted paths neither middleware runs for, so a liveness probe is answered before the runtime scope opens instead of returning 500. PassDEFAULT_HEALTH_PATHS; the empty default changes nothing. -
attach_readiness_routecan probe the dependencies, not just the drain gate. Naming client dep keys inprobesadds a per-dependency breakdown and adegraded503 when one is unreachable — an un-drained process whose database is gone used to answerready.probe_timeoutbudgets each probe. -
RuntimeSettingsholds the argument lists ofbootstrap_loggingandbootstrap_telemetry— log level, render mode, the telemetry exporter and the version parts behind a computedfull_version. It defaults the render mode to JSON, because a settings object exists where something is being deployed. -
Every integration now ships a
<Backend>Settingsmodel holding the endpoint, credentials and client knobs a deployment sets, and exposing both the connection string its lifecycle step takes and the backend's own config object. A missing endpoint is refused by name when read, andssl=Trueverifies. -
forze.base.settings.EndpointSettings— thehost/portpair and the URL-authority grammar every scheme built from it needs: IPv6 bracketing, port joining, blank-host refusal. Subclassed by the URL-building integration settings, which keep their own scheme and query parameters. -
MIN_SECRET_BYTESis exported fromforze_identity— the 32-byte floor the authn kernel and signers already enforced, now readable by an application validating its own settings, so a short secret fails at boot naming the variable.RenderModejoinsLogLevelinforze.base.logging. -
Four helpers each integration package used to carry its own copy of are now core and exported:
log_server_error,drain_domain_events,resolve_result_snapshot,domains_from_create_payloads. -
Empty pages can say why they are empty. Every page value object and kits response DTO carries an optional
abstention—no_match,ambiguousornot_permitted— so a gated read can tell "nothing matches" from "rows you may not see". No reason by default; hits beside a reason, or an unknown one, refused. -
Every backend the published skill described now has code you can run. Thirteen backends were named in prose and imported nowhere; each now carries a wiring block the corpus gate resolves against the installed packages.
Changed¶
-
yckms_lifecycle_stepandbuild_socketio_serveraccept aSecretStrfor their token and Redis DSN, matching every sibling lifecycle step, and unwrap it at the boundary — soYcKmsSettingsandSocketIOSettingsfeed them without an unwrap at the call site. Plainstris unchanged. -
forze[fastapi]now needsfastapi>=0.138.0(was>=0.137.0). The startup checks walk the app's real routes throughfastapi.routing.iter_route_contexts, which 0.137.0 does not export — importingforze_fastapi.middlewaresraisedImportErrorthere before the app ever started. -
BREAKING —
forze[mcp]moves to FastMCP 4 and MCP SDK v2.build_mcp_server,register_toolsand the auth classes are unchanged, but the SDK's protocol fields are snake_case now, so client code readingtool.inputSchemawantsinput_schema. Pinfastmcp<4to stay on the 3.x line. -
BREAKING — the 21 published Agent Skills are merged into one,
forze-skills. Installing now brings a routing index over 45 lazily-read reference files; per-skill install is gone, because it left every cross-link dangling. Re-run the installer, then delete the stale directories by hand.
Removed¶
-
BREAKING — 16
routed_<backend>_lifecycle_stephelpers are gone. Each only forwarded torouted_client_lifecycle_step, which callers now use directly, passing the step name explicitly. Postgres and Temporal keep theirs: those add a capability and schedule bootstrap. -
BREAKING — the per-package logger-name enums are gone (
Forze<Backend>Logger,FORZE_<BACKEND>_LOGGER_NAMES, 26 packages). Name a package root instead and the stdlib hierarchy reaches every logger beneath it. CoreForzeLoggeris unchanged. -
BREAKING — unreferenced public helpers are removed:
Logger.notset,ForzeSocketIOAdapter.include_routers(loop overinclude_router),has_query_values,has_query_fields,MockRoutedStateRegistry.state_for, plus 20 unused internals.
Fixed¶
-
An idempotency claim reclaimed by a duplicate can no longer be committed or released by the operation it displaced. The claim records the invocation that took it, wired like the tenant provider — no port signature change. Postgres needs one optional nullable column; the other stores need no migration.
-
The Firestore integration suite runs again. Its pinned emulator image had aged out of the registry, so all 104 tests errored on
manifest unknownand took the coverage and conformance gates with them. Bumped to the newest tag the registry serves. -
A durable run or schedule registered for a tenant nothing is bound to now lands where that tenant looks for it. The relation came from the binding while the tag and the scoped id came from the argument, so on a per-tenant relation the row was written somewhere nobody reads and the schedule never fired.
Security¶
-
A bound tenant can no longer reach another tenant's durable run.
begin,renew,loadand the terminal writes enforced no tenant; they now reach the bound tenant's runs and untagged ones. Postgres, Mongo and the mock. -
required_tenant_isolationnow coversdurable_step,durable_runanddurable_schedule, which previously passed wiring below a declared floor. An explicit tenant contradicting a bound one is refused (authentication/tenant_mismatch).
0.6.0 - 2026-08-15¶
Added¶
Statement origin — a second tenancy floor, set by what wrote the statement. StatementOrigin (structured → none, compiled → namespace, raw → dedicated), declared per route via TenancyRouteSpec.origin / TenancyRouteGroup.origin and enforced at freeze in validate_module_tenancy, independently of required_tenant_isolation. New required_isolation_for_origin, validate_origin_isolation, ORIGIN_ISOLATION_FLOOR_CODE ("statement_origin_isolation_floor"). Nothing that wires today changes: structured is the default and no shipped config declares an origin.
Governed dynamic read — an opt-in read plane for statements whose text is data (catalog SQL, a semantic layer's output, a report builder). New contracts/dynamic_read/ + ctx.dynamic_read.query(spec), resolvable in QUERY operations; Postgres and mock adapters:
DynamicReadSpec(row_cap=10_000,max_statement_bytes=65_536,capture_statements=False) andDynamicReadPort.run/.select(return_type, …). No pagination, noIn/Outgenerics, no encryption field — both shapes are runtime data, so a sealed column returns ciphertext. Per-callDynamicReadOptionsclamp down only; exceedingrow_capraises rather than truncates.PostgresDynamicReadConfigruns every statement in aREAD ONLYroot transaction on its own connection, withSET LOCAL statement_timeout/search_path/ROLE, streamed over the extended query protocol. No SQL is parsed anywhere: writes, multi-command strings and cross-schema reads are refused by Postgres. Taxonomydynamic_read_write_refused,_statement_invalid,_multi_statement,_permission_denied,_timeout,_row_cap_exceeded,_statement_too_large,_row_type_mismatch,_role_unavailable.- Wiring fails closed:
provenance("trusted"/"untrusted") has no default; routes declareorigin="compiled", so any tier belownamespaceraisesstatement_origin_isolation_floor. Alsodynamic_read_untrusted_unconfined(untrusted with no role or routed client) anddynamic_read_shared_role_across_tenants(per-tenantquery_schema, shared staticrole). PostgresSchemaTenantProvisionerrefuses a shared static role beside a per-tenant schema (tenant_role_shared_across_schemas), a role already bound elsewhere (tenant_role_already_bound), and one that already reaches somewhere —LOGIN,SUPERUSER,BYPASSRLS, or membership of another role (tenant_role_not_confinable). Teardown drops the schema first and reports a surviving dependency astenant_role_still_depended_on.MockDynamicReadRegistryanswers routes from a handler; engine-enforced refusals are declared in the divergence catalog rather than faked. Newdynamic_readconformance plane anddata-events/dynamic-read.md.
Postgres COPY bulk load — the engine-native bulk path, 20–79× faster than the multi-VALUES INSERT it replaces (10⁴–10⁵ rows × 6–20 columns, interleaved A/B):
PostgresClientPort.copy_rows((schema, table), columns, rows, *, binary=False, column_types=None) -> int— sync or async row iterables, joins the caller'stransaction(). Raisescopy_row_invalidwith the server's COPY line/column, orcopy_type_mismatchfor a binary type mismatch. In binary mode astrfor a declaredjson/jsonbcolumn is refused (text and binary want opposite Python types there).- Analytics
appendon Postgres executes overCOPY; port contract, encoding, field encryption andAnalyticsAppendResultunchanged.PostgresAnalyticsConfig.max_append_rowsdefault rises 10 000 → 100 000. ClickHouse and BigQuery caps unchanged.
Temporal, deliberately thin — package identity fixed at connection, codec, interceptors, schedules and lifecycle (no workflow model, ever):
TemporalClientPort.nativereturns the configured SDK client (codec, interceptors, rpc metadata included) — the escape hatch the durable contracts' docstrings already promised.TemporalStartOptions(retry policy, execution/run/task timeouts, id-reuse policy, memo, search attributes, start delay) onTemporalWorkflowConfig.start_options, plus a field-by-field per-call override on the Temporal adapter'sstart. Unset fields are omitted from the SDK call; a non-positive timeout raisesconfiguration.DurableWorkflowCommandPort.startis unchanged, and schedules do not inherit these.temporal_worker_lifecycle_step+DEFAULT_WORKER_GRACEFUL_SHUTDOWN: supervised crash restart, optionalmax_consecutive_crashes, drain registration, and a 10 s activity window bounded by the runtime's drain deadline (the SDK's default is zero). Refuses a worker registering neither workflows nor activities. New Run a Temporal worker recipe.ExecutionContextInterceptor(auto_heartbeat=True)beats at a third of each activity'sheartbeat_timeout; off by default. Details-bearing heartbeats stay raw SDK.
Durable run control — cancellation for the self-hosted function tier, cooperative only (no hard kill in the contract):
DurableRunAdminPort.request_cancel(run_id)records an unfenced ask; only the fence-holding runner lands it (PENDINGstops at once,RUNNINGat the holder's next lease heartbeat, a dead holder's run at recovery without re-invoking the body). NewDurableRunRecord.cancel_requested_at/cancel_refused_at;DurableRunStorePortgainsmark_cancelled/mark_timed_out/refuse_cancel. Tenant-scoped likelist_runs.- Sagas decide by the pivot: before it, compensate and land
CANCELLED; at or after it, refuse and complete forward. NewSAGA_CANCELLED_CODE/SagaProgress.step_cancelled_error. - New
DurableRunControlAware/DurableRunControlCapabilities;DurableFunctionRunner.request_cancelfails closed withoutsupports_cancel.
Breaking: DurableRunStatus gains CANCELLED + TIMED_OUT; max_run_duration now lands TIMED_OUT instead of FAILED (same error text). Telemetry gains the cancelled / timed_out / unrecorded outcome labels; an attempt whose terminal write never lands is now unrecorded, not completed. DurableRunStorePort.renew returns DurableLeaseRenewal(held, cancel_requested), not bool. Migration, required before deploying: ALTER TABLE <durable_run> ADD COLUMN cancel_requested_at timestamptz, ADD COLUMN cancel_refused_at timestamptz;
Three CI ratchets, each with a declared, shrink-only exemption table and wired into just quality:
- Docs floors (
[tool.docs_floors],just docs-check) — every publicDepKey/*Specundercontracts/must be mentioned inpages/docs, the nav must resolve both ways, and every relative doc link must land. Newreference/spec-registry.md. - Conformance manifest (
[tool.conformance_manifest],just conformance) — everyDepKeyis claimed by exactly one plane, legs carry@pytest.mark.conformance(plane=…, engine=…), and a manifested leg that ran on no shard fails CI.test_forze_inferenceandtest_portabilityjoin the CI matrix. Newforze_dst.conformancescenarios andforze_kits.integrations.realtime.conformance. - Mock catalog gate (
[tool.mock_gate]) — every operation in the driving example's catalog must resolve and answer against seeded data, and every plane it serves must be one the seed plan fills. - Nightly DST matrix (
.github/workflows/nightly.yml,just dst-nightly/dst-nightly-all/dst-nightly-cells) — the flagship scenarios over 65,536 seeds per cell across fourFaultProfileenvironments declared intests/support/dst_flagship.py, with cells derived from the profiles. The verdict fails on a missing cell, an empty band, a violating seed, a declared reachability target the band never drove, or an undeclared result.
Operation progress — long-running work gets an observable shape, kit-level (forze_kits.integrations.progress), no core-contract changes:
job_record_spec()(oneJobRecordper job — status, fraction, message,heartbeat_at,durable_run_id; DDL documented), the declaredjob.progressrealtime event,ProgressReporter(coalescing window, monotonic clamp,track()),JobProgressProjector(find_stalled/count_stalled) andprogress_spec_contributions().job_staleness_lifecycle_step()+instrument_job_progress()exportforze.jobs.stalled,forze.jobs.stalled.oldest_silenceandforze.jobs.staleness.scan_age. Ticks ride the ephemeral lane, status transitions a dedicated outbox route (progress_outbox_spec()); a job is task-grained and spans many durable runs (waitingis non-terminal).ArchiveExporter/export_archiveandrebuild_search_indextake an optionalprogress=reporter.
Observability last mile — SDK setup, probes, and a deployable Grafana stack; no instrumentation change (no metric added or renamed):
bootstrap_telemetry()(newforze.base.telemetry, newobservabilityextra) — resource identity with a per-processservice.instance.id, OTLP http/protobuf exporters, millisecond views onforze.operation.duration/forze.durable.run.duration, and aTelemetryHandlewithflush()/shutdown()for the drain path. Defers per signal to an application-installed provider (on_existing_provider="error"to refuse instead); config stays on the standardOTEL_*variables. Plusmillisecond_histogram_views().- Probes —
attach_liveness_route(/livez, drain-blind by design) beside/readyz;probe_listener_step(runtime, port=…)serves both from any non-HTTP process on stdlibasyncio;attach_metrics_route(router, reader)for single-process pull scrapes (needsopentelemetry-exporter-prometheus; refuses a non-PrometheusMetricReader). - Docs and assets — new The Grafana stack recipe (compose +
config.alloy+ label/sampling/bucket doctrine + K8s probe mapping), four provisioned dashboards, a Prometheus alert pack, and a newreference/metrics.md. Parity-tested againstsrc/in both directions, expressions included.
Cross-backend conformance — shared batteries for the counter, graph, storage, inference, search, idempotency, field-encryption and realtime-cursor planes; each change below holds on every backend of its plane:
- Counters are signed 64-bit: new
COUNTER_MIN_VALUE/COUNTER_MAX_VALUEandvalidate_counter_value; an out-of-rangeresetraisescounter_value_out_of_range. - Storage:
delete/abort_uploadare idempotent; a same-keycopy/moveis refused with newSELF_COPY_CODE; mocklistis lexicographic; S3 mapsNoSuchUploadtonot_found. Behaviour change: the mock models bucket existence (newMockState.storage_buckets) —listover a never-written bucket now raises unlessmissing_ok=True; reads never provision, the four write paths do. - Inference: a
max_batch_sizecap sub-batchespredict_stream; a spent budget raises newinference_budget_exhaustedpre-flight; scalar outputs wrap via newscalar_output_field.FULL_INFERENCE_CAPABILITIES.supports_async_jobsis nowFalse. - Search:
delete_all()on an unprovisioned index is a no-op; a Meilisearchinvalid_requestraisespreconditionwith the engine's message. - Graph: new
normalize_property_filterandMockGraphManagementAdapter;UUID/datetime/Decimalproperty filters match everywhere; aNonefilter value matches nothing; duplicate vertex keys raisegraph_vertex_conflict(Neo4j needsensure_schema()). - Idempotency: key reuse with a different payload raises
conflicton the Redis store too.
Secrets lifecycle plane — versions, change feed, hot reload, durable rotation, leases; SecretsPort unchanged:
resolve_versioned/current_versionandSecretsAdminPort.put(newsecrets_admindep key); per-backendSecretsCapabilitiesfail closed.- Change feed and hot reload:
SecretChanged/SecretsChangeSource,SecretsPollWatcher, Kubernetes-awareDirectorySecretsChangeSource(optionalwatchfilesaccelerator),SecretsHotReloadBinder;SecretRotatednotifications ride outbox → pub/sub. - Durable rotation:
SecretRotator(staged, fenced, CAS promotion) withforze_postgres.PostgresRotationTargetandforze_mongo.MongoRotationTarget(dual-user by default; degraded single-user/role modes are explicit opt-ins). Leases:DynamicSecretsPort,forze_vault.VaultDynamicSecrets,SecretsLeaseManager. - Counterparty-rotated credentials:
RotatingCredentialStorePort(rotating_credentialskey; new codescredential_burnt/credential_exchange_timeout/credential_persist_lost),forze_postgres.PostgresRotatingCredentialStoresealed at rest by default (plaintext needsacknowledge_plaintext=True);forze_kitsaddsCredentialSweeperandRotatingCredentialsAdminPort.due_for_refresh. Migration: the documented DDL gainsCREATE INDEX ON <relation> (tenant_id, updated_at). Behaviour change: the mock's store is tenant-aware per scope — resolve withdeps.resolve_simple(ctx, …). - New
forze.base.primitives.StripedAsyncLocksandRoutedTenantClientBase.cached_tenant_ids(); walkthrough inexamples/recipes/secrets_rotation/.
DST evidence — quantitative verdicts, Adya-complete isolation battery, mock↔real fidelity:
forze_dst.stats: exact Clopper–Pearson detection bounds in every clean verdict, plus a stdlib-only survival kernel (Kaplan–Meier, log-rank, Fisher exact); newdirty_write/intermediate_readanomaly cases with Adya labels.forze_dst.conformancefidelity matrix and corpus transfer against real Postgres (just dst-fidelity/dst-transfer);forze_dst.misusecorpus; detection-time campaigns (forze dst campaign); mechanical depth extraction (forze_dst.depth).- Falsifiability witnesses and horizon accounting:
Simulation(witnesses=…, horizon=…),mine_witnesses/replay_witnesses/account_invariants,named/name_of,FaultRule.at_call. Behaviour change:no_unclosed_transaction()violations report their own name. - Honest denominators — every clean-run bound states what it divides by, and names its weakest per-invariant denominator. New
stats.coverage_deficit/format_coverage_deficit,stats.familywise_level/format_family_verdict(opt-in--dst-family-verdict/ inidst_family_verdict),stats.flip_margin,stats.format_withheld_verdict,CoverageStats.shape_counts/deficit,ConfidenceReport.at_risk_runs/unmeasured_exposure/shape_counts/redundant_seeds/weakest_exposure, andinvariants=onHorizonProbe/ConfidenceProbe. Seed redundancy is a warning, never a corrected denominator.
Serve your real app on in-memory backends — the mock composes with real modules, and the whole app can be served on it:
- Hybrid deps contexts — keyword-only
fallback=onDeps.plain/Deps.routed/Deps.routed_groupmarks registrations that yield to a real one instead of colliding, order-independently;MockDepsModulemarks everything, so "real Postgres, mock everything else" is one deps list. Two real (or two fallback) registrations still raise. NewFallbackReportfromProviderStore.fallback_report(), also oncheck_wiring(...).fallbacks; itscatch_allnames keys where an unregistered route silently reaches the fallback. forze_mock.server(newmock-serverextra) —forze mock serve module:attrserves a declaredMockAppoverMockDepsModule, with a/_mockcontrol plane offeringreset,seed,state/{store},fault,latency,disarm,time,emit,health. Refuses withoutFORZE_MOCK_SERVER=1or a fallback-marked mock module; the control plane needs aMockSessiononly the builder mints. NewMockState.clear().forze_mock.seeding— deterministic seed data across four planes:SpecSeed/SearchSeed/StorageSeed/QueueSeedon oneSeedPlan, with fixtures (load_fixtures) and inferred references (SeedPlan.links,SearchSeed(ids_from=…)).rng_seed+SeedPlan.instantreproduce a plan byte-identically across processes. Needspolyfactory(in thedstextra).- Recipes
mock_server/(with a container),mock_workspace/,realtime_sse/served.py; docs: Serve your app on the mock.
Server-side CSRF gate on cookie ingress, on by default — CookieTokenAuthn.csrf (new CookieCsrf): an unsafe-method request using the cookie must prove a same-host or allowed_origins origin via Origin/Referer, refused as 403 csrf_rejected; allow_missing_origin=True admits header-less non-browser cookie clients, csrf=None opts out.
README rewritten around a runnable, transport-free slice — new test-backed examples/hexagon/ (domain → port → wiring, no web framework), what the library deliberately does not do, how to write an adapter for an unshipped backend, and a stability statement in place of the SemVer boilerplate; banner now served from the repo.
Changed¶
build_runtime takes its modules like every other argument (breaking) — the variadic *modules becomes a normal deps_modules parameter, so a list no longer has to be spread and can finally be named: build_runtime(module), build_runtime([a, b]) and build_runtime(deps_modules=[a, b]) all work. deps, lifecycle_modules, lifecycle_steps and specs accept one item or many on the same footing. Migration: two or more modules passed positionally become one sequence — build_runtime(a, b) → build_runtime([a, b]); concurrent_lifecycle= is now lifecycle_concurrent=.
specs=accepts every contribution to merge — the author's own registry, eachAggregateKit.spec_contributions(),forze_identity.spec_contributions()— instead of one pre-merged registry. Merging happens into a fresh registry, so no argument is mutated; a frozen registry is still accepted alone but is refused inside a list.shutdown_step_timeout,cursor_token_signerandcursor_token_cipherjoin the pass-throughs; setting any of them no longer means abandoning the assembler and composingExecutionRuntimeby hand.
Fixed¶
A routed tenant resolver was never found, so authenticated requests bound no tenant (behaviour change) — TenancyDepsModule/local_identity_deps register TenantResolverDepKey routed while TenancyDeps.resolver() looked it up plain-only, so tenant-aware adapters failed closed with tenant_required. resolver()/manager() now take an optional route (the authn spec's name), falling back to plain. MockDepsModule registered both tenancy keys with the wrong factory arity; new route_simple_stubs/ConstantSimpleMockFactory fix it.
Numeric exactness fails closed on every backend (behaviour change) — a value a backend cannot hold exactly raises precondition instead of being silently degraded: Firestore refuses non-finite and double-overflowing numerics (previously persisted as inf/nan); BigQuery Decimal parameters pick NUMERIC vs BIGNUMERIC by value, arrays widening to their neediest element; Mongo refuses a Decimal beyond BSON decimal128 exactness; DuckDB refuses non-finite and beyond-DECIMAL(38) values (previously rebound as a rounded DOUBLE).
Storage fails closed on an absent container — two changes:
StorageQueryPort.listreturns aStoredObjectPage(objects,total,container_missing) instead of a(objects, total)tuple, and the kit'sListedObjectsgainsprovisioned, somissing_okno longer hides that the container is absent (breaking). Migration: unpack sites becomepage.objects/page.total.- A missing bucket is
configuration, not retryableinfrastructure(behaviour change) — S3, GCS and the mock agreed on a kind the egress policy reads as retryable, so a saga step or consumer loop retried an unprovisioned bucket forever. Client-facing behaviour is unchanged; only the retry decision moves.
Graph reads no longer answer short or silently absent — two changes:
GraphNodeSpec/GraphEdgeSpecrefuse akey_fielddeclared asbool/int/float(graph_non_string_key_field); on Neo4j every keyed read of such a vertex answered as absent (behaviour change).Decimalis allowed: it persists as text, and that text is the key (Decimal("1.50")≠Decimal("1.5")). Migration: use astr/UUIDkey and keep the number as an ordinary property.neighbors(to_vertex_kinds=…)appliedLIMITbefore the kind filter and could come back short or empty; the filter now runs in Cypher. A bounded call returns an arbitrary — but full — page.
Realtime and streaming hardening (behaviour change):
build_realtime_mailboxtakes a requiredretention=— aMailboxRetentionwindow orMailboxRetention.unbounded(reason=…)— and a declared window with norealtime_mailbox_retention_lifecycle_stepbehind it is refused at build (realtime_mailbox_retention_unwired) (breaking). Migration: passretention=at every build, including the Socket.IO/SSEmailbox_factory.- WebSocket: fail-loud closes are bounded (a non-reading client can no longer block them); with no
allowed_origins, a browser upgrade carrying cookies must be same-host; a connection without device identity keys its ack cursor by the stablews:{principal}instead of forcing a full replay every reconnect. - Socket.IO: an unset
require_tenantfollowsbind_tenant_from_headers, dropping untenanted signals instead of emitting them to the global room; a drain-gate refusal mid-bridge stops the loop without acking, counting or poisoning (new sharedforze.application.execution.is_draining_refusal); the AsyncAPI document and the dispatch surfaces share one set of wire constants (newREALTIME_ACK_EVENT/REALTIME_REAUTH_EVENT) and the document gains therealtime.reauthoperation. - Commit-stream dead-lettering is exactly-once for any producer: a DLQ copy of a message without a
forze_event_idheader carries a deterministically minted dedup id (a re-produced copy could previously process twice); sealed envelopes stay byte-identical.
Routed consumers survive credential rotation — guarded=True now works across the SQS, RabbitMQ and Kafka facades (previously the first call raised). evict_tenant moves an in-flight RoutedSQSClient.consume onto fresh credentials at the next poll instead of tearing it down; RoutedRabbitMQClient.consume re-acquires the tenant's client per fetch, so it polls where single-tenant RabbitMQClient.consume keeps a push subscription. Non-retryable resolution failures still raise out of the stream. Migration: SQSRoutingCredentials.secret_access_key is now SecretStr — read it via get_secret_value().
The spec inventory closes the routeless-provider blind spot (behaviour change) — declaring a spec_registry installs a resolve-time guard (new inventory_route_guard): an uncatalogued route on an inventoried plane is refused at first use, whatever the provider's shape; allow_unregistered=True downgrades to one warning per route.
DST audit() no longer claims a detection bound for invariants the sweep could not falsify (behaviour change) — such witnesses are new InvariantStatus.UNEXERCISABLE and audit() fails naming the missing capability. New forze_dst.oracle.config_capabilities; invariant_accounting/account_invariants accept config=.
A plateau-stopped DST coverage() sweep printed an exact fixed-n bound over a stopping time (behaviour change) — the early stop chooses n from the runs the bound summarizes, so the sweep now states its stop reason and prints no bound; stats.format_withheld_verdict renders it, and ConfidenceReport.data_dependent_stop carries the design. audit() is byte-identical (it has no plateau).
Log scrubbing masks pluralized, numbered, and compound-authorization names — tokens=…, "api_key2": "…", authorization_value: … previously leaked; the email and userinfo-DSN rules are length-bounded, removing a quadratic-cost path on adversarial log text.
Postgres update_many/touch_many could attach one document's returned diff to another when the server reordered RETURNING rows; diffs are now keyed by document id.
Postgres pool exhaustion answers 429, not 409 (behaviour change) — psycopg_pool's PoolTimeout/TooManyRequests map to throttled with code pool_acquire_timeout instead of concurrency; a wait that ends with the pool unable to connect at all is infrastructure with code database_unreachable. The server's own too many connections is unchanged. Both stay retryable at egress, but neither is retried in place by the occ policy, which retries concurrency only.
The mock-server recipe's container never built — unresolvable version metadata, an unfiltered build context, and a missing authn extra; both images are now pinned by digest.
Also fixed: a paged Temporal schedule listing dropped schedules when a limit was reached mid-page (cursors stay opaque, but a mid-page one is no longer a bare Temporal token — do not decode it); Mongo counter allocations cost one round trip instead of two, and a legacy document appearing after the counter exists is left in place rather than retired; local KMS seals each wrap under a one-shot HKDF subkey, so the AES-GCM ~2^32 ceiling never accrues against a master key (earlier envelopes stay readable).
0.5.1 - 2026-07-25¶
Added¶
Inference seam — typed model invocation behind one port; local artifact, served endpoint or cloud runtime is a wiring fact.
- Handlers call
ctx.inference.model(spec)for single, batch (all-or-nothing) or streaming prediction; a read-plane port, output validated against the declared type. - Backends: local (off-loop loader, fail-closed warmup), programmable mock, KServe-V2/MLflow over HTTP and SageMaker (extras
inference-http,inference-sagemaker); per-backend capabilities fail closed. - Remote routes require
acknowledge_data_egress=Trueand honour all four tenant-isolation tiers (dedicatedneedsRoutedInferenceHttpClient/RoutedSageMakerRuntimeClient); upstream error bodies are withheld from errors and logs, and SageMaker pins botocore to one attempt unless configured. Simulation masks inference inputs unless a spec setscapture_inputs=True.
Portability (forze_kits.integrations.portability) — carry an application's system-of-record state to any other wired backend.
export_archive/import_archivemove the document, blob, graph and counter planes through a backend-agnostic archive (ids preserved, revisions reset);migratefuses the same pipelines port-to-port with no artifact, re-sealing under the target's keys.- Scope is declared, never inferred; a quiesced claim requires an attested report, and per-tenant exports omit identity specs unless asked. Archives are plaintext unless a sealer is passed; gzip by default, zstd via the
zstdextra.
Export foundations — every plane an application binds is enumerable, quiescible and streamable; an export refuses anything undeclared.
- Spec inventory — apps register specs via
build_runtime(specs=…), reconciled against the wired deps at startup; identity and aggregate kits contribute theirs. A drift signal, not a gate. - Quiesce —
quiesce()stops admission, waits out in-flight work, polls the outbox/durable/stream-group planes to rest, and returns settled/attested verdicts; without closing the gate it is a health check that cannot attest. - Counter backends — Postgres, Mongo and Firestore join Redis. Counter operations never join the caller's transaction — breaking for custom implementations of the three client ports; Postgres needs an app-migrated counter table.
- Graph streaming reads — capability-gated keyset streaming of vertices and edges (Neo4j, mock). Breaking for custom graph query ports: two new protocol methods.
- Search-index rebuild, relay drain, provenance —
rebuild_search_indexbackfills idempotently (standalone or on a kit); the outbox relay can drain on shutdown; analytics specs declare projected vs system-of-record. Read-only admin ports expose outbox depth and counter partitions.
Realtime transports & operational hardening — three transports (Socket.IO, SSE, raw WebSocket) behind one versioned wire contract, at the durable plane's operational bar.
- Versioned wire protocol — envelope, cumulative ack and handshake are normative; every transport negotiates the version at connect and refuses unsupported ones. The catalog also renders as an AsyncAPI 3 document.
- SSE and raw WebSocket egress — replay plus live tail on both; SSE resumes from
Last-Event-IDand acks over POST; the WebSocket route adds governed command dispatch. Topic subscriptions authorize fail-closed. - Fail-closed WebSocket scopes — middlewares refuse raw WebSocket scopes unless the exact mounted path is allowlisted. Breaking for apps mounting raw WebSocket routes behind them.
- Transport-neutral kernel — mailbox/cursor seams, replay/ack helpers and presence move into the core realtime integration;
forze_socketiore-exports every established name unchanged. - Supervised loops, retention and perimeter — a shared background-loop runner (jittered restart backoff, crash ceiling) under all realtime loops; stream trim and mailbox age sweeps bound growth; a poison ceiling bounds redelivery; WebSocket Origin allowlist and continuous credential-expiry enforcement with a built-in reauth command. Breaking:
BackgroundLoopControlmoved out offorze_kits.lifecycle(unreleased).
Identity & authn ergonomics
- Cookie-mode authn routes —
AuthnCookieCarrierviaattach_authn_routes(cookies=…): login/refresh set and rotate HttpOnly cookies and strip token strings from bodies; logout expires both idempotently. SecurityContextMiddleware(anonymous_paths=…)— exact paths where an authentication-kind failure binds no identity instead of 401ing; other failure kinds still return the error response.AuthnDepsModule(eligibility="allow_all")— declared opt-out of the policy-principal gate for token-only deployments; unknown values are refused at wiring.- Self-hosted durable→registry bridge —
operation_durable_handler/register_operation_functionsauto-bridgeDurableFunctionSpec.operationlike the Inngest tier (durable_input_invalid,durable_output_invalid). - Routed-client ergonomics — the tenant-scoped accessor is public (
client_scope(), replacing_client_scope) and works in both registry modes;RoutedNeo4jClientpinsguarded=True.
Keys, crypto & mock conformance
- Self-hosted KMS backend —
forze_kms.local.LocalKeyManagementwraps data keys under operator-provided 32-byte master keys, no cloud or extra needed; the multi-key map carries the rotation overlap, a rotated-away key id fails closed, and a one-wayfingerprintspots fleet drift. - Every mock field plane seals — document, graph, search (hub/federated, snapshots), analytics and procedures resolve the same fail-closed encrypting codecs as real backends, via an opt-in synchronous key seam for computation-only backends. Behavior change for mock suites asserting raw stored values; text queries no longer match sealed content.
- OpenBao compatibility —
forze_vaultworks with OpenBao (verified 2.6.1); the integration suite runs against any compatible engine viaFORZE_VAULT_IMAGE.
Docs & agent skills — a "Portability" page (spec inventory, plane dispositions, quiesce, export/import/migrate), cookie-mode authn and anonymous_paths documented, new forze-realtime / forze-inference skills, and a drift sweep realigning the integration index, tenancy/encryption matrices and contracts index.
Changed¶
Breaking
- An edge kind identified by its endpoints now enforces that identity — at most one edge per endpoint pair; a second create conflicts. Migration: kinds that allow parallel edges declare a key field and identify by it; ensure/update/keyed kinds unaffected.
- Graph module specs are validated at construction — duplicate kind names, endpoints naming unknown node kinds, or a key field missing from its read model fail at build, not first use.
- Custom realtime signal sources must accept a stop signal — the gateway, presence heartbeat and identity-expiry sweep are supervised, restart on crash with jittered backoff, and register as drainable.
Behavior
- Background loops stop gracefully instead of being cancelled — kits loops register per scope and stop between units of work; a commit-stream consumer commits processed offsets even when stopped mid-batch.
- Realtime gateway defaults harden — emits time out after 5 seconds; a realtime stream route declaring an encryption tier is refused at start.
- Redis pub/sub subscribers reconnect by default — a transport error resubscribes instead of silently stopping; opt out per client config.
- An outbox relay with no destination is rejected — naming a never-provided transport spec raises at construction instead of dropping the route.
python-socketiocapped below 6 — the gateway and server builder depend on 5.x constructor surfaces.
Fixed¶
Decimal is a first-class filter and sort value across the query DSL — the scalar union omitted it, and every backend showed it differently.
- Postgres compared nested JSON leaves as text and round-tripped numerics through
float; Mongo matched nothing; Firestore could not write the field at all; the mock refused Decimal aggregates; Meilisearch indexed lexically. - Filter values no longer guess locale commas, JSON string bounds cast per field, and non-finite bounds (
"NaN"/"Infinity") are refused everywhere. Migration: rebuild a Meilisearch index whose Decimal fields were indexed from an unreleased build.
Sealed fields are refused as filter and sort keys on every backend, including the mock (breaking) — filtering a randomized field is refused (searchable fields keep equality), sorting any sealed field is refused including a default sort; previously these returned wrong answers. A kind sealing its own graph key field is refused at spec construction, and Meilisearch merge="federation" decrypts through the same seam instead of returning ciphertext.
A saga step interrupted at its own commit no longer reports a consistent rollback — all three drivers (in-process, durable, Temporal) compensate nothing on commit_ambiguous and raise infrastructure-kind saga.step_ambiguous for operator reconciliation (non-retryable in the Temporal mapping); the durable journal keeps the classification across recovery and replay.
Cross-process fingerprints: sets hashed in iteration order — idempotency was live-broken: a byte-identical retry of a command with a set field was rejected as a payload-hash mismatch. Sets hash order-independently now; search-snapshot and federated-cursor fingerprints fixed with it.
Persistence tenancy & fidelity
- Counter tenancy (Postgres, Mongo, Firestore) — counters resolve through the bound tenant and fold the spec route into the stored key (shared relations no longer merge sequences); a pre-route sequence is carried forward. Contended Firestore allocation retries instead of surfacing a concurrency error.
- Mongo history reads scope by tenant — snapshots are stamped and reads filter strictly, closing a tagged-tier cross-tenant read. Migration: a pre-upgrade snapshot carries no tenant and is invisible to every tenant; backfill legacy history rows for pre-upgrade concurrency continuity.
- Mongo could not store a document with a UUID or Decimal field — write coercion applies on insert too; reads convert back exactly.
- Meilisearch reads and filters match the shared seam — filters validate operator-field fit and cast string ordering bounds to the field's scalar family (no lexical
price < "5"); exact counts honorattributes_to_search_on;merge="rrf"sorts only tie-break the fused score (sort-primary =merge="federation"). The filter renderer'sread_modelis now required. - Postgres schema validation no longer rejects boolean columns or skips optional fields — both union spellings unwrap, and exact type matches beat subclass overlap (
bool/int,datetime/date). - Neo4j transaction-scope integrity — a scope opens exactly one transaction, a routed transaction pins its client so a mid-scope credential rotation cannot swap it, and a tenant change mid-scope is refused (
neo4j_tx_tenant_conflict). - Mock parity — the mock graph matches Neo4j on four write-path guards (delete detaches edges, edges require endpoints, duplicate keys conflict, unknown kinds raise), and mock storage creates-or-replaces on unconditional overwrite per the port contract.
- Object storage — streamed (multipart) uploads apply their tags after completion instead of dropping them; listing a missing bucket can read as empty via
missing_ok=True(the default still raises), and listing bounds its per-object HEAD fan-out. - A backward keyset page no longer dead-ends navigation (behavior change) — a
beforepage landing on the start of the set always carries a forward cursor, andhas_morereports that same forward answer; every keyset-paging backend.
Portability, quiesce & inventory
- A full-system scope must declare its tenant dimension (breaking) — no default; a full export/migrate writes one archive section per declared tenant (archive format 2), counters included.
- Imports confirm their tenant scope (breaking) — a per-tenant archive imports against an explicit
tenant=, andexpect_tenants=anchors a full-system archive's section set so a tenant deleted from the manifest cannot vanish with checksums passing. The artifact is cross-checked against the manifest and the target's plan: an unlisted data file or an expected-but-unlisted plane refuses the import, and identity or field-encrypted specs require a sealer or an explicit plaintext acknowledgement. - Quiesce attests only what it observed — unreadable planes block attestation;
QuiesceReportcarriestaken_at(breaking for hand-built reports: now required) and the tenant partitions probed, and the export gate refuses an attestation not covering the scope's tenant set. Cleanly stopped loops flush the relay before the sweep (pubsub excepted). - Inventory registration refuses conflicting metadata — a disagreeing re-registration raises; an empty registry over bound planes fails reconciliation; exporting against an empty inventory is refused.
Realtime
- A cap-filled replay no longer lifts the ack clamp —
iter_backlog(replaceshas_entries_after) drains past the cap in bounded rounds and never lets acks claim a partially delivered equal-HLC run; an unconfirmed drain ends the stream (WS close 1012), andLast-Event-IDresume is id-anchored on both transports. Cumulative acks no longer skip — then trim — undelivered mailbox entries. - SSE acks require
?device_id=...(behavior change) — the device-less fallback cursor was shared across a principal's tabs; device-less streams still work viaLast-Event-IDresume. - A principal in two orgs could wedge the ack path forever — the per-device cursor id includes the tenant, and the compare-and-advance loop is bounded (
realtime_cursor_advance_stalled). - An untenanted signal no longer stops the gateway for every tenant — a per-signal error bounded by the poison ceiling, not a process-terminal verdict.
- A non-JSON payload never tears down a transport —
ErrorEnvelope.contextcoerces to JSON-renderable values and theencode_frame/jsonable_framehelpers back every WS frame, SSE data line and Socket.IO error ack; a hostile binary frame closes cleanly. - Node-local presence on a multi-node backplane suppressed every live emit — refused under a pub/sub Socket.IO manager instead of silently dropping.
- Redis counter reset returned the previous value, not the new one (behavior change); batch allocation uniformly accepts size one and rejects smaller on every backend.
Broker & consumer delivery integrity
- Draining no longer parks in-flight work as poison — a drain-gate refusal requeues without counting a delivery attempt and stops the loop; the offset-log consumer stops with its offset uncommitted for redelivery instead of dead-lettering a healthy message.
- SQS FIFO poison and requeue handling (behavior change) — a terminal nack retains a copy on the configured poison queue and deletes instead of wedging the group forever (if the copy cannot be sent, the original is kept and the group stays blocked; standard queues unchanged);
nack(requeue=True, count=False)restarts the receive count. - The RabbitMQ pending map leaked on partial ack and after channel recovery — channel reopen purges stale delivery tags; only confirmed acks/nacks are counted and settled.
- Kafka — a failed rewind is no longer read as a benign rebalance, and a poison marker carries decoded headers and message type so a forwarded sealed envelope stays decryptable for triage.
- Permanent dependency faults are no longer retried forever — a deleted or disabled KMS key maps to
CONFIGURATIONon AWS/GCP/Yandex (behavior change; ambiguous states like access-denied stay retryable), and the commit-stream supervisor retries retryable crashes indefinitely with a newcrash_alert_after(default 5 min,Nonedisables).
Outbox, sync & boundaries
- JSON-boundary encoding — transactional outbox staging, Meilisearch search-synced writes, BigQuery analytics ingest and Inngest events now encode to JSON at the seam instead of handing Python-mode maps (live UUID/datetime/Decimal) to JSON serializers. Postgres and ClickHouse keep the Python encode on purpose.
- The search-sync outbox route declares
require_transaction— a marker flushed outside a transaction is refused (core.outbox.flush_outside_transaction); hand-rolled wiring withoutbind_tx()fails loudly. - Durable search sync works multi-tenant out of the box (behavior change) —
OutboxSearchSync.bind_tenant_from_headersdefaults toTrue; setFalsefor the strict posture. serialize_callssurvives the invocation deadline — a deadline-abandoned worker is waited out before the next serialized prediction enters the model.- Analytics cursor limits use the hardened clamp — DuckDB and BigQuery cursor pagination coerce and clamp
limitlike every keyset path.
Leak-shaped fixes
- A rejected MCP tool argument no longer echoes the value back — masked validation error (
mcp_invalid_arguments) with field-level errors, independent of the host server'smask_error_details. - The CLI no longer prints local variables on a crash — the pretty traceback stays, the locals panel (live credentials included) is dropped.
- The log scrubber masks camelCase and PascalCase names —
dbPwdandsecretKey=mask like their snake_case twins; mid-token runs likebackupwdstay unmasked.
0.5.0 - 2026-07-13¶
Added¶
Reliability & durability
-
Self-hosted durable execution (Postgres) — crash-resumable durable functions and sagas: exactly-once step replay, lease-based recovery with heartbeat renewal, an execution deadline (
max_run_duration, default 1h — a hung body is cancelled and FAILED instead of renewing its lease forever), multi-worker fencing, delayed and cron runs, and an opt-in read-only admin port. Wired viaforze_kits; adds croniter. -
HLC durable high-water mark — the hybrid logical clock resumes from a checkpoint so a restart cannot re-issue a stamp; the outbox flush advances the mark in-transaction. Opt-in, node-global.
-
Postgres co-located idempotency store — the dedup record commits inside the business transaction (exactly-once across a crash) when the store reports transactional commit; mock counterpart included.
-
CPU-offload seam —
run_cpu/run_cpu_maprun blocking work off the event loop via a context-bound executor (bounded pool in production, inline and deterministic under simulation), honoring the invocation deadline. -
Fencing-token capability for distributed locks — a lock spec requiring fencing tokens fails closed at resolve against a backend that cannot mint them (Redis and the mock can).
-
Wiring check — opt-in
check_wiringdry-runs every registered operation against a throwaway context so a missing or misrouted dependency surfaces at startup. Diagnostic only. -
Neo4j transaction manager — enlists graph writes into the framework transaction scope (READ COMMITTED); not co-transactional with other backends.
Querying & read models
-
Nested-field projection & sorting — dotted paths into nested models and string-keyed mappings work in projections and sort keys on all document backends. The mock now nests a dotted projection key instead of emitting it flat.
-
Materialized derived fields —
DocumentSpec(materialized=…)persists selected computed fields as filterable/sortable columns; in-place search accepts them too (Postgres, Mongo). -
Lenient read & write-omit fields — a read-model field may have no backing column and a write may omit columns, for expand/contract migrations; documents on all backends plus Postgres/Mongo search.
-
Streaming reads on Mongo & Firestore — bounded-memory batched reads bring all three document backends to parity.
-
Procedures port —
ctx.procedure.command(spec).run(params)runs a spec-named parametrized statement (Postgres + programmable mock); a tenant-aware route fails closed at wiring unless the SQL binds the tenant. -
Query parameters — a typed contract binds query-scoped session settings (Postgres + mock); capability-gated, fail-closed.
-
Aggregate capability gate — a backend that cannot compile aggregates (Firestore) rejects aggregate queries with a precondition error instead of failing at the driver.
-
Graph parity, schema provisioning & k-shortest paths —
forze_neo4jcovers the full graph query/command ports with the mock verified equivalent by differential conformance tests; idempotent schema provisioning; native k-shortest paths, weighted opt-in via GDS and fail-closed without it.
Search
-
Bounded-memory result streaming — search streams iterate the ranked set in keyset chunks, capability-gated per backend; unsupported backends fail closed.
-
Vector & hybrid search as a declared capability — unsupported retrieval features fail closed via
SearchCapabilities; pages gain an optional scores sidecar; fusion choice and filtered-ANN recall are declared per backend. -
Facets & highlights — declared on the spec, returned as optional page sidecars across mock, Meilisearch, Postgres and federated search; unsupported fields or topologies fail closed.
-
Thin federated merge —
FederatedSearchSpec(thin_merge=True)late-materializes the RRF merge (Postgres, Meilisearch); opt-in, identical results. -
Search result caps —
max_resultsbounds an unbounded offset search andhighlight_scan_limitbounds the PGroonga highlight scan; both opt-in. -
Hub member from a standalone config —
PostgresHubSearchMemberConfig.from_search_configderives a hub leg from a standalone search config.
Execution & handlers
-
Two-phase prepare/apply handlers —
prepareruns read-only outside the transaction,applyinside it; a transaction route is required. -
Transaction isolation as a fail-closed contract — an operation declares an isolation level verified against the route's manager, never silently weaker; the current level is introspectable.
-
Cross-aggregate (system) invariants — declarative
SystemInvariantlaws with detective and preventive (in-transaction rollback) enforcement in kits, compilable into a DST oracle. -
Resilience control plane (
ResilienceAdminPort) —inspect()snapshots live per-policy/route state;force_openis a manual kill-switch where omitting the route covers the whole policy, and a clear also resets breaker state so recovery is immediate;retunehot-swaps a policy, re-validated by the wiring gate.
Observability & encryption
-
KEK replacement with a migration overlap — a key directory may name a previous key: reads accept current and previous while writes use current, re-encryption sweeps move the data, and dropping the previous key restores the strict guard. Key-version rotation still needs no action.
-
Blob re-encryption sweep — streams every object of a route down and back under a fresh data key, in place and in bounded memory, preserving metadata and reporting rewritten and skipped counts. The rewrite is conditional on the object being unchanged, so a concurrent delete stays deleted rather than being resurrected and a concurrent change is retried once from fresh bytes.
-
Cloud KMS backends (
forze_kms) — AWS, GCP and Yandex Cloud envelope-key backends behind the sharedKeyManagementPort(extras kms-aws / kms-gcp / kms-yc), with transparent key-version rotation and per-tenant KEK provisioning through the same provisioner port as schemas and buckets. -
One-call logging setup —
bootstrap_loggingwires framework, integration and third-party loggers plus the uncaught-exception hook; opt-in sampling and dedup volume controls. -
Unified integration logging — previously-silent integrations now log under
forze_<pkg>namespaces with typed logger-name enums; client connect/close logs at trace, off by default. -
Sampled access logs — FastAPI and MCP request logging is quiet by default: successes sampled one-in-N, errors always logged, health paths excluded. Successful requests are no longer all logged at INFO; a full mode restores that.
-
Per-port OTel spans & logging — opt-in per-call CLIENT spans and per-call logging for every resolved port.
-
Signed / encrypted / context-bound cursor tokens — an opt-in signer HMAC-signs every keyset cursor (or a cipher AEAD-encrypts it) and binds it to spec, tenant and filter fingerprint, rejecting tampered or replayed tokens. Off by default; enabling is a hard cutover — pre-existing cursors 400 once.
-
Per-item stream interception — an around-stream interceptor capability wraps async-generator port calls: cooperative yield points, whole-stream logging, opt-in mid-stream fault injection.
-
W3C trace-context propagation — a published event carries its span outbox→broker→inbox (opt-in; relational backends need a traceparent column); outbound HTTP injects it.
-
EncryptionReachladder — names the messaging encryption reach (none < at_rest < end_to_end); a required reach refuses a weaker route at resolve. -
Bounded-memory object-storage streaming —
download_stream/upload_streammove large objects through fixed memory, including client-side-encrypted blobs via a chunked-AEAD format with ranged reads; legacy whole-payload envelopes still read.
Realtime
-
Server push + offline store-and-forward — handlers publish realtime signals through messaging ports; the Socket.IO gateway bridges to tenant-scoped rooms, and durable signals are mailboxed and replayed per device on reconnect.
-
Tenant-aware & multi-node hardening — per-tenant consume loops, a tenant-sharded outbox relay, TTL-backed presence with heartbeat, credential-expiry eviction, per-emit timeout.
-
BREAKING — realtime delivery envelope — every Socket.IO frame is the uniform
{id, data}envelope; clients read data and dedup by id.
Transports & DX
-
Offset-log stream consumption — a fourth delivery model for partitioned offset-committed logs: commit-stream query/admin ports with capability gates, and a kits consumer that commits after inbox dedup (exactly-once effect). Mock adapters and a conformance battery included.
-
forze_kafka— the first offset-log backend (kafka extra): produce, consume and admin (replay, lag), end-to-end encryption, tenancy. -
Redis stream & pub-sub transports — production Redis backends for stream and pub-sub specs with optional end-to-end encryption and tenant prefixes; the consumer-group adapter splits data and admin planes.
-
Streaming download route — the generated download route streams by default with real ranged requests, conditional 304s and a new HEAD route, backed by three read-only storage kit ops. The ETag is now the backend etag; a buffered mode stays opt-in.
-
Top-level front door — the most-used names re-export lazily from forze and forze_kits; the core never imports kits.
-
Less CRUD boilerplate —
build_document_registryderives DTOs when omitted;document_facadereturns a typed per-call factory. -
AggregateKit — one declaration composes a governed aggregate's wiring: document CRUD, soft delete, search-index sync, invariant enforcement, in-transaction outbox flush, route projection, and DST-verifiable invariants. Its four primitives (outbox emit, search sync, soft delete, invariants) are usable standalone.
-
AggregateKit durable search sync — opt-in
search_delivery=OutboxSearchSync()routes index maintenance through the transactional outbox: an identity-only marker stages in the write's transaction and a consumer re-reads the committed row (idempotent, reorder-safe, no payload in the event), so a transient index failure converges instead of drifting. With soft delete composed, kit search reads exclude soft-deleted rows; the index must declareis_deletedfacetable, checked fail-closed. -
Shared error helpers — one client-safe error envelope and kind-to-HTTP-status mapping shared by the FastAPI and Socket.IO edges.
-
Mock document adapter tenant scoping on every write — the in-memory mock injects the tenant column on ensure/upsert/update/touch, matching Postgres.
-
Telegram Login Widget verifier — verifies Login Widget callback data via Telegram's HMAC scheme with a freshness bound, emitting the canonical assertion.
Deterministic Simulation Testing (forze_dst) — new package
-
Point-at-a-real-app simulation — one master seed reproduces a whole run (schedule, faults, latency, inputs, crashes, partitions) over real registries and runtimes, single-process or N-node, with no app changes; a violation minimizes to a reproducible counterexample.
-
Deterministic runtime & seams — a simulation event loop and time source give byte-identical replay; time and entropy flow through bindable seams, and a determinism gate bans raw time/entropy outside them.
-
Faults, latency, crash & partitions — declarative fault rules, latency profiles, crash-restart recovery over persisted mock state, and lossy or asymmetric network partitions.
-
Workloads, schedulers & coverage — scenario rules and a fuzzer generate workloads; PCT and systematic schedulers explore interleavings; coverage-guided exploration and parallel seed sweeps scale the search.
-
Oracles — built-in invariants (duplicate effects, mutual exclusion, linearizability, consistency models), transactional-isolation oracles up to serializability cycles, commutativity checks, reachability targets, and opt-in value-level checks.
-
Reporting & regression corpus — causal-graph violation reports and timelines, failure bundles with honest replay semantics, a regression corpus, and a CLI (
forze dst run/ replay / coverage / topology / derive). Bundle serialization round-trips every config field (an introspective guard fails on any future field a serializer misses — it caught stream-fault rules and the pruning flag being dropped). -
Mock substrate (behavior change) — journalled transactions with MVCC isolation are now the mock default; in-memory outbound HTTP added.
-
Adapter conformance — a backend-agnostic isolation-anomaly battery asserts the mock matches real Postgres and Mongo, including lock races, outbox→inbox delivery under crash, resilience stores under partition, and identity rotation/revocation under fault. The Mongo leg runs the full battery (abort-vs-block catalogued as a mechanism divergence); contract strengthenings can be engine-scoped, so a backend-specific one never masks a mock deviation.
Changed¶
Breaking — search
-
Search pages split from the base pagination contract — search page types now carry facets, highlights and the snapshot handle; the facet, highlight and snapshot value types move to the search contract.
-
SearchFuzzySpecis a frozen value object — was a dict; edit-distance ratio defaults to 0.34 (validated 0.0–1.0), prefix-length removed. No shim. -
Search options de-leaked — the raw-Groonga override is removed, the PGroonga plan is adapter config only, candidate caps renamed, and hub/federated member keys move to a multi-source options type.
-
Search index provisioning moves to
SearchManagementPort— ensure-index and delete-all move off the command port onto the management port (Meilisearch). -
Typed value-object configs — search engine is a tagged union, federated merge takes a shared Rrf, warehouse analytics take a shared IngestSpec (Postgres drops the legacy schema field).
Breaking — imports & DSL
-
Application contracts surface consolidation (no runtime change) — codecs move to base serialization, conformity and lock types move to their contracts homes, aggregate laws rename to
SumOf/CountAll, and aTenantSecretResolverreplaces the per-kind resolver callables. -
Contract value types import from their contracts home — resilience store types, lifecycle types, deps maps and outbox staging types import from
contracts.*, not the execution layer. -
update_manytakesSequence[KeyedUpdate]— not id/rev/dto tuples; single-item update unchanged. -
GroupRef(query grouping) →GroupField— resolves the clash with the authz GroupRef. -
PEL stream ports renamed —
StreamGroup*becomesAckStreamGroup*(ports and dep keys), pairing with the new commit-stream ports; behavior unchanged. Stream messages gain optional partition/offset fields. -
Package restructures — forze_dst splits into a facade over engines/oracle/artifacts; forze_mock moves adapters and factories (top-level imports unchanged); the notify router becomes a register-then-freeze builder.
Behavior
-
Lazy transaction acquisition — default for Postgres/Mongo/Firestore: a scope defers connection checkout to the first operation; opt out per route.
-
Runtime owns its CPU-offload pool — a runtime scope binds and closes a scope-lifetime thread pool; with nothing bound,
run_cpuruns inline. -
Search snapshots stream their pool and expose an expiry; hub search defers heavy projection to per-page hydration.
-
Empty filter/sort maps are no-ops on list/search requests; a structured-but-empty envelope is still rejected.
-
Sizing bounds clamp or reject instead of silently resetting — an out-of-range batch size is rejected at wiring; a per-call chunk size clamps to the nearest bound.
-
Integration logger namespaces unified — integrations log under
forze_<pkg>; update log filters keyed on the old bare prefixes. -
Hot-path micro-optimizations — byte-identical output: faster normalization, cursor canonicalization, bulk decode and span construction.
-
Generated FastAPI routes omit null response fields by default — opt out per attach call to restore explicit nulls; raw-Response routes unaffected.
-
reencrypt_documentsreturns aReencryptReport(breaking) — rewritten and skipped counts instead of a bare int; a row deleted between listing and its write-back is now skipped instead of aborting the pass.
Removed¶
msgspecdropped; the codec layer is Pydantic-only (breaking) — the msgspec codec and serialization module are removed; record models must be Pydantic. Storage value objects become frozen keyword-only attrs classes.
Fixed¶
Durable execution & broker failure paths
-
Inngest event-supplied identity is not trusted by default — the envelope's principal and tenant are attacker-controllable, so they no longer bind as identity unless opted in via bind_identity_from_event; payload decryption still uses the envelope tenant (self-authenticating).
-
Temporal saga failures fail the workflow — a saga CoreException becomes a non-retryable ApplicationError; the deterministic clock survives a plain import through the workflow sandbox.
-
Inngest deterministic failures stop retrying — malformed events, failed decrypts and non-retryable errors raise NonRetriableError; retryable kinds still propagate.
-
SQS: one poison message no longer poisons the receive batch — per-message decode failures are isolated (left for redrive; deleted on FIFO so a message group cannot deadlock); FIFO per-message delay and over-length queue names fail closed. Opt-in
poison_queue_urlretains a raw copy (with provenance attributes) before the FIFO delete — the framework's one poison-destroying path; unconfigured destruction now logs a warning naming the knob. -
RabbitMQ: opt-in dead-letter sink and redelivery counting — a configured DLX makes a poison nack dead-letter instead of drop, and opt-in redelivery counting lets poison-parking fire past the broker's redelivered ceiling (requires publisher confirms; enabling the DLX on an existing queue requires recreating it). A consumer starting on a queue with no DLX now warns once per queue that poison messages would be destroyed.
-
Kafka admin reads no longer auto-create topics — existence is checked against the all-topics listing, so querying lag for a mistyped topic returns empty instead of minting the topic.
-
Inngest function-level config —
InngestFunctionConfigforwards Inngest's native controls (retries, concurrency, rate limits, batching and more). -
An unregistered durable run name cannot livelock recovery — the run lands FAILED with the reason recorded instead of stranding every co-claimed run each sweep; the scheduler likewise isolates a corrupt cron expression. Externally cancelling a run's awaiter also tears down the body and heartbeat tasks instead of leaving them running detached.
-
A transient saga step error no longer triggers irreversible compensation — a retryable kind (infrastructure, throttled, concurrency) is retried in place with backoff before anything is journaled, so a one-off blip cannot undo committed steps; compensation fires only on exhausted retries or a genuinely non-retryable failure, and a crash mid-blip replays completed steps from the journal. The same retry now guards transient errors during compensation itself.
-
A transient KMS failure during consume is retried, not poison — decrypt failures classify through the egress policy: retryable kinds requeue (queue consumer) or crash for a supervised rewind-and-restart (commit-stream) instead of being dropped; tampering still parks.
-
The e2e-encrypted DLQ copy is decryptable and keeps its identity — the dead-letter path re-sealed the decrypted payload tenant-unbound under a fresh id while keeping the old tenant header, so a DLQ consumer could not authenticate it and correlation was severed. The diverted copy is now the original sealed envelope byte-identical, and sealing without a bound tenant drops a stale tenant header rather than contradicting the envelope.
Reliability — durability, shutdown, resilience
-
Deadlines enforced at the database driver — a bound deadline sets Postgres statement_timeout in-transaction and wraps Mongo ops in client-side timeouts, tighten-only, with an asyncio backstop.
-
Cancellation or a deadline tearing a commit is non-retryable — both surface
commit_ambiguousinstead of a retryable error or a raw CancelledError, scoped to operation-owned tasks so a shutdown cancel still reaches consumer loops; during the body both still roll back and stay retryable. -
Shutdown reliability — the drain timeout cancels still-running ops and awaits their unwind before teardown; detached refresh tasks are cancelled; each shutdown hook gets its own timeout.
-
Spawned operations no longer escape drain; engine hops stay inside the admitted op — nesting is decided by task identity, so a handler-spawned op is admitted, counted and drained, while the engine's own hops (prepare, hedges, post-commit, fan-out) adopt the operation and cannot be rejected mid-drain.
-
A failed after-commit effect no longer discards a committed result — it is reported out-of-band to an after-commit error handler, now installable via build_runtime; fatal callbacks still re-raise.
-
Inbox dedup no longer treats the ordering key as an event identity — the fallback is now header then message id (also for causation binding and notify event ids), so distinct headerless events sharing an ordering key both process. Pre-0.4 headerless relay messages degrade to at-least-once instead of silent drops.
-
Inbox exactly-once fails closed on a cross-client misconfiguration — inbox processing asserts the store commits in the transaction, raising a configuration error instead of silently non-atomic dedup.
-
Resilience hardening — the breaker classifies by downstream health (429/OCC do not trip it; timeouts count); state maps are LRU-bounded; a blanket policy retrying ambiguous failures is refused at build; a store outage fails open by default.
-
Streams under a port policy now feed the circuit breaker — acquisition is breaker-gated (including force-open) and mid-stream infrastructure failures record with unary parity, so a stream-only-failing downstream trips the breaker for its unary siblings. Streams get no retry, hedge, timeout or bulkhead slot.
-
Resilience bookkeeping never fails a completed call — hedge metrics count correctly, and limit, breaker and digest bookkeeping errors surface as metrics only.
-
Hedging requires an explicit safety basis (breaking, freeze-time) — an idempotency wrap alone no longer passes the gate; every hedge must declare a safety basis or fail closed at freeze.
-
Bulkhead no longer over-admits after a cancelled shed — a shed-then-cancelled waiter no longer releases a slot it never held.
-
Operation OTel spans stop painting expected 4xx failures red — client-class domain errors record as failed on a clean span, so error-rate alerts track genuine faults.
-
Default resilience executor is per-event-loop — a bulkhead never resolves a waiter parked on a foreign or closed loop.
-
Idempotency — the dedup TTL default rises from 30 seconds to 24 hours to cover the redelivery horizon; a store failure after the business commit no longer fails the succeeded op.
-
Batch field decryption no longer stalls the event loop — large encrypted result sets decrypt off-loop against a thread-safe snapshot, byte-identical output.
-
require_tenant_idraises authentication, not internal — a missing bound tenant is caller-caused. -
Hard-delete cache-invalidation failures surface at error level — a deleted document served from cache is a correctness hazard; still best-effort so a cache outage cannot block a delete.
-
Opt-in guard against outbox dual-writes — an outbox spec can require flush-inside-a-transaction as a checked precondition.
-
Outbox relay no longer dead-letters the backlog on a missing keyring — the pass aborts with rows left pending; genuine decode poison still parks the row.
Bounded memory
-
Unbounded in-process caches gain bounded defaults — the Postgres estimate lane, Redis breaker local cache, document-cache refresh fan-out and L1 registry sweep; all with escape hatches.
-
More read/list paths stream — GCS listing pages, mailbox trim projects only ids, Postgres hub parallel search late-materializes, and update_matching batches by keyset instead of one unbounded UPDATE.
-
Streamed offline-mailbox replay — replay emits page-by-page instead of loading a device's whole backlog.
-
Mongo ranked search late-materialization — thin rank rows are sorted and paged before hydrating the page by id.
-
Analytics
run_chunkedtruly streams — DuckDB, ClickHouse and BigQuery consume the streamed query one window at a time. -
HttpConfig(max_response_bytes=…)— caps the in-memory response body; default off.
Error taxonomy
-
Client-caused errors no longer masquerade as 500s — unsupported features raise precondition and malformed values raise validation, uniform across mock, Postgres, Mongo and Firestore; genuine server faults stay internal.
-
Bad query fields are a client error — a sort or filter naming an absent field raises precondition; a spec's own bad default sort stays configuration.
-
Malformed
$and/$oris a clean 400 — a combinator whose operand is not a list raises precondition instead of an AttributeError. -
Cursor limits and token fields are validated at decode — a non-integer page size is rejected and clamped to the 10,000 cap; a crafted token version or non-finite decimal/float raises validation instead of an uncaught 500.
-
Encryption fail-closed — filtering a randomized-encrypted field raises precondition; encrypted-sort rejection covers every search backend. Declaring an encrypted field as an indexed search content field (randomized or deterministic-searchable — ciphertext never tokenizes) is now refused at spec construction with a configuration error, instead of sealing ciphertext into the index and silently matching nothing.
-
Consistent adapter errors — the mock rev-conflict matches the real adapters; a missing dependency names what is registered; database errors classify on codes, not message text.
Correctness & consistency
-
Reverse cursor pages return the window adjacent to the cursor — the document assembler, Postgres hub parallel cursor and Mongo search cursor kept the wrong over-fetch sentinel; all now delegate to one shared keyset windowing. Mongo search first-page prev cursor is now null like every backend.
-
Firestore cursor pagination works past page 1 — the anchor snapshot was passed in a list (a TypeError on any real token) and the before direction was inverted; a deleted anchor now fails closed as a stale cursor. The dead start_before_id client-port parameter is removed.
-
$neq/$nin/$disjointinclude NULL rows on Postgres — the renderer uses IS DISTINCT FROM semantics, matching mock and Mongo. -
Tenant-aware Mongo text search works — the prefilter (tenant tag, caller filters) was emitted as its own leading match stage, pushing the required first-stage
$textto stage two, which the server rejects — every prefiltered text search failed. The first match now carries the text query and the prefilter together, across the offset, cursor, count and thin paths. -
Decimal cursor keys order numerically — keyset comparison coerces numeric types to Decimal (was string-comparing) and a Decimal sort key round-trips exactly.
-
Intercepted streams close deterministically — the proxy wrap and both builtin stream interceptors now chain aclose to the backend cursor at close time instead of leaving release to garbage collection; abandonment logs nothing and mid-stream errors keep their classification.
-
Concurrency primitives hardened — the in-flight lane shields the shared task from one caller's timeout, and the LRU registries no longer double-dispose or dispose under the lock.
-
Mock isolation matches Postgres at the default level — READ COMMITTED conflict detection anchors on the version actually read, a duplicate-id create race raises conflict, and FOR UPDATE is honoured; verified against real Postgres.
-
DST systematic search is complete again — and honest about pruning — the DPOR frontier zero-pads the choice prefix, so previously-unreachable schedules are explored; violation reports print a faithful reproduce line. Signature-pruning (a heuristic that can miss a violation behind an effect-equivalent prefix) is now switchable:
dpor_prune=Falsewalks the full choice tree, and the docs state the actual guarantee. -
Regression bundles handle non-self-contained strategies honestly — a bundle that cannot self-replay reports as a clear failure, and one bad bundle cannot crash the batch.
-
Mock outbox/inbox write-through is a catalogued DST divergence — confirm a premature-visibility finding against a real broker or store. In-transaction outbox status transitions (claim, mark, retry, requeue, reclaim) now journal and revert on rollback, matching Postgres — the catalogued divergence covers visibility only.
-
Kafka commit-stream consumer is loss-free under poison and rebalance — malformed payloads pause instead of raising, every pause or abort re-seeks to committed, a rebalance listener drops stale routing, and the supervised lifecycle restarts crash-loss-free.
-
Mongo datetimes decode timezone-aware (UTC) — the client now reads BSON dates as aware UTC like Postgres and the mock (was: naive UTC, caller-visible). The naive reads also made history OCC's three-way check flag a stale-rev update that echoed an unchanged datetime as a false
historical_consistency_violation; Mongo document history is now covered by integration tests end-to-end. -
GCS conditional multipart completion covers the metadata stamp — user metadata binds inside the same precondition boundary as the final write: the single-part rewrite carries it in the conditional write itself, and the compose paths pin the follow-up patch to the composed object's generation — a concurrent overwrite answers conflict instead of inheriting the completing caller's metadata.
-
Firestore write path is OCC- and tenant-safe — patch does real rev-CAS in a transaction, deletes are tenant-verified, unsupported operators fail closed, creates fail closed on an existing id, and Firestore joins the cross-backend DSL parity harness.
-
CQRS read-only guard covers eager (factory-time) write-port acquisition — an eager command acquisition in a QUERY factory hits the same guard as a call-time one.
-
Notifications route through the queue consumer — redelivery dedups on the event id and poison messages park.
-
Outbox relay tenancy — each claim's tenant is bound before publishing; a tenant-aware outbox on the plain relay fails closed.
Field encryption & KMS
-
Durable-secret entropy is a distinct type a seeded source cannot satisfy — a replayable EntropySource splits from a CSPRNG-only SecretEntropy for nonces, tokens and keys, making a predictable secret unrepresentable; the permit_insecure_entropy flag is removed. A simulation seeds only the replayable seam.
-
SystemEntropySource.random()is CSPRNG-backed — it drew from the global Mersenne Twister while advertising the system CSPRNG. -
Strict mode for encrypted fields (
reject_plaintext) — opt-in rejection of non-ciphertext values in encrypted and searchable fields after backfill, reachable declaratively: the spec flag flows through every codec resolver (documents, search, graph, analytics, procedures), previously hardcoded off. -
Plaintext data keys no longer reachable via repr — the keyring's caches and frozen decryptor suppress raw DEK bytes.
-
Cached data keys honor a TTL — a KEK rotation or revocation takes effect within the configured window; the crypto module now forwards the TTL and cache bounds to the keyring it builds.
-
Confused-deputy guard on decrypt — with a tenant supplied, the keyring authorizes an envelope's key id against the tenant's own key before any KMS unwrap. The guard holds on decrypt-cache hits too — the sync pre-pass previously skipped it when the key was already cached.
-
Vault Transit signer picks up key rotation — the cached public key re-fetches after a TTL, so a rotated key verifies without a restart.
-
A sealed field cannot reach the external search index in clear — index sync re-seals the aggregate's decrypted read model under the search spec's own policy, so a search spec that omitted a field the document spec sealed published that field's plaintext to Meilisearch. The "same policy on both specs" rule was documented but unenforced; it is now checked wherever the two specs meet, failing closed.
Identity & authorization
-
OIDC verifier no longer re-fetches JWKS per request — the verifier and its key provider are built once and reused, so the JWKS cache actually spans requests.
-
Nonce enforcement reachable through presets — presets forward require_nonce to the token verifier; default off.
-
ForzeJwtTokenVerifierguards its session spec — the same no-cache/no-history construction check as every sibling verifier, so a revoked session cannot be served from cache. -
Authz grant resolution cross-checks the tenant — a caller-supplied scope naming a different tenant is refused.
-
OIDC assertion records the validated audience — the matched audience, not the first list entry.
-
trust_tenant_headerno longer binds a tenant for anonymous requests on a resolver-gated app — the raw header fallback applies only without a resolver, or for authenticated requests the resolver did not bind.
Transport & agent surfaces
-
MCP no longer leaks internal error details — boundary errors render through the same egress-masked envelope as HTTP; caller-caused errors keep message and code. Resource templates now mask through the same shared helper as tools — a raw internal error no longer reaches the agent on a caller-owned server.
-
Pre-built pagination DTO is bounded —
pageandsizevalidate at the boundary (sizecapped at 10,000, matching the cursor clamp and the implicit find-many limit), rendering as the standard 422 envelope instead of admitting unbounded materialization. -
MCP stops advertising idempotency it cannot honor — the boundary binds no idempotency key, so the retry-replay claim is gone from tool descriptions.
-
MCP tool defaults run their default_factory per call — uuid and timestamp defaults regenerate per call instead of freezing at registration.
-
Generated FastAPI routes render one 422 shape — request-validation errors use the shared error envelope; raw ctx/input dropped.
-
A range request over a client-side-encrypted object serves the right bytes — ranges resolved against the stored ciphertext size, so a suffix range (what media and zip readers ask for first) returned a window shifted by the envelope overhead under a plausible-looking content range, and a range past the plaintext end surfaced as a 400 rather than a 416. Ranges now resolve against the plaintext total.
-
forze dst replaysurvives a bad corpus target — one unloadable target counts as a failure while the rest of the corpus replays.
PostgreSQL
-
Query parameters no longer leak across reads in a caller transaction — each param-bound read now restores its session settings after the fetch, and analytics and procedure timeout and search-path settings do the same. Previously a procedure's per-tenant search path and statement timeout applied to every statement the caller ran afterwards. A failed restore never masks the original query error.
-
An OCC retry inside a caller transaction can no longer die on the aborted transaction — on Mongo a write conflict aborts the whole server transaction, so the in-place retry failed with NoSuchTransaction; on Postgres a serialization failure or deadlock did the same, surfacing as masked infrastructure. Both now surface the original clean concurrency error for a whole-scope re-run; Postgres still heals a healthy zero-rows rev conflict in place.
-
find_manywarns when its implicit 10,000-row cap truncates — pass an explicit limit or paginate to read past it. -
update_matchingbounds its primary-key snapshot — capped at one million rows by default and fail-closed beyond it; opt back into unbounded with None.
Adapters & security
-
Tenant-aware Mongo writes and Firestore queries no longer crash at the driver — both gateways passed a raw UUID the drivers reject; the tenant is now stamped and filtered as the canonical string everywhere, with tenant-isolation integration tests on both backends.
-
Firestore updates no longer strip the tenant tag — every gateway write re-stamps the tenant (patch previously full-set the domain image, hiding the row from all tenant-filtered reads); history snapshots are tenant-stamped and a cross-tenant get reads as not-found.
-
Temporal tenant scoping covers listing and handle ops — signal, cancel, terminate, describe and every schedule op resolve ids through the create-time tenant prefix and refuse a foreign tenant's id; schedule listing filters to the tenant's prefix. Non-tenant-aware wiring is unchanged.
-
Object storage tenant isolation covers reads, not just writes — every key-taking read, delete, copy and presign path now requires the key to lie within the active tenant's prefix.
-
A missing S3/GCS object classifies as not_found, not retryable infrastructure — a caller miss is no longer retried or counted against the breaker, and download routes 404 on real backends as on the mock. Bucket-level 404s stay infrastructure, and the re-encryption sweep confirms the container still lists before counting a skip.
-
Object-storage reads no longer create the bucket (behavior change) — listing was the only read path that created on demand, so listing a deleted bucket re-created it and answered "empty", making an absent container indistinguishable from an empty one and letting the re-encryption sweep report a "complete" rotation it never performed. Reads now raise on a missing bucket; only the write paths create on demand.
-
Meilisearch write path — a failed task raises instead of reporting success, task waits are bounded, tenant-tagged writes and deletes are scoped, and windows crossing maxTotalHits fail closed with the index provisioned to match.
-
Neo4j keyed-edge identity & quantifier coercion — a keyed-edge ensure now matches on the edge key so distinct keyed edges stay separate, hop quantifiers are integer-coerced before inlining, and walk and path parameters validate their bounds at construction. A filter key must be a plain identifier, since a crafted key could otherwise reach query text through the parameter name; the mock enforces the identical rule from a shared helper.
-
Neo4j transactions honour the per-tenant database — an enlisted transaction bound to the client's static default and ignored the per-statement database, so under the namespace tier a tenant's transactional graph writes landed in the shared database and reads cross-contaminated. The transaction now binds to the tenant-resolved database of its first statement, and a second, different database inside one transaction is refused rather than silently redirected.
-
Credentials in a logged JSON or dict-repr body are masked — the log scrubber matched only unquoted key/value forms, so in a serialized body every secret term but one went out verbatim wherever a request, response or webhook body was logged. Quoted keys are now derived from the same term vocabulary as the plain form, and the mask stops at the value so neighbouring fields survive.
-
Logger-name constants are importable as documented —
FORZE_POSTGRES_LOGGER_NAMES/FORZE_REDIS_LOGGER_NAMES(and their logger enums) are now re-exported from the package roots forbootstrap_loggingwiring, matchingforze_http. -
forze_redisimports on redis-py 7 again — version-specific typing aliases are self-owned; client-side caching fails closed below redis-py 8. -
Redis idempotency store cannot be corrupted via the idempotency key — the untrusted key is hashed, results live in a disjoint scope, and commit/fail are fenced compare-and-set. The key format change resets the in-flight dedup window once on upgrade.
-
Temporal default workflow id is a real UUID — the factory stringified the function instead of calling it, so every unnamed start collided.
-
VK login — the untrusted introspection envelope is no longer copied into claims.
-
Mongo — the query renderer rejects dollar-prefixed field names; index introspection keeps string index directions verbatim.
-
FastAPI
X-API-Key— splits prefix:secret on the first colon; bare keys still authenticate. -
Per-tenant routed clients — fingerprint the full host list, so multi-host DSNs no longer raise.
-
Postgres — schema validation accepts parameterized column types; search index-definition parsing is delimiter-aware.
-
Log scrubbing closes three leaks — exceptions render scrubbed under sanitize_logs; assignment scrubbing covers credential-suffix keys, whole Authorization headers and user:pass DSNs; a non-string dict key no longer raises into the log site.
-
Scrubbing fragment lists reconciled — pwd, passphrase, private_key and six more fragments that existed only as key heuristics now also mask in assignment form (
pwd=…,db_pwd=…,private_key=…); a parity test keeps the value-form and key lists from drifting apart again. -
Ranged reads over encrypted objects detect tail truncation — the range path now verifies the terminal frame's authenticated final flag (riding an already-required fetch, no extra I/O), raising the same chunked-truncated error as streaming instead of serving truncated bytes as authentic; a spliced early final frame is refused too.
-
configure_logging()configures the root logger by default — with no logger names it previously attached nothing and INFO logs vanished; an explicit list is still an allowlist. -
Console renderer no longer shares mutable defaults across instances — the renderer's class-level dict and list defaults were shared by every instance, so per-instance customization leaked between renderers; they are now per-instance factories.
-
Misc — BigQuery empty-array params typed from annotations; timezone offsets validated; S3 multipart-ETag normalization; If-None-Match parsed per RFC 7232; outbound HTTP suppresses its default bearer when an Authorization header is set; GCS rejects reserved metadata keys.
0.4.1 - 2026-06-17¶
Added¶
-
Mergeable quantile sketch — a relative-error sketch answering any quantile and mergeable across streams, for fleet-wide multi-quantile latency, in windowed and unwindowed forms.
-
Hybrid logical clock — a skew-tolerant causal clock over the ambient time source, with an optional drift guard.
-
Causal outbox ordering — opt-in on the Postgres and Mongo outbox configs: events are stamped with a hybrid logical clock and claimed in causal order across replicas. Off by default; Postgres needs a new column, and legacy rows fall back to their creation time.
-
Fleet-wide adaptive-bulkhead congestion signal — the AIMD latency signal flows through a pluggable digest store, defaulting to the existing in-process one, and Redis-backed storage lets the limit react to the fleet's p95 rather than one replica's. Opt-in.
-
Prioritized load shedding — opt-in, making the bulkhead wait queue criticality-aware through a task-scoped criticality binding. A no-op until enabled, and requires a bounded queue.
-
Delay-based bulkhead — a third bulkhead kind tuning concurrency from the latency gradient, needing no latency threshold. Mutually exclusive with the other kinds.
Changed¶
- Quantile estimators relocated — the P² estimators moved from the resilience module to the shared primitives, alongside the new sketch, and are now public. The old module path is removed; internal resilience wiring is unaffected.
Fixed¶
- Typing annotations — type-only imports moved under
TYPE_CHECKINGwith forward references (including the runtime-optional OpenTelemetry types), so affected modules import cleanly without those optional dependencies installed and skip needless runtime imports.
0.4.0 - 2026-06-17¶
Added¶
-
Envelope-encryption core — an encrypted-envelope primitive, a bring-your-own-key management seam that keeps the key-encryption key in the backend, a field-encryption policy, and a fail-closed minimum-encryption floor ordered none < field < envelope. Adds
cryptographyto core dependencies. Opt-in, off by default. -
Per-tenant keyring and wiring — a directory port resolves a tenant to its key, a deps module composes the stack, and the mock ships a dev-only key backend.
-
At-rest sealing across persistence and transport planes — each plane takes an encryption policy on its spec, fail-closed at wiring and tolerant of legacy plaintext.
-
Object-storage encryption — opt-in per storage route on S3 and GCS; presigned URLs are refused on an encrypting route.
-
Document-field encryption — declared per spec. Binding the record id into the authenticated data refuses bulk updates of a bound field, and a re-encrypt pass upgrades legacy ciphertext.
-
Searchable deterministic-field encryption — AES-SIV, needing no KMS round-trip, so equality and membership filters rewrite to ciphertext; root rotation matches the previous root and re-encrypts. Trade-off: it leaks equality and frequency within a tenant.
-
Encrypted search reads — the same policy object as the document spec, decrypting out of results across every read path.
-
Analytics and graph encryption — sealed on write and decrypted out of every read and traversal. Encrypted columns are neither analyzable nor matchable; analytics rejects record-id binding, and graph binds the kind's key field.
-
Outbox and direct-messaging encryption — at-rest or end-to-end for the outbox, end-to-end for queue, stream and pubsub specs, with tenant and event id bound into the authenticated data.
-
Durable-payload encryption — opt-in on Temporal and Inngest with per-tenant keys. A Temporal worker must be built from the same encrypting client to decode.
-
Cache, search-snapshot, and idempotency encryption — sealed when the underlying route encrypts. The in-process L1 cache stays plaintext in memory.
-
Vault Transit KMS (
forze_vault) — key management backed by Vault Transit, plus an idempotent provisioner that creates a tenant's Transit key. -
BYOK access-token signing and JWKS — a pluggable signer (HMAC by default, plus local-asymmetric and Vault Transit) and a route publishing JWKS. Breaking: the access-token service now takes a signer, issuing and verifying became awaitable, and the algorithm config field is gone.
-
Crypto and signing observability — always-on instrumentation for both.
-
Declared-minimum tenant isolation, fail-closed at wiring — every deps module accepts
required_tenant_isolationover none < tagged < namespace < dedicated, enforced per route, and each integration declares its supported ceiling. Additive, with theNonedefault unchanged. -
Neo4j reaches namespace and dedicated isolation — the database accepts a per-tenant resolver, and a routed client resolves per-tenant Bolt URI and credentials, failing closed on partial auth.
-
Tenant infrastructure provisioning — idempotent provision and deprovision behind a port, with reference object-storage and Postgres-schema implementations. Opt-in.
-
Analytics per-tenant namespace routing and advisory binding — query operations route into the tenant's namespace, and
tenant_awareroutes bind the tenant id and fail closed if unbound. Off by default. -
Tenant-safe structured graph walk and raw gating — a scoped walk runs an adapter-owned traversal that is tenant-scoped along the full path, and the raw query hatch is now disabled by default. Breaking: deployments using raw graph queries must opt back in.
-
Fluent query builder —
Q.field("age").gt(18) & Q.field("name").like("a%")lowers to the same filter AST as the mapping form. Additive. -
Hierarchy operators —
$descendant_ofand$ancestor_ofover a tree-path field, using Postgresltreewhere available and a text-prefix fallback otherwise, gated by a capability flag. -
Aggregation operators —
$count_distinct,$stddev,$var,$percentile, and post-group$havingon Postgres and Mongo ($first/$lastdeferred). -
Full and array-of-arrays nested quantifiers on every document backend; the previous capability gate is dropped.
validate_query_field_typesnow runs in the gateway and the mock, rejecting mismatches. -
Mixed-direction keyset pagination with per-key null ordering — coherent null ordering across backends; old cursor tokens stay valid, and Mongo opts in via
computed_null_ordering. -
Query discovery metadata —
build_query_discoveryprojects a read model's filterable, sortable, and aggregatable surface as an OpenAPI extension plus an MCP line. -
Tenant selector self-service —
GET /tenants, an activate endpoint that re-mints a tenant-scoped token pair, and a leave endpoint, via the newattach_tenancy_routes. -
Tenant admin (
forze_kits.aggregates.tenancy_admin) — create, list members, invite, remove and deactivate. Ships unguarded, so bind authn and authz per op. Breaking for tenant-management implementers: two new listing methods. -
Self-service API-key management — issue, list and revoke under
/api-keys, with the secret returned once. Breaking for API-key lifecycle implementers; the account table gains hint and label columns. -
Delegation-aware API keys (user to agent) — a key can bind a delegation actor, carried as an RFC 8693
actclaim. Breaking for API-key lifecycle implementers; the account table gains an actor-principal column. -
MCP boundary API-key auth — an API-key verifier and identity resolver protect a FastMCP server with the identity plane and no OAuth flow, reads-only by default.
-
OpenAPI security from configured authn — security schemes are derived from the configured authn requirement, and principal-requiring operations are flagged.
-
Authn plane — an orchestrator with a full mock identity plane and routes for login, refresh, logout, change-password, deactivate and self-service password reset. Principal deactivation ships unguarded.
-
In-process L1 document cache — sits ahead of the distributed cache, tenant-scoped with pluggable eviction and Redis invalidation push. Off by default.
-
Stampede protection and adaptive freshness — singleflight on read-through misses, probabilistic early refresh, per-entry age and sliding TTLs, and a keyword
ttl=on every setter. -
New resilience strategies — adaptive bulkhead over AIMD concurrency, adaptive throttle, tail-based hedging, and a token-bucket rate limit, all configured as per-port policies.
-
Invocation deadlines — per-operation budgets; expiry raises a timeout, surfacing as 504.
-
Distributed rate limits — a pluggable store, with a fail-open Redis implementation, lets replicas share one rate. Bulkheads and budgets stay process-local.
-
App assembly and deployment — a runtime builder and ASGI lifespan, graceful drain defaulting to 10s, and fleet and serverless deployment profiles, the latter rejecting long-running operations.
-
Envelope headers and correlation propagation — messages gain headers and a delivery count, the relay forwards the full envelope, and the inbox rebinds correlation and causation across broker hops.
-
Outbox ordering key — per-aggregate ordering, mapping to an SQS FIFO message group or a stream partition key. Requires a new column.
-
Kits queue-consumer runner — a consumer plus background lifecycle step giving inbox exactly-once, requeue, poison parking and envelope rebinding.
-
Stream pending-entry recovery — claim and pending inspection over consumer groups. Breaking for stream port implementers.
-
Presigned object-storage URLs — download and upload URLs on S3, GCS and the mock. Breaking for port implementers, since minting an upload URL is a CQRS write.
-
Object-storage metadata and access ops — head, ranged and conditional download, copy and move, and object tags; generated routes honour range and conditional request headers. Breaking for the storage and client ports.
-
Resumable multipart uploads — begin, presign-part, complete and abort behind a session port, guarded as a CQRS write and refused on object-encrypting routes.
-
Storage HTTP edge — kit ops and generated routes for presigned download and upload and the full multipart session. Minting an upload URL is a command op, so bind authn and authz.
-
Server-side encryption at rest (SSE/CMEK) — configured per storage route. A separate axis from client-side encryption, which it does not satisfy for an encryption floor. Off by default.
-
Catalog and registry ergonomics — catalog entries carry idempotency-key and required-permission facts, duplicate merge keys raise with an override hatch, and registration is one step.
-
Generated-route mount ergonomics — every route-attach helper accepts a resource name (mutually exclusive with a namespace) and per-path overrides. Additive.
-
Scoped, materialized patch authoring — patches can be scoped to a namespace and folded into per-op plans. Merging now raises when a patch from one registry matches another's ops, which is breaking only in that case and can be opted out of.
Changed¶
-
Queue consumer and outbox relay are now configurable classes — the old module-level runner functions become classes with a run method; lifecycle steps keep flat parameters. Breaking for direct callers of the old functions.
-
Tenant-isolation tier model made coherent — the ladder is none < tagged < namespace < dedicated, dropping the old relation rung; each integration owns its supported ceiling and namespace resolution is unified. Key and path formats are unchanged.
-
Argon2 hashing off the event loop — hashing, verification and the timing dummy are now async on a bounded pool; the synchronous variants remain.
-
Performance (measured) — the engine hot path roughly halved, document updates copy only changed subtrees, the Postgres and Mongo write paths cut round-trips, and lazy error context with opt-in tracing cut overhead.
-
RPC-style FastAPI routes use REST verbs and query params — e.g.
GET /notes.get?id=. Breaking: RPC clients must switch from posting to the operation path; REST and MCP are unchanged. -
The singleton lifecycle step takes a lock spec, not a live port — breaking for callers passing a resolved port.
-
Release-coherence sweep — the relay logs the at-least-once to fire-and-forget downgrade, Temporal query/update/result deserialize into declared types, the API-key prefix is validated, and saga
step_failedstays a domain error.
Fixed¶
-
Tenant-isolation correctness and parity — Postgres outbox and inbox enforce the declared isolation floor, a missing bound tenant fails closed consistently as an authentication error, and mock durable, graph, and document adapters now tenant-partition their stores.
-
Post-commit work survives task cancellation — the after-commit drain runs as a cancellation-protected critical section and then re-raises; cancellation during the body still rolls back.
-
PGroonga search honors tenant isolation regardless of plan — a tenant-aware search now always uses
filter_first, overriding index-first plans that scanned cross-tenant rows and could truncate results.
0.3.0 - 2026-06-11¶
Added¶
-
Generated FastAPI routes — project a frozen registry's operations onto the app's router in either a REST or an RPC style, which must be chosen explicitly. Idempotency moves to the engine.
-
forze_mcp— expose operations as MCP tools, read-only by default; commands must be opted in. -
forze_duckdb— in-process, query-only analytics over a Parquet, CSV, Iceberg or Delta lake on S3, GCS or local disk, with no standing warehouse. -
Delegated identity (on-behalf-of, RFC 8693) — the authenticated identity carries an acting principal, authorization enforces a least-privilege intersection, and explicit authority is checked through a delegation port.
-
Operation-level CQRS — an operation declared as a query runs read-only: command ports cannot be acquired and the transaction opens read-only, enforced by the database. Untagged operations remain commands.
-
Operation catalog descriptors — interface-agnostic request and response schema metadata, joined with the operation kind, for projecting operations onto MCP or HTTP.
-
Queryable-field policy — per-aggregate filterable, sortable and aggregatable allow-sets, powering schema discovery and boundary enforcement. Direct port calls stay unrestricted.
-
OpenTelemetry traces and metrics — every operation gets a span plus a counter and duration histogram. Opt-in, additive.
-
Declarative domain invariants — an always-true rule enforced on both create and update, closing the merge-patch bypass that model validators allowed.
-
Saga / process orchestration — declarative multi-step processes across aggregates with typed steps and reverse compensation before the pivot, run by an in-process executor outside any enclosing transaction.
-
DDD domain events and aggregate roots to outbox — aggregates buffer domain events, and persisting one drains and dispatches them inside the operation's transaction.
-
End-to-end worked example (
examples/recipes/order_fulfillment/) — the first runnable, test-backed example: checkout saga to outbox to relay to inbox to downstream, plus compensation, onforze_mock. -
Deterministic time and ids — the clock and id generator read a context-active time source, so binding a frozen one makes every read deterministic with no call-site changes.
-
Resilience policy pipeline — composable strategies validated into one policy and run through the context. Adds hedging and a distributed circuit breaker that fails open.
-
Inbox / consumer-side dedup — a mark-if-unseen port, with a helper that marks and runs the handler in one transaction for an exactly-once effect. Adds a Postgres store plus mock.
-
Graph contracts plus
forze_neo4j— query, command and raw graph ports, a Neo4j async Bolt adapter covering CRUD, neighbours, expand, shortest path and a raw Cypher hatch, and an in-memory mock. -
forze_kits— consolidated kit package — kits, aggregates, mapping, DTOs, outbox/notify, secrets and scopes, absorbing the former patterns and secrets packages and several application modules. -
forze_http— outbound HTTP services declared by spec and resolved by name from the context, backed by httpx, with per-tenant routed clients. -
forze_meilisearch— async Meilisearch with offset search, index maintenance, and federated search using native or weighted reciprocal-rank fusion. -
Transactional outbox, notify, and search-command — outbox contracts with Postgres, Mongo and mock stores plus relay helpers, a notify kit, and a core search-command port for external-index maintenance.
-
Tenant routing — declarative per-request backend targets across all integrations, with per-tenant client variants, routed lifecycle steps, LRU pool dedup, and a client registry.
-
Identity — IdP presets — OIDC presets for Google, VK ID and Telegram Login, bootstrap wiring and PKCE helpers. Authn adds API-key rotation and single-use password invites.
-
Execution — freeze/resolve pipeline — an authoring registry freezes, then resolves per scope, separating registration from resolution, with a matching lifecycle plan. Per-scope caches default on.
-
Codecs — a default model codec, per-spec document codecs, optional read and ingest codecs, and trusted-row read validation.
-
Postgres / Mongo search — Postgres strict and trusted read validation, PGroonga plan modes and hub parallel legs; Mongo text, Atlas and vector search over both offset and cursor paging.
-
Document adapters — page-count ceilings on scan, stream and chunked commands (default 100 000, unlimited on request) with cursor-stall detection.
-
Durable workflow — run status and description types, plus describing a run through the durable workflow query port.
-
forze_temporalsecure connections —TemporalConfigTLS, API key, RPC metadata, and data-converter override; defaults unchanged. -
AWS — long-lived clients and credential chain (SQS/S3) — one aiobotocore client is reused, and access key, secret, and region are optional (defaulting to the standard credential and region chain). Per-tenant routed creds still require explicit keys and region.
-
Vault — token renewal, metadata existence, health — an opt-in self-renew loop,
kv_existsvia KV v2 metadata, and a standardhealth(). -
forze_fastapiupload cap and attach-time validation — chunked upload streaming undermax_upload_size(default 64 MiB,Nonedisables) with early Content-Length rejection. -
forze_socketioerror translation and identity — handler exceptions become structured ack payloads honoring egress redaction, with an optional connect-time identity resolver. -
Distributed-lock fencing tokens (breaking for port implementers) —
DistributedLockCommandPort.acquirereturns anAcquiredLock | Nonecarrying a monotonic fencing token. Backends without tokens returntoken=None. -
Object-storage tags end-to-end —
UploadObjectRequestDTO.tagsand aninclude_tagsflag on head and list (Truemakes S3 pay forGetObjectTagging). -
IdempotencyPort.fail()(breaking for port implementers) — releases a pending claim on handler failure so legitimate retries are not rejected (Redis plus mock). -
AuthnFacade.deactivate_principal— the existing tested handler is now registered intobuild_authn_registry, exposed, and exported. -
forze_mockparity — strict transactions, queue and idempotency parity, consumer groups with real ack, keyset cursor pagination, and tenancy, dlock, search, durable, and identity adapters. -
Shared base primitives — bounded LRU registries, single-flight and cache lanes, a once-initialized cell, frozen mappings, and fingerprint helpers, all now public.
Changed¶
-
Breaking: document write identity is an explicit argument —
CreateDocumentCmdno longer carriesid/created_at; the write surface becomescreate(payload, *, id=None),ensure(id, payload), andupsert(...)withKeyedCreate/UpsertItemvalue objects. -
Breaking: storage CQRS split —
StoragePort/StorageDepKeysplit into query (download,list) and command (upload,delete) ports, resolved viactx.storage.query(spec)/.command(spec). S3 and GCS factories are renamed. -
Breaking: coordinators to adapters —
DocumentCoordinatortoDocumentAdapter, the cache and outbox coordinators likewise, andDistributedLockCoordinatortoDistributedLockScope;forze.application.coordinatorsis removed. -
Breaking: codecs unified on
ModelCodec— document, search, and analytics paths materialize through spec-owned codecs, and document kernel gateways require explicit codecs (build viaread_gw/doc_write_gw). -
Breaking: frozen
attrsintegration configs — all integration wiring configs are frozenattrs(no dict orTypedDict); module-level validators are removed (validation at construction or.validate()), and some timeout fields move totimedelta. -
Breaking:
ensure_bucketis create-if-missing on both backends (S3) — both now create idempotently and race-safe (was a not-found). Usebucket_exists()for existence assertions. -
Breaking:
nack(requeue=...)semantics aligned (SQS) —requeue=Falseno longer deletes the message but leaves it for the redrive policy, andrequeue=Truemeans immediate redelivery. Apps relying on nack-to-drop mustack. -
Breaking:
workflow_id_templatetoworkflow_id_base— the schedule field is passed verbatim (Temporal appends the fire timestamp); renamed across contract, adapter, and mock with no alias. -
Idempotency reshaped to engine-level result idempotency —
IdempotencySnapshotis replaced by an interface-agnosticIdempotencyRecord(result: bytes), and a newIdempotencyWraphook returns the stored typed result early. FastAPI middleware readsIdempotency-Key. -
OCC retry routed through the resilience pipeline — Postgres, Mongo, and Firestore write gateways drop their own retry library for the shared
occ_retrypolicy. Attempt counts are unchanged. -
Write gateways — unified OCC/history validation — Postgres and Mongo share one history-OCC mixin, and a missing history snapshot now raises a retryable precondition error on both.
-
Async contract protocols standardized on
Awaitable[X]returns — remainingasync defProtocol ports converted (type-only; call sites unaffected). Async-generator methods are unchanged. -
Transaction nesting contract — nested scopes are savepoints, isolation and read-only are honored only at root, and a conflicting nested read-only raises.
TransactionHandle.idis removed and it gainedread_only. -
Unbounded-read protection unified on the implicit cap — Mongo and Firestore gain an implicit find limit (default 10 000,
Nonedisables), and the hard "filters or limit required" precondition is dropped. -
Analytics SQL pagination wraps in a subquery —
apply_limit_offsetwraps Postgres and ClickHouse too, and a negative limit or offset now raises. -
forze_mockadapters are stricter (potentially breaking for tests) — the password verifier actually compares, authz and scope deny by default, and a duplicate-id create raises a conflict. -
Graph contracts (evolving, pre-1.0) — dual-addressing
EdgeRef.by_key/by_endpoints, a single-pathshortest_pathplus newk_shortest_paths, and a config-raising spec validator. -
Execution-context lifecycle tripwire, import-linter, kernel consolidation — constructing an
ExecutionContextmid-operation warns, plane layering is now lint-enforced (14 contracts), and kernel-client boilerplate folds onto shared lifecycle helpers. -
Internal package layout — integration
kerneltokernel.client,executiontolifecycle/plus deps sub-modules, and registry/planning/facade/run move underforze.application.execution.operations. Package-root imports are unchanged; direct internal-module imports must update. -
Performance — hookless operations skip body-stage scaffolding (~30%), per-scope caches reuse gateways/adapters/codecs, and JSON logs render via
orjson. -
Misc — Postgres streaming uses a server-side named cursor; outbox uses bulk insert on conflict plus stale-processing reclaim (default 5 min) and
requeue_failed;forze[oidc]now bundleshttpx.
Deprecated¶
forze_identity.oidc—OidcTokenVerifier.enforce_issuer_and_audiencenow defaults toTrue, so construction requires both an issuer and an audience unless explicitly opted out.
Removed¶
-
Dead public surface removed — the
forze[arango]extra,AccessTokenService.try_decode_token,ISSUER_FORZE_JWT,EffectiveGrantsAdapter, the GCS head and listed-object aliases,PostgresQualifiedName.from_string, an internal fingerprint module, and a never-honoreddelete_manybatch size. -
python-dateutilcore dependency — dropped;datetime_to_uuid7parses ISO-8601 via the stdlibdatetime.fromisoformat(a trailingZis accepted). -
forze[casbin]extra — dropped (no integration shipped). -
forze_identity.local(breaking) — useforze_identity.builtin.local; local verifiers and factories are no longer exported from authn or tenancy. -
forze_identity.builtin.telegram— the Telegram Mini AppinitDataHMAC preset, superseded by Telegram Login OIDC underforze_identity.builtin.idp.telegram. -
Execution —
forze.application.coordinators; the registry, planning, facade, and running modules;OperationRunner; andlifecycle_graph_from_sequence(usesteps_graph_from_sequence). -
Validation helpers from public APIs — Postgres and integration
validate_*_confhelpers; validation now lives on the config types. Also dict/mapping coercion for the configurable Postgres document specs. -
Codecs —
RecordMappingCodec, the Pydantic and Msgspec codec families,codec_for_model, and public helper functions (useModelCodec/default_model_codec); plusSearchSpec.row_codecand the effective-row-codec accessor (useread_codec). -
Relocated to
forze_kits(breaking) — formerforze_patterns,forze.application.{composition,kit,handlers,mapping,dto}, andforze_secretsnow live underforze_kits;Mapper/MapperFactorystay onforze.application.contracts.mapping.OutboxDestinationis now the discriminatedOutboxDestination.queue(route=…, channel=…).
Fixed¶
-
Package error mappers were dead code in 12 integrations —
ChainExceptionMappernow flattens nested chains, so Postgres serialization and deadlock errors (plus Mongo and Neo4j conflicts) map to concurrency and OCC retry fires on real serialization conflicts. -
Firestore transactions — aborts map to concurrency, rollback happens on
BaseException,count_documentsjoins the ambient tx, and a mismatched database raises configuration. -
ClickHouse
run_query_all_pagesis one streaming execution — a consistent snapshot with no growing-offset duplicates. -
Redis pipelines fail loud on reads — value-returning methods inside
pipeline()raiseredis_read_in_pipeline. -
RabbitMQ robustness —
close()nacks and requeues pending unacked, poison messages are dead-lettered, and there is one delay queue per distinct delay. The same poison handling applies on SQS. -
Outbox relay failure model — codec-decode poison fails immediately; publish failures reschedule with backoff until
max_attempts(default 5). Adds durable attempt-tracking columns andmark_retry(breaking for port implementers), andrequeue_failedresets the counter. At-least-once delivery. -
Outbox staging is per-route and per-task — fixes a process-global flag dropping events and shared buffers. Adds per-route buffer, flushed, and peek accessors.
-
GuardedLruRegistryuse-after-dispose race — refcount transitions and eviction reads happen under the registry lock, and a dispose error during drain deregisters and propagates. -
After-commit callbacks run to completion — a failing post-commit callback no longer skips the rest; failures aggregate into one error.
-
Lifecycle steps are shut down exactly once — per-scope started-state tracking ends double-shutdown on failed startup.
-
finallyhooks observe before-hook denials — before hooks run inside the try/finally, whileon_failurestays handler-only. -
OCC history validation hardened — records are re-keyed by id and revision, and comparisons run in canonical space so no-op resends do not falsely conflict.
-
Document.update()re-validates the patched state — it merges into a python-mode dump and re-validates, so semantic no-ops yield an empty diff, partial nested dicts and ISO datetimes are no longer raw, computed-field keys are excluded, and validators run on update. -
Concurrent graph waves report all failures — an exception group for two or more; a single failure raises directly.
-
Per-scope port cache works for per-call specs — value equality with an identity fast-path first.
-
kill()/kill_many()verify row counts on every path — all paths raise not-found on missing rows. -
SQS message identity fixed (was breaking inbox dedup) —
QueueMessage.idis now the broker message id, and the receipt handle moves to the SQS-specific message type. -
Postgres transaction options no longer leak across pooled connections — read-only and isolation are emitted as
SET TRANSACTION …inside the root tx. -
Mongo write conflicts retry under OCC — write-conflict and transient-transaction errors map to concurrency.
-
forze_fastapimiddleware errors return proper status codes — core exceptions in forze middlewares render the standard JSON error instead of a 500. -
RabbitMQ/SQS receive and consume defaults — bounded receive windows and a uniform idle-timeout consume (
Noneis forever, finite is a clean stop). -
DistributedLockScopeno longer loses the lock silently — a lost heartbeat is raised as concurrency at scope exit without masking the body's exception. -
Notify consumer dedup — the event id is derived deterministically from the broker message identity (was a random UUID).
-
All integration kernel clients —
initialize()/close()serialize on an internal lock, and partial-failure assignment is hardened (BigQuery, Postgres, Redis). -
Analytics adapters — chunked execution rejects a non-positive fetch batch size via a shared validator.
-
Misc fixes — Postgres on-conflict targets, PGroonga index-first cap and exact count, Mongo bulk-upsert miss detection, Meilisearch federated finalization, identity duplicate/ambiguous-login detection, and sorted query params in the connection fingerprint.
-
forze_temporalandforze[mcp]workflow sandbox — the sandbox runner passesbeartypeandcoveragethrough, fixing circular-import failures and a coverage-induced test hang.
Security¶
-
Password change revokes existing sessions (breaking by default) —
change_passwordrevokes all sessions (refresh families plus session-bound access JWTs); opt out explicitly, and missing session ports fail at startup. -
Rehash-on-login (opt-in) — the Argon2 verifier persists parameter-upgraded hashes after login, OCC- and fire-safe.
-
sensitive=Truespec marker keeps credentials off generated surfaces — route, tool, and resource generators refuse sensitive specs at attach time, and shipped authn specs are marked. -
Owner-override permission keys configurable and documented — the admin bypass moves to
AuthzKernelConfig.owner_override_permissions(defaults unchanged, an empty set disables it). -
tenancy_mode="global"warns over tenant-partitioned stores — grants are shared across tenants; setrequire_invocation_tenantfor isolation. -
OIDC nonce value binding —
verify_id_token_nonce(constant-time) plusgenerate_nonce()/generate_state(); the VK and Telegram exchanges accept an expected nonce. -
Secret values masked in reprs framework-wide — credential value objects become non-repr, and several ClickHouse and Inngest fields become secret. Direct readers must call the secret accessor.
-
Outbound HTTP does not follow redirects by default — prevents custom credential headers from following a malicious 30x to an attacker host.
-
AuthnDepsModulerejects a token-verifier override without a resolver override — a principal-collision hazard; it fails at startup naming the route. -
Tenancy adapters enforce the cache/history guard — a cached principal-to-tenant binding could otherwise keep a detached principal resolving after revocation.
-
Cursor pagination tokens validated as client input — malformed, stale, or tampered tokens raise 4xx (was a 500), and values are restricted to JSON scalars.
-
Log message text is scrubbed — string scrub rules apply to the rendered message after interpolation, not just structured extras.
-
Postgres sort direction whitelisted (asc/desc only); S3 object tags URL-encoded;
OidcClaimMapperrejects an empty issuer or subject. -
5xx responses no longer leak internal diagnostics — generic detail for status 500 and above, sanitized context restricted below 500, and configuration-kind details are no longer sent to clients.
-
Authz document-scope filters fail closed — a scope port returning row filters with no DTO attribute to carry them raises configuration (was silently dropped to an unscoped query).
-
Raw-query tenancy hardening — the raw graph query (
forze_neo4j) fails closed in a tenant-aware module and binds the tenant; addsctx.tenancy.current()/require_current_id(). -
Missing authentication surfaces as authentication (401), not authorization (403).
-
builtin.localAPI-key verification no longer 500s on non-ASCII input — via a UTF-8 bytes comparison. -
asyncio.CancelledErrorpasses through exception interceptors — it was converted to a core exception, breaking timeouts, structured concurrency, and graceful shutdown. -
forze_identity.authnsession enforcement (breaking) — access JWTs carry a session id cross-checked against the session store. Pre-upgrade tokens without it fail until re-login (or register a stateless verifier override). -
forze_identity.authnchange_passwordrequires the current password (breaking) — it re-authenticates first, so a hijacked session cannot escalate to account takeover. -
forze_identity.authnprincipal eligibility (breaking) — the authn and credential lifecycle is gated on an active flag, deactivation cascades, and API keys enforce expiry. -
forze_identity.authnlogin hardening — a generic 401 for all failures, always running an Argon2 verify (anti-enumeration and timing). -
forze_identity.authzfail-closed tenant isolation — grant-resolution adapters refuse to construct when a tenant-scoped route has a non-tenant-aware binding or catalog port. -
forze_identity.oidc— resolves JWKS signing keys in a worker thread, so a cache miss does not block the event loop. -
Secret-field redaction — JWT signing keys and HMAC peppers become non-repr, and several Vault, S3, GCS, and HTTP routing credentials become secret or redacted.
-
forze_fastapi—X-Tenant-Id/X-Forwarded-Hostnot trusted by default (breaking) — a raw tenant header is ignored unless trust is enabled, the forwarded host is gated likewise, and Scalar docs default to not persisting auth. -
Input/identifier hardening — Meilisearch filter attribute names validated, PGroonga terms quoted as literal phrases, SQS rejects absolute-URL queue names on tenant-aware adapters, object-storage keys validated, and tenancy rejects invalid hints and inactive tenants.
-
Misc — BigQuery and GCS routed clients unlink the temp service-account JSON on close, and logging can scrub error message and stack and omit stacks from JSON logs.
0.2.0 - 2026-05-28¶
Added¶
-
Execution —
OperationRegistry/Handlerwith stage hooks,OperationRegistry.patch(), andrun_operation;ResolvedOperationPlandrives hooks, tx scopes, and after-commit dispatch. -
Execution context — nested resolvers
ctx.document,ctx.deps,ctx.tx_ctx,ctx.authz. -
Tracing —
ResolutionTracer/RuntimeTracerwithDepsPlan.with_tracing()and dev runtime tracing. -
Composition catalogs —
DOCUMENT_OPERATIONS(and search, storage, authn) underforze_kits.*.catalog, plus plan hooks. -
Query DSL — literal and field filters,
$not, array quantifiers, text patterns, aggregate groups and truncation, andQueryFilterLimits. -
Document and search —
DocumentCoordinator,update_matching/ensure, method-specific ports (find_page/find_cursor/…), federated search,RowLockMode, stream methods, anddefault_sort. -
Durable functions — contracts under
forze.application.contracts.durable.functionplusrun_durable_function. -
forze_inngest(inngestextra) — Inngest adapter with registry-backed runs and a FastAPI serve. -
Workflow schedules — schedule contracts and Temporal Schedules via declarative schedule bootstraps.
-
Queue delayed delivery —
enqueue/enqueue_manyaccept a delay or a not-before time. -
forze_identity(plusoidcextra) — consolidated authn, authz, tenancy, and OIDC withAuthnOrchestratorandAuthzPolicyService. -
Analytics —
AnalyticsSpec/AnalyticsQueryPortwith Postgres, ClickHouse, and BigQuery adapters. -
forze_firestore,forze_gcs,forze_secrets,forze_vault— document, object-storage, and secrets integrations. -
Postgres startup validation — Pydantic-to-column compatibility and tenancy-wiring checks on
PostgresDepsModule. -
Scrubbing and logging —
forze.base.scrubbing(sanitize,configure_logging(sanitize_logs=True)). -
Integrations — Redis distributed locks, Pydantic and Msgspec model codecs, and optional kit domain mixins.
Changed¶
-
Breaking — execution and composition —
Usecase/UsecaseRegistryreplaced byHandlerplusOperationRegistry. Register withset_handler, compose via patch and bind methods, freeze, then resolve per operation and context. -
Breaking —
ExecutionContext— the doc, dep, transaction, and call-context accessors are renamed onto the nested resolvers. -
Breaking — document and search ports — result shape and pagination are chosen by method name (
find_pagevsfind_cursor);find_many_with_cursoris removed. -
Breaking — query DSL — filter literals, field compares, and grouping move to new operator keys; the top-level time-bucket key is removed.
-
Breaking — identity — legacy
forze_authnzconsolidated intoforze_identity.AuthnIdentityis principal-only,AuthnPortreturns anAuthnResult, and tenant hints are validated via a resolver port. -
Breaking — authorization —
AuthzPort.permits(...)removed; useAuthzDecisionPort.authorize(...). Import plan helpers from the authz hooks module. -
Breaking — durable workflows — contracts move under
forze.application.contracts.durable.workflowwith renamed types and dep keys. -
Breaking — errors —
forze.base.errorsremoved in favor offorze.base.exceptions; the HTTP error-code header defaults tocore.<kind>. -
Breaking — tracing — runtime tracing renamed to
forze.application.execution.tracing;Deps.merge()no longer propagates tracer flags. -
Breaking — FastAPI — the endpoints and HTTP-transport packages are removed; the package now ships middleware, exception handlers, OpenAPI helpers, and security resolvers only.
-
Breaking — Mongo — the client db and collection accessors and the gateway collection accessor are async.
-
Document/search pagination — omitting sorts no longer emits an order-by-id when the read model has no id field; configure
default_sortor pass explicit sorts. -
Messaging contracts — queue, pubsub, and stream messages are frozen attrs value objects, and specs require a model codec.
-
forze_gcsuses native async storage; Postgres PGroonga match and weights follow index order; Postgres and Redis get safer batched writes and atomic mset. -
Scrubbing/console — the log scrub mask changed and traceback frames grew; Socket.IO bind takes an operation resolver; unhandled FastAPI route exceptions return a generic JSON 500.
Removed¶
-
Execution —
Usecase,UsecaseRegistry,UsecasePlan, the bucket module,facade_call, and registry graph introspection types. -
FastAPI — the endpoints package, the HTTP-transport package,
ForzeAPIRouter, and attach-based route helpers. -
Authn and identity — the monolithic authn adapter, the header authn resolver,
OAuth2Tokens, and principal codec ports. -
Query/search/domain — deprecated predicate aliases, the legacy Postgres FTS and PGroonga search adapters, and
forze.domain.mixins(use kit mixins).
Fixed¶
-
forze_fastapi— the exception handlers critical-log tracebacks for unhandled exceptions, and a deliberate causeless 5xx logs at error level. -
Errors — error details and FastAPI context responses no longer expose raw credentials or Pydantic validation input.
-
Postgres — batched updates cast nullable cells correctly and read-only is set before opening transactions; empty FTS queries no longer emit invalid rank SQL.
-
Redis — script result normalization avoids rare type-check failures. S3 user-metadata decoding is fixed and default keys use a fresh UUID v7. The API-key lifecycle unpacks prefix and secret in the correct order.
0.1.14 - 2026-04-08¶
Added¶
-
forze.base.logging— structlog-based logging (structured records, a trace level, Rich/JSON renderers, request/context binding, per-namespace levels, optional dual pretty-stderr plus JSON-stdout, and a global unhandled-exception handler). Replaces the previous Loguru stack. -
forze_fastapi— ANSI-colored HTTP status in access logs, plus an optional unhandled-exception handler and registration for non-core exceptions. -
forze.application.contracts.workflow— port protocols and specs for workflow engines (start, signal, update, query, cancel, terminate). -
forze_temporal— Temporal integration package: deps module and lifecycle, a workflow adapter implementing the command port, and client/worker interceptors propagating context and running payload codecs. -
forze_fastapi.middlewares.context— an ASGI middleware to bind call and principal context and emit call-context headers.
Changed¶
-
DepsreplacesDepRouter— spec-based routing and the router module are removed; route selection now lives onDepswith plain and routed registration and updated merge and removal helpers. -
DepKey/DepsPortimports moved toforze.application.contracts.base— the old deps package (keys, ports, router) is gone; import the base types and drop router types. -
DepsModulewiring — integration packages now buildDepsthrough module callables with routed registration; review each package's execution deps. -
Contracts — ports, specs, and dep keys updated across domains (document, search, workflow, cache, queue, pubsub, stream, tx), including renames and new overloads; search parse helpers removed; the mapper port relocated.
-
forze_fastapi— HTTP integration reorganized under an endpoints package with attach helpers and route features for idempotency and ETag; the custom router and routing package are removed. -
forze.base.logging— a new logger API (configure, get-logger, message sub vs extras); migrate code that relied on Loguru-specific helpers. -
forze.base.logging— OpenTelemetry-aware processors, an exception-info formatter, configurable dim keys, and level-aware Rich console styling. -
forze_fastapi— idempotent routes do not record idempotency when the body is invalid JSON (422), so the key can be reused after fixing the body. -
forze_fastapi— a batch HTTP route registrar, plus an exclude-none option on the document, http, and search attach helpers. -
forze.application.execution—UsecaseRegistry.finalizesupports an in-place mode. -
forze.application.contracts.documentand adapters — optional return-new and return-diff on create, update, touch, and batch variants.
Removed¶
-
DepRouterand the deps package — useDepsrouting and the base module forDepKey/DepsPort. -
TenantContextPortand the tenant contract module. -
ActorContextPortand the actor contract module — caller identity is modeled via the execution context, auth identity, and the FastAPI context middleware. -
The Loguru-based implementation and the
logurudependency, including the old configure-prefixes, render-message, and safe-preview helpers in favor of the structlog logger.
Fixed¶
-
forze_postgres/forze_mongo— document deps modules register each read-write route's read and query port from that route's read config, fixing incorrect reuse of the read-only route. -
forze_postgres/forze_mongo— tenant-aware write gateways include the tenant id in update and hard-delete predicates; Postgres still raises not-found when no row matches the scoped delete. -
forze_postgres— the FTS search adapter reads rows from the configured source relation and uses the index only for catalog metadata; empty-query FTS uses a valid order-by.
0.1.13 - 2026-03-15¶
Added¶
-
hybridmethoddescriptor inforze.base.descriptorsfor class/instance dual methods. -
PaginationDTO with page and size fields for list and search request payloads. -
DocumentDTOswith list and raw-list keys for custom list request DTO types. -
SearchDTOswith read, typed, and raw keys for search facade DTO configuration. -
build_document_list_mapperandbuild_document_raw_list_mapperin document composition. -
build_search_typed_mapperandbuild_search_raw_mapperin search composition. -
LoggingMiddlewareinforze_fastapi.middlewaresfor request/response logging with scope. -
Logger.optfor passing options (depth, exception) to the underlying logger. -
UVICORN_LOG_CONFIG_TEMPLATEandInterceptHandlerinforze_fastapi.loggingfor uvicorn log-config integration. -
Storage application layer — upload, list, download, and delete usecases plus the storage facade, DTOs, and registry builder.
Changed¶
-
OperationPlan.merge,UsecasePlan.merge, andUsecaseRegistry.mergeare now hybridmethods (callable on class or instance). -
The document and search endpoint-name overrides are renamed to path overrides, and the name-overrides parameter to path-overrides.
-
Document and search facades now take a DTOs object instead of a read DTO, and the registry builders require it.
-
DTOMappernow requires the source model type in addition to the output; update existing mappers. -
MappingStepis now generic over the source type; custom steps should specify it. -
CoreModelno longer includesDecimalin its JSON encoders; custom Decimal serialization must be handled elsewhere. -
The list and search request DTOs extend
Pagination, so page and size now live in the request body. -
List and search usecases take the request DTO directly instead of a TypedDict with body, page, and size.
-
Postgres and Mongo document adapters — write operations now return results via the read gateway.
-
Logging — scope-based contextualization, a section helper for structured spans, and
safe_previewreplacing the old argument-safety helper.
Fixed¶
-
Document list endpoints now correctly pass pagination to the usecase.
-
Logging format — escape the extras dict to avoid a key error, and exclude a redundant logger-name field.
Removed¶
-
Paginationand the pagination parameter fromforze_fastapi.routing.params; use the request body instead. -
Usecase.log_parametersand the private argument-safety helper; usesafe_previewfrom logging. -
register_uvicorn_logging_interceptor; use the log-config template in uvicorn'slog_config.
0.1.12 - 2026-03-11¶
Added¶
-
A paginated list-documents endpoint in
forze_fastapiwith typed and raw variants, the list request DTOs, and a list usecase. -
Name overrides on the document and search routers for customizing operation ids and paths.
-
attach_document_routesandattach_search_routesfor attaching routes to existing routers.
Changed¶
attach_search_routerrenamed toattach_search_routesin the search router module. Update imports.
Fixed¶
- Postgres bulk update — correct table alias in the returning clause, and English error messages for consistency errors.
0.1.11 - 2026-03-11¶
Added¶
-
Route-level HTTP ETag support in
forze_fastapiwith an ETag provider protocol, an ETag route, and a route-class factory. -
Per-route and per-router ETag configuration (enabled, provider, auto-304).
-
A document ETag provider deriving the ETag from the document id and revision without response hashing.
-
ETag and
If-None-Match/304 support on the document metadata endpoint. -
A
get()override on the router with ETag parameters. -
A route-feature protocol and a route-class composition engine for composable route behaviors.
-
ETag and idempotency as standalone route features.
-
A route-features parameter on the router's route methods.
-
Document update validators now run even when the update produces an empty diff.
-
A model-hash helper that normalizes
Decimalfor stable hashing;CoreModeladdsDecimalto its JSON encoders.
Changed¶
-
The router now composes idempotency, ETag, and custom route features into a single route class, replacing the single-feature override pattern.
-
The validation helper's default for forbidding extra keys changed from true to false; extra keys are now ignored by default.
-
Document.touch()now returns a new instance via a model copy instead of mutating in place. -
The Postgres document gateway raises a conflict with a revision-mismatch code when history is disabled.
-
The Postgres query renderer requires array column types for array operators.
Fixed¶
-
The document metadata endpoint path is corrected to
/metadata. -
Cache operations in the Postgres and Mongo document adapters are now non-fatal, so failures are suppressed and primary operations succeed.
0.1.10 - 2026-03-11¶
Added¶
-
An error handler for
forze_mongomapping PyMongo exceptions to core error subtypes. -
Optimistic retry on Mongo write-gateway write operations for concurrency errors.
-
A default adaptive retry configuration for the S3 client when none is provided.
Changed¶
-
Replaced the DeepDiff-based dict diff with a lightweight recursive implementation (a large speedup on diff and patch).
-
Removed the
deepdiffandmergedeepruntime dependencies from the core package. -
Cached the middleware chain in the usecase call to avoid rebuilding closures per invocation.
-
Cached signature lookups in the error-handling decorators.
-
Cached module lookups in the introspection helpers.
-
Cached type-adapter instances per payload type in the Socket.IO emitter.
-
The document update now uses a shallow model copy for scalar-only diffs.
-
The S3 storage adapter list now fetches object metadata concurrently.
-
Used list-extend over augmented assignment for middleware chain construction.
-
Eliminated per-call signature binding; the operation name resolves once at decoration time.
-
Postgres dict-row fetch uses a dedicated row-to-dict method.
-
SQS queue-name sanitization uses pre-compiled regex patterns.
-
RabbitMQ ack/nack acquires the pending-messages lock once per batch.
-
Cached field-name lookups and narrowed the return type to a frozenset.
-
Cached Postgres type normalization in the introspection utilities.
-
Pre-computed query-operator sets as module-level constants in the filter parser.
-
S3 list now exits pagination early when the limit window is fully collected.
0.1.9 - 2026-03-10¶
Added¶
- Socket.IO integration package
forze_socketiowith typed command-event routing, usecase dispatch through the execution context, a typed server-event emitter, ASGI and server builders, and an optional extra.
Changed¶
-
Contracts refactor — removed the conformity protocols and their dep variants; port protocols remain the source of truth.
-
Removed
forze.base.typing; type checking is now enforced via mypy strict mode.
0.1.8 - 2026-03-10¶
Added¶
-
A strict content-type parameter (default true) on the router and route methods.
-
Tenant context support in the S3 storage adapter.
-
An
S3ConfigTypedDict for abstracting botocore configuration inforze_s3. -
Socket and connect timeouts on the Redis config.
-
Prefix validation on the S3 storage adapter.
-
A Mongo document adapter with dependency factories and CRUD/query support.
-
PubSub contracts and a Redis pubsub adapter with execution wiring.
-
RabbitMQ integration package
forze_rabbitmqwith queue contracts, client and adapters, an execution module and lifecycle, and test coverage. -
In-memory integration package
forze_mockwith shared-state adapters and deps for document, search, counter, cache, idempotency, storage, queue, pubsub, stream, and the tx manager. -
SQS integration package
forze_sqswith an async client and adapters, an execution module and lifecycle, an optional extra, and LocalStack coverage.
Changed¶
-
Search router — split building from attachment.
-
Response body chunk processing in the idempotent route (performance).
-
The Postgres patch-many loop now gathers concurrently (performance).
-
Postgres document write operations avoid redundant reads (performance).
-
The Mongo integration now mirrors Postgres composition with dedicated read, write, and history gateways and configurable revision and history strategies.
-
RabbitMQ batch enqueue now publishes via a single channel scope and queue declaration per batch (performance).
Fixed¶
-
Tenant-context dep resolution in the S3 storage adapter (invoke the dep as a factory).
-
Read-gateway fallback on cache failure.
-
Deterministic UUID generation now uses SHA-256 instead of MD5 (security).
0.1.7 - 2026-03-08¶
Changed¶
-
The package is now published on PyPI instead of OCI.
-
register_scalar_docs— the version parameter is renamed, and the docs page title now uses the app title.
0.1.6 - 2026-03-04¶
Execution and mapping refactor, middleware-first usecases, split search/cache/document contracts.
Added¶
-
A mapping module with
DTOMapper, mapping steps, and a mapping policy for composable async DTO mapping. -
Document plan and create-mapper builders in the document registry builder.
-
Namespaced document and storage operation values.
-
A creator-id field constant in the domain constants.
-
A search contract with read and write ports, specs, and a parser, plus a Postgres search adapter.
-
A FastAPI search router and facade dependency.
Changed¶
-
Document and storage operations and the document facade move out of the facades package, which is removed.
-
The effect, guard, middleware, and next-call types move from the usecase module to a middleware module.
-
Depsadopts a constructor-based API; the builder methods are removed. -
Usecasenow requires an execution context, and guards and effects are replaced by middlewares. -
TxUsecaseremoved; transaction handling moves to a tx middleware in the plan. -
The document facade provider now requires the registry and plan.
-
The create and update document usecases use the async mapper instead of sync callables; the numbered create usecase is removed in favor of a numbered create mapper.
-
Search specs split into public TypedDicts and internal attrs, with per-index source and ordered groups.
-
Router subclasses must set the dep key as a class attribute when using the attrs decorator.
Fixed¶
-
The Postgres history gateway consistency error messages are now in English.
-
The Postgres search adapter uses a correct attrs mutable default for its gateway cache.
-
Postgres index introspection uses a lateral unnest and simplified detection.
-
The Postgres error handler covers grouping errors.
0.1.5 - 2026-02-28¶
Added¶
-
A
scalar-fastapidependency and a Scalar docs registrar inforze_fastapi.openapi. -
An exception-handlers module in
forze_fastapi.handlers. -
Stable OpenAPI operation ids on all document router endpoints.
-
Public exports in
forze_postgres,forze_redis, andforze_s3for the deps modules, client dep keys, and lifecycle steps. -
An idempotency dep key in the idempotency contract.
-
A route-level idempotency route and route-class factory in
forze_fastapi.routing.routes. -
A deps module and deps plan in the execution deps module.
-
From-modules and from-steps factory methods for the deps and lifecycle plans.
-
A lifecycle plan and step in the execution lifecycle module.
-
An
ExecutionRuntimecombining the deps plan, lifecycle, and context scope.
Changed¶
-
Depsmoved from the deps contract to the execution package. Update imports. -
Postgres, Redis, and S3 restructure — the dependencies package is removed and modules move to execution with attrs-based deps modules and lifecycle steps. Replace the old module helpers with the new deps modules.
-
The router's from-deps factory now accepts a deps port and returns an optional remainder.
-
The doc, counter, tx-manager, and storage port resolvers consolidate into a single resolver namespace class.
-
The DTO spec is renamed to the document DTO spec. Update imports.
-
Document router — request body params now use a body annotation with override annotations for a correct OpenAPI schema.
-
The router and document-router builder no longer accept idempotency parameters; idempotency is applied via a custom route class and resolved from the context via the idempotency dep key.
0.1.4 - 2026-02-27¶
Added¶
-
A configurable revision-bump strategy in
forze_postgres(database vs application) and a configurable document factory. -
A middleware protocol and chain composition in the usecase.
-
An outbox feature module with buffer middleware and a flush effect.
-
A middleware factory and middleware support in the usecase plan.
Changed¶
-
TxContextScopedPortrenamed toTxScopedPort(the context requirement is removed). Update imports. -
The tx-scope-match decorator is removed; tx-scope validation is now handled by the execution context when resolving dependencies.
-
The Postgres document adapter no longer requires the context; it uses the tx-scoped port instead.
Fixed¶
- Duplicate guards, middlewares, and effects are now deduplicated by priority when merging usecase-plan operations.
0.1.3 - 2026-02-27¶
Added¶
-
A filter query DSL in
forze.application.dsl.query— AST nodes, parser, and value coercion. -
A Mongo query renderer for compiling filter expressions to MongoDB queries.
-
A buffer primitive in
forze.base.primitives.
Changed¶
-
Application-layer restructure — the kernel splits into a contracts package (ports, specs, deps, schemas) and an execution package (context, usecase, plan, registry, resolvers). Update imports.
-
Contracts flattening — top-level re-exports, with internal modules moved to underscored sub-packages.
-
Tx contracts rename — the tx-manager port and related contracts move from the tx-manager module to the tx module. Update imports.
-
Postgres filter builder — replaced the old builder with the DSL-based query renderer; the old builder is removed.
0.1.2 - 2026-02-26¶
Added¶
-
forze.base.typingwith protocol conformance helpers. -
Domain document support in
forze.domainbuilt from the domain document model with name, number, and soft-deletion mixins and update-validator infrastructure. -
A document kernel in
forze.application.kernel— pluggable usecase plans, a document facade factory, the document port with explicit read and write ports, and a document operation enum. -
An optional FastAPI integration package with routing helpers, idempotent POST support, and a prebuilt document router.
-
Optional provider packages
forze_postgres,forze_redis,forze_s3,forze_temporal, andforze_mongowith platform clients, gateways and adapters, and dependency keys.
Changed¶
-
Kernel — transaction handling and dependency resolution refactored around the execution context and kernel deps; the tx-manager and app-runtime ports are removed from the kernel ports.
-
Postgres filter builder — filter input accepts only canonical operator names; aliases such as
==,ge, andin_are no longer accepted and raise a validation error. -
Infrastructure previously under
forze.inframoved into optional packages; coreforzeno longer ships Postgres, Redis, S3, or Temporal implementations.
Fixed¶
- Correct UUIDv7 datetime conversion in
forze.base.primitives.uuidso round-trips preserve timestamp semantics.
0.1.1 - 2026-02-23¶
Added¶
- Initial DDD/Hex contracts — ports, results, errors.
Fixed¶
- Packaging metadata for PyOCI classifiers.