ADR 0036 — Cascade meta, not expose
- Status: Accepted — extends ADR 0021; records a rejection that amends nothing in ADR 0016.
- Date: 2026-08-05
Context
A consuming project repeated expose: ['HTTP'] on 28 endpoints across 7
contracts, and meta on 73 endpoints across 8 — because implement() cascades
scope from the contract but takes expose and meta from the endpoint alone.
The ask was to cascade both.
The expose half was framed as safety: a missing expose means the endpoint is
a tool on MCP and AGENT, so forgetting one line silently hands an endpoint to
a model. That framing is right about the default and wrong about the remedy.
Decision
meta cascades, shallow-merged
ContractMeta gains meta?, and an endpoint’s own meta merges over it, key
by key. One level, no deep merge — a deep merge invites nested-unset questions
with no clean answer. Neither side declaring anything leaves meta undefined,
not {}, because readers test method.meta?.x and an empty object reads as
“declared”.
An explicit key: undefined on the endpoint is the opt-out. The key survives
the spread with value undefined, so it shadows the contract’s value and every
meta?.key reader sees nothing — “the contract turns this on for everyone, this
endpoint turns it off”. This is deliberate, not an artifact: a consuming project’s
public form-submission endpoint sits in an otherwise admin-gated contract and opts
out of the inherited RBAC page exactly this way. (Their previous ??-based
cascade swallowed the opt-out — undefined ?? PAGE is PAGE — which silently
put an admin gate on a public endpoint; the live probe returned 401 on the public
form. The spread semantics fix that class.) Note the asymmetry this creates for
in-style readers: the key is present with value undefined — readers must
test the value (meta?.key), not membership.
Shallow merge rather than override because meta is not decoration: the OpenAPI
generator documents meta: { public: true } as the recommended declarative
allowlist for the published spec. Under override, a contract declaring
{ public: true } plus an endpoint adding { rateTier: 2 } would silently drop
public and the endpoint would vanish from the spec with no diff explaining why.
Applied at every MethodDef producer — implement, implementRemote — and
createContractFactory now forwards the meta object instead of rebuilding it,
which had silently dropped any field beyond prefix and scope.
expose does not cascade
- It would not close the hole. Forgetting
exposeat both levels leaves the endpoint exactly as exposed as before. With ~4 endpoints per contract the cascade divides the chance of the mistake by 4 and multiplies its blast radius by 4 — expected exposure roughly flat. - It would open a new silent vector. Today an endpoint’s exposure is written
on the endpoint, so moving it between contracts is exposure-neutral. With a
cascade, moving it into a contract that does not declare
exposeturns it into an AI tool with no diff on the endpoint itself — the “forgot a line while refactoring” scenario, made harder to see in review. - The real remedy already ships and needs no framework change.
listToolNames({ services, runtimeTools })resolves every tool through the same mixed-surface resolver the mounts use; pinned in a snapshot it fails the build the moment a forgottenexposeadds a tool, naming its origin, service, method and transports. That catches the both-levels case a cascade cannot. - It would need five more edit sites to not regress.
browser/client.tsandtools/remote.tsreadendpoint.exposedirectly;ExposesHttptypes the client off it;defineContract’stoolNamevalidator readsep.expose, so it would stop throwing where it should and start throwing on a legal contract; and the scoped factory would have dropped the field. A tools-only contract would have shipped a typed HTTP client for routes the router refuses to serve.
Alternatives considered
- Flip the default to
['HTTP'](tools opt-in). Rejected — not because it is breaking (this repo’s policy explicitly allows that pre-1.0), but because it is breaking silently: nothing errors, every consumer’s tools simply vanish from their host. It would also supersede ADR 0016’s reasoned default-on baseline. - Cascade
exposeanyway, with all five sites fixed. Rejected on 1 and 2 — the ergonomics are real but they are bought with a new silent-change vector, for a safety benefit that is roughly nil. - A required-
exposestrict factory (ascreateContractFactorydoes forscope). Deferred here; later consumer evidence produced the type-visible, materialized factory policy in ADR 0062. It does not introduce the contract-level inheritance rejected by this ADR.
Consequences
- Additive.
exposebehaviour is byte-identical to 0.25.0; a contract that declares nometais unaffected. - A contract-level
metanow reaches endpoints through the factory too — code that relied on the factory dropping it (there is no reason to) would change. - The fail-open
exposedefault stays, documented in the guide next to the snapshot recipe that catches it.