Proposed 2026-11-01 (STA-1821). The number is reserved by the testing session; until then this
page uses
0511 in its name and text. It amends
0260 (decisions 2 to 4),
0453
(decisions 6, 10 and 11) and 0141 (decision 2),
each by a dated note at its end. It relates to
0349
(decisions 2, 4 and 5),
0294 (rule R10),
0142 and
0227.Context
stackra gen builds a consumer’s #stackra/clients/<service>, its listener registrations and its read-model types from
the producer’s committed stackra.manifest.json (0260
decisions 2 and 3, 0294 rule R10). Today gen finds that
manifest only in a sibling folder. When no sibling exists it falls back to any dependency whose member name matches, and that
match can pick an unrelated package’s manifest. Product gates went green only with uncommitted local copies of the
manifests, so the fallback hid the gap instead of reporting it.
Three ways to ship the manifests across repositories were weighed. A contract package on the registry (chosen) reuses the
registry, the catalogs, OIDC publishing and the dependency lookup gen already has. A stackra manifests pull command with
committed copies and a lock file was rejected: it adds a second channel beside the registry, committed copies drift, and it
needs a cross-repo read token. A central manifest repository consumed as a submodule was rejected: one global ref, cross-repo
write tokens, merge races and an informal second registry.
Clients alone do not deliver a cross-app event. 0260
decision 4 assumes one product, and Encore Pub/Sub topics cannot be shared between apps. So the transport of events that cross
apps is decided here too, and phase 1 ships only the clients.
Decision
Contracts
- A contract is a package. A service that another repository consumes publishes
@figentra/<service>-contract, where<service>is the service’s published name after@figentra/, as 0349 decision 2 names it. So the OS’sservice-<name>gives@figentra/service-<name>-contract, and another product’s<product>-service-<name>gives@figentra/<product>-service-<name>-contract. The leader confirmed this reading on 2026-11-01. The package holds three things and nothing else:stackra.manifest.json,openapi.jsonand the service’s lang catalogue. It carries no source: nosrc/folder and no source maps. - The kind is
contract.stackra.kindgets a sixth value,contract(decision 11 of 0453, amended below). The kind, and not a-contractsuffix on the name, says what a package is. A contract is not installed bystackra add. - Consumers pin through the catalog, and gen reads
node_modules. A consuming member declares@figentra/<service>-contractas a devDependency withcatalog:, so its version comes frompnpm-workspace.yamlas every other version does. When no sibling folder holds the service, gen reads the manifest from that package, and only when the consumer declares it. The member-name fallback is removed: a service with neither a sibling nor a declared contract is refused. A contract is never hosted as a module, kind, driver or package; discovery skips it. - Each producer releases its own contract. The
release.ymlof the producer repository packs the contract and publishes it through GitHub OIDC (0453 decision 6, as amended below). No repository publishes another repository’s contract. - One trusted publisher per producer repository. The
@figentrarow of the 2026-10-31 scope table gets one publisher per producer repository, bound to the contract names of that repository’s own services. The registry refuses a contract published by any other repository. - Each contract has its own version line. A contract is versioned by its service’s changesets. A change that breaks the
manifest or the OpenAPI document is a major version, as decided by
stackra diff(0157). A consumer moves by a catalog bump, andstackra gen --checkreports a consumer whose generated output differs from its pinned contract. - Branch builds publish prerelease versions. A branch or pull request build of a producer may publish a prerelease
contract version under the dist-tag
pr-<n>(nis the pull request number). A stable contract version is published only frommain. A branch build never moveslatest. A branch may pin such a prerelease exactly in its catalog; a build frommainrefuses one. - The OS handover manifests go first. The first contracts published are the OS handover manifests. Once they are on the registry, the uncommitted local overlays that stand in for them retire.
Events that cross apps
- Phase 1 ships clients only. A consumer in another repository gets the generated client from the contract, and its calls and queued commands go through the transport of 0142. A listener on an event of another repository waits for decisions 10 to 15.
- Contracts say what, transport says how. A contract declares what a service exposes: its actions, queries, events, refusal codes and lang catalogue. How an event crosses apps is decided by decisions 11 to 15. Sharing a contract never implies sharing a bus, and a consumer with a contract gets no subscription to the producer’s Encore Pub/Sub topics.
-
One envelope for every transport. An integration event that crosses apps uses the envelope of
0141 decision 2: its
idis the event id,occurredAtis the occurred-at time,tenant,actor,onBehalfOf,correlationandcausationcarry the tenant, scope and correlation context. The cross-app form adds two fields, set by 0141’s dated amendment:envelopeVersion, the version of this envelope, andproducer, the verified producer service. The relay setsproducerfrom the verified identity of the sending service (STA-1792), never from a value the producer writes into the envelope. The envelope does not change with the transport. - Phase 1 transport is HTTP push from the producer’s outbox. An event leaves a producer only from its transactional outbox (0078, 0260 decision 5), never from a request handler. A cross-app relay reads the outbox and delivers each event to each subscribed consumer as one signed HTTP request to the consumer’s inbox endpoint. A failed delivery is retried. The consumer’s inbox (decision 13) makes a retry safe. The signing key is read from the secret store (0173), never from a file. The in-service relay of 0260 decision 4 is a different thing and stays inside one service. A broker driver behind the event-bus port of 0142 is an explicit later option, not chosen here. 0227 keeps Kafka, NATS and Redpanda deferred, and adopting any broker needs its own ADR.
- Consumer inboxes are idempotent. Delivery is at least once, and a retry is delivered with the same event id. A consumer records each event id in its inbox in the same transaction as the handling. A delivery whose id is already recorded is acknowledged and not handled again.
- Encore Pub/Sub stays inside one app. Within one app, events keep the typed subscription of 0260 decision 4. Across apps, only decisions 11 to 13 apply.
-
Gen refuses a remote listener until the relay ships. A listener on an event of a service with no sibling folder, that is
contract-only, is refused with
client_event_remote_transport. The refusal names the event and the service. The refusal is lifted when the relay of decision 12 ships.
Consequences
- A consumer builds against the manifest it declares. A wrong manifest can no longer be picked by name, and a missing one is a visible refusal instead of a silent substitute.
- The registry gains a kind, one publisher row per producer repository, and a prerelease path for branches. Each branch build adds an immutable version (0453 decision 7), so the registry keeps those versions. Their cleanup is not decided here.
- Each producer repository needs a release job and a publisher row before its contract can be consumed. Each consumer repository needs a catalog entry for the contract it uses.
- Contracts follow their service’s version line, not the product line of 0349 decision 5.
- Cross-app listeners wait for the relay. Until it ships, a product that needs them stays on calls and queued commands.
- The relay needs an outbox on the producer side, an inbox on the consumer side, and signing keys in the secret store. Those are chores for the owner and the lanes that build them.
- AGENTS.md’s “Services” section names the generated Encore app as the only event path and the manifest as the only client source. It is updated in the same PR that implements the contracts, not in this ADR’s commit.
Rollout
- Registry pre-publish check. Before the first contract publish, check the registry’s D1 publisher seed for the
@figentrarow: its read rule isinternal, and its trusted publishers must match decision 5. Add the producer repository rows that are missing. No contract is published until this check passes. - Gen and the kind. Land decisions 2, 3 and 15 with their tests, and update AGENTS.md’s “Services” section in the same PR.
- OS handover contracts. Publish the OS handover manifests as the first contracts (decision 8), then retire the overlays.
- Consumers switch. Each consuming repository moves from a sibling or local copy to its declared contract.
- Relay. Build the outbox relay and the consumer inbox (decisions 12 and 13), then lift the refusal of decision 15.
Enforcement
- planned — STA-TBD:
stackra genreads a contract only from a declared dependency, and refuses a service with neither a sibling folder nor a declared contract (client_service_unknown), with a test per path inpackages/developer/gen. - planned — STA-TBD: gen discovery skips a package whose
stackra.kindiscontract, so no contract is hosted. - planned — STA-TBD: the contract pack step refuses a package that holds a
src/folder, a source map, or any file outside decision 1’s three items. - planned — STA-TBD: the registry refuses a contract from a workflow or repository that is not the publisher row of its name,
refuses a stable version from a ref other than
refs/heads/main, and refuses thecontractkind until its schema lands. - planned — STA-TBD:
pnpm validaterefuses a prerelease contract in a catalog of themainbranch. - planned — STA-TBD:
stackra gen --checkfails when a consumer’s generated output differs from its pinned contract. - planned — STA-TBD: gen refuses a remote listener with
client_event_remote_transport(decision 15). - planned — STA-TBD: the relay sets
producerfrom the verified sender identity (STA-1792), refuses an unsigned or mis-signed push, and the inbox handles a repeated event id once (decisions 11 to 13). - Nothing checks the handover of decision 8, the registry seed check of the Rollout, or the AGENTS.md update; they are owner and lane steps tracked in Linear.