Status: Accepted (2026-06-21)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0054 (prove-it-runs for the authorable surface), ADR-0049 (enforce-or-remove)
Consumers: @objectstack/spec (the public API surface + api-surface.json snapshot), @objectstack/downstream-contract (the frozen fixture), the TypeScript Type Check + Test Core CI gates, the Release workflow, spec authors, platform contributors, and every third party consuming a published release.
Surfaced by: #2035 — 16 writable domains had no defineX factory, so third parties authored them as bare output-type literals; and #2023, where a root-import resolved to any and silently swallowed ~30 real type errors in the examples.
ObjectStack is a development platform: the @objectstack/spec package is the
third-party API. A removed, renamed, or narrowed export — or a schema that stops
accepting previously-valid metadata — breaks every consumer pinned to a published
release the moment they upgrade.
Every consumer the framework tested before this ADR (the example apps,
@objectstack/dogfood, the Studio/Setup/Account apps) lives in this monorepo
and co-evolves with the spec in the same commit: when a spec change would break
them, the same PR just fixes them. So none of them can catch a break for a real
third party — e.g. hotcrm — that is
pinned to a published release and authors metadata independently. A breaking
change could be green on every framework gate and still shatter downstream after
publish. Call this gap invisible backward-compat breakage.
Decision. Backward compatibility for third parties is proven by a layered set of gates, each with a defined job, none of which co-evolves silently with the spec:
| Layer | Gate | Proves | Runs |
|---|---|---|---|
| author-time | defineX(config) factories |
a definition is valid at the point it is written | consumer's editor / module import |
| author-time | the bare-literal lint guard | the canonical authoring form is used, never a degradable : Type literal |
every PR (examples + packages/apps) |
| load-time | objectstack validate |
a consumer's own metadata parses against the spec, regardless of authoring style | the third party's CI (the authoritative self-gate) |
| framework · depth | @objectstack/downstream-contract |
exercised exports still accept real third-party metadata | every PR |
| framework · breadth | packages/spec/api-surface.json snapshot |
the whole public export set still exists (no silent removal/rename) | every PR |
| framework · live | the pre-publish hotcrm smoke | a real, independently-authored consumer still type-checks + validates | the Release workflow, pre-publish |
A metadata-driven platform makes two promises to third parties: what you author the way the templates show works, and what worked on version N keeps working on N+1. The first is covered by ADR-0049 (enforce-or-remove) and ADR-0054 (prove-it-runs). This ADR covers the second.
The spec's surface is large and almost entirely AI-authored, by design. Two failure modes recur:
- Silent type-surface erosion. A root-namespace import that resolves to
any(#2023), or a.default()added to a schema that flips a.inputfield from optional to required, degrades or narrows the surface without anyone noticing — the static gates stay green because the in-repo consumers are edited in lockstep. - An authoring gap that pushes consumers onto unsafe patterns. #2035: 16
domains had no factory, so the only authoring option was a bare
: DomainTypeoutput-type literal — no runtime validation, and (via theanytrap) no compile-time validation either. hotcrm did exactly this for pages / reports / actions / flows, including: Action(the output type), while usingdefineView()everywhere a factory existed.
The lesson from both: an in-repo consumer that is fixed in the same PR as the spec change cannot be the witness for backward compatibility. The witness must be something the change is not allowed to edit.
1. Every writable domain has a defineX factory; bare output-type literals are banned in authoring surfaces
defineX(config: z.input<typeof XSchema>): X { return XSchema.parse(config); }
is the single authoring entry for every writable domain (#2088
completed the remaining 16). It is the only form that is simultaneously
input-shape ergonomic, validated at authoring time, and — being a value import
— impossible to silently degrade to any. A no-restricted-syntax lint guard
bans bare : DomainType exported-const literals across examples/** and
packages/apps/** (#2088,
#2097).
The runtime loader parses every metadata file against the spec schemas
regardless of how it was authored. A third party runs it in their own CI
(hotcrm's verify = validate && typecheck && build && test). The factory moves
the same check earlier and into the editor; it does not replace it.
3. The framework proves backward compatibility with two frozen in-repo gates + one live pre-publish gate
- Depth —
@objectstack/downstream-contract(#2089, #2095). A fixture authored the way an external consumer does — builder + factories + bare literals — across all 16 writable domains, typed with the spec's own input aliases and run through each schema's.parse()plus adefineStackassembly. Catches a narrowed/removed schema property on any domain. - Breadth — the API-surface snapshot (#2092).
packages/spec/api-surface.jsonrecords every exportedname (kind)of all 16 public entry points.check:api-surfacefails on any drift; a removal is breaking, an addition still requires regenerating the snapshot, so no change to the public surface is silent. - Live — the pre-publish hotcrm smoke (#2093).
The Release workflow clones
objectstack-ai/hotcrmat a pinned tag, installs it against the published packages, overlays the about-to-publish spec, and runs hotcrm's owntypecheck+validate. A red blocks the publish.
The downstream-contract fixtures and the api-surface snapshot are frozen:
A spec change that requires editing the fixtures, or that removes an entry from the snapshot, is by definition a breaking change for third parties.
When a gate turns red, the change is a deliberate fork in the road, never a mechanical "update the test":
- Removed / renamed / narrowed → restore it, or bump
@objectstack/specto a new major and document the migration. Only then regenerate the snapshot / update the fixture, in the same PR. - Added (safe) → regenerate the snapshot so the addition is acknowledged.
This is what makes the witnesses real: they do not co-evolve silently.
- The in-repo consumers (examples,
@objectstack/dogfood, the platform apps) keep their existing roles — reference corpus and prove-it-runs (ADR-0054). They are not backward-compat witnesses, precisely because they are fixed in lockstep. - The snapshot is name+kind, not full signatures: it catches add/remove/rename but not every narrowing. Narrowing of exercised exports is caught by the downstream-contract typecheck. A signature-level snapshot is deferred until a narrowing actually slips both (evidence-gated, like ADR-0054's Phase 3).
Positive.
- A backward-compat break for a lagging third party is caught at PR time (depth + breadth) or pre-publish (live), not after release.
- The public surface of
@objectstack/speccannot change silently — every add/remove is recorded and reviewed. - One canonical authoring form, which is also the form the AI corpus teaches.
Costs / trade-offs.
- The fixtures and the snapshot must be maintained; the freeze discipline (don't edit-to-pass) is a review-culture requirement, not just a CI check.
- The api-surface snapshot is large (~4 000 exports) and regenerates on any export change — intentional churn that forces acknowledgement.
- The live hotcrm gate couples the Release workflow to one external repo; it is
release-only and pinned to a tag (
HOTCRM_REF) to bound that coupling, and a hotcrm-side break can be worked around by re-pinning.