← stitchkitDocumentation

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

  1. It would not close the hole. Forgetting expose at 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.
  2. 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 expose turns 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.
  3. 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 forgotten expose adds a tool, naming its origin, service, method and transports. That catches the both-levels case a cascade cannot.
  4. It would need five more edit sites to not regress. browser/client.ts and tools/remote.ts read endpoint.expose directly; ExposesHttp types the client off it; defineContract’s toolName validator reads ep.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 expose anyway, 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-expose strict factory (as createContractFactory does for scope). 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. expose behaviour is byte-identical to 0.25.0; a contract that declares no meta is unaffected.
  • A contract-level meta now reaches endpoints through the factory too — code that relied on the factory dropping it (there is no reason to) would change.
  • The fail-open expose default stays, documented in the guide next to the snapshot recipe that catches it.