|
| 1 | +# ADR-0059: Third-party backward compatibility is proven by layered gates, not by in-repo consumers |
| 2 | + |
| 3 | +**Status**: Accepted (2026-06-21) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs for the authorable surface), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove) |
| 6 | +**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. |
| 7 | +**Surfaced by**: [#2035](https://github.com/objectstack-ai/framework/issues/2035) — 16 writable domains had no `defineX` factory, so third parties authored them as bare output-type literals; and [#2023](https://github.com/objectstack-ai/framework/issues/2023), where a root-import resolved to `any` and silently swallowed ~30 real type errors in the examples. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## TL;DR |
| 12 | + |
| 13 | +ObjectStack is a development platform: the `@objectstack/spec` package **is** the |
| 14 | +third-party API. A removed, renamed, or narrowed export — or a schema that stops |
| 15 | +accepting previously-valid metadata — breaks every consumer pinned to a published |
| 16 | +release the moment they upgrade. |
| 17 | + |
| 18 | +Every consumer the framework tested before this ADR (the example apps, |
| 19 | +`@objectstack/dogfood`, the Studio/Setup/Account apps) lives **in this monorepo |
| 20 | +and co-evolves with the spec in the same commit**: when a spec change would break |
| 21 | +them, the same PR just fixes them. So none of them can catch a break for a *real* |
| 22 | +third party — e.g. [hotcrm](https://github.com/objectstack-ai/hotcrm) — that is |
| 23 | +pinned to a **published** release and authors metadata independently. A breaking |
| 24 | +change could be green on every framework gate and still shatter downstream after |
| 25 | +publish. Call this gap **invisible backward-compat breakage**. |
| 26 | + |
| 27 | +**Decision.** Backward compatibility for third parties is proven by a layered set |
| 28 | +of gates, each with a defined job, none of which co-evolves silently with the |
| 29 | +spec: |
| 30 | + |
| 31 | +| Layer | Gate | Proves | Runs | |
| 32 | +|------|------|--------|------| |
| 33 | +| author-time | `defineX(config)` factories | a definition is valid at the point it is written | consumer's editor / module import | |
| 34 | +| author-time | the bare-literal lint guard | the canonical authoring form is used, never a degradable `: Type` literal | every PR (examples + `packages/apps`) | |
| 35 | +| 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) | |
| 36 | +| framework · depth | `@objectstack/downstream-contract` | exercised exports still **accept** real third-party metadata | every PR | |
| 37 | +| framework · breadth | `packages/spec/api-surface.json` snapshot | the whole public export set still **exists** (no silent removal/rename) | every PR | |
| 38 | +| framework · live | the pre-publish hotcrm smoke | a real, independently-authored consumer still type-checks + validates | the Release workflow, pre-publish | |
| 39 | + |
| 40 | +## Context |
| 41 | + |
| 42 | +A metadata-driven platform makes two promises to third parties: *what you author |
| 43 | +the way the templates show works*, and *what worked on version N keeps working on |
| 44 | +N+1*. The first is covered by ADR-0049 (enforce-or-remove) and ADR-0054 |
| 45 | +(prove-it-runs). This ADR covers the second. |
| 46 | + |
| 47 | +The spec's surface is large and almost entirely AI-authored, by design. Two |
| 48 | +failure modes recur: |
| 49 | + |
| 50 | +1. **Silent type-surface erosion.** A root-namespace import that resolves to |
| 51 | + `any` (#2023), or a `.default()` added to a schema that flips a `.input` field |
| 52 | + from optional to required, degrades or narrows the surface without anyone |
| 53 | + noticing — the static gates stay green because the in-repo consumers are |
| 54 | + edited in lockstep. |
| 55 | +2. **An authoring gap that pushes consumers onto unsafe patterns.** #2035: 16 |
| 56 | + domains had no factory, so the only authoring option was a bare |
| 57 | + `: DomainType` output-type literal — no runtime validation, and (via the |
| 58 | + `any` trap) no compile-time validation either. hotcrm did exactly this for |
| 59 | + pages / reports / actions / flows, including `: Action` (the output type), |
| 60 | + while using `defineView()` everywhere a factory existed. |
| 61 | + |
| 62 | +The lesson from both: an in-repo consumer that is fixed in the same PR as the |
| 63 | +spec change cannot be the witness for backward compatibility. The witness must be |
| 64 | +something the change is **not allowed to edit**. |
| 65 | + |
| 66 | +## Decision |
| 67 | + |
| 68 | +### 1. Every writable domain has a `defineX` factory; bare output-type literals are banned in authoring surfaces |
| 69 | + |
| 70 | +`defineX(config: z.input<typeof XSchema>): X { return XSchema.parse(config); }` |
| 71 | +is the single authoring entry for every writable domain ([#2088](https://github.com/objectstack-ai/framework/pull/2088) |
| 72 | +completed the remaining 16). It is the only form that is simultaneously |
| 73 | +input-shape ergonomic, validated at authoring time, and — being a *value* import |
| 74 | +— impossible to silently degrade to `any`. A `no-restricted-syntax` lint guard |
| 75 | +bans bare `: DomainType` exported-const literals across `examples/**` and |
| 76 | +`packages/apps/**` ([#2088](https://github.com/objectstack-ai/framework/pull/2088), |
| 77 | +[#2097](https://github.com/objectstack-ai/framework/pull/2097)). |
| 78 | + |
| 79 | +### 2. `objectstack validate` is the third party's authoritative self-gate |
| 80 | + |
| 81 | +The runtime loader parses every metadata file against the spec schemas |
| 82 | +regardless of how it was authored. A third party runs it in their own CI |
| 83 | +(hotcrm's `verify = validate && typecheck && build && test`). The factory moves |
| 84 | +the same check earlier and into the editor; it does not replace it. |
| 85 | + |
| 86 | +### 3. The framework proves backward compatibility with two frozen in-repo gates + one live pre-publish gate |
| 87 | + |
| 88 | +- **Depth — `@objectstack/downstream-contract`** ([#2089](https://github.com/objectstack-ai/framework/pull/2089), |
| 89 | + [#2095](https://github.com/objectstack-ai/framework/pull/2095)). A fixture |
| 90 | + authored the way an external consumer does — builder + factories + **bare |
| 91 | + literals** — across all 16 writable domains, typed with the spec's own input |
| 92 | + aliases and run through each schema's `.parse()` plus a `defineStack` |
| 93 | + assembly. Catches a narrowed/removed schema property on any domain. |
| 94 | +- **Breadth — the API-surface snapshot** ([#2092](https://github.com/objectstack-ai/framework/pull/2092)). |
| 95 | + `packages/spec/api-surface.json` records every exported `name (kind)` of all 16 |
| 96 | + public entry points. `check:api-surface` fails on any drift; a removal is |
| 97 | + breaking, an addition still requires regenerating the snapshot, so no change to |
| 98 | + the public surface is silent. |
| 99 | +- **Live — the pre-publish hotcrm smoke** ([#2093](https://github.com/objectstack-ai/framework/pull/2093)). |
| 100 | + The Release workflow clones `objectstack-ai/hotcrm` at a pinned tag, installs |
| 101 | + it against the **published** packages, overlays the about-to-publish spec, and |
| 102 | + runs hotcrm's own `typecheck` + `validate`. A red blocks the publish. |
| 103 | + |
| 104 | +### 4. The freeze contract |
| 105 | + |
| 106 | +The downstream-contract fixtures and the api-surface snapshot are **frozen**: |
| 107 | + |
| 108 | +> A spec change that requires editing the fixtures, or that removes an entry from |
| 109 | +> the snapshot, is **by definition a breaking change** for third parties. |
| 110 | +
|
| 111 | +When a gate turns red, the change is a deliberate fork in the road, never a |
| 112 | +mechanical "update the test": |
| 113 | + |
| 114 | +- **Removed / renamed / narrowed** → restore it, or bump `@objectstack/spec` to a |
| 115 | + new **major** and document the migration. Only then regenerate the snapshot / |
| 116 | + update the fixture, in the same PR. |
| 117 | +- **Added** (safe) → regenerate the snapshot so the addition is acknowledged. |
| 118 | + |
| 119 | +This is what makes the witnesses real: they do not co-evolve silently. |
| 120 | + |
| 121 | +### 5. Boundaries |
| 122 | + |
| 123 | +- The in-repo consumers (examples, `@objectstack/dogfood`, the platform apps) |
| 124 | + keep their existing roles — reference corpus and prove-it-runs (ADR-0054). |
| 125 | + They are **not** backward-compat witnesses, precisely because they are fixed in |
| 126 | + lockstep. |
| 127 | +- The snapshot is name+kind, not full signatures: it catches add/remove/rename |
| 128 | + but not every narrowing. Narrowing of *exercised* exports is caught by the |
| 129 | + downstream-contract typecheck. A signature-level snapshot is deferred until a |
| 130 | + narrowing actually slips both (evidence-gated, like ADR-0054's Phase 3). |
| 131 | + |
| 132 | +## Consequences |
| 133 | + |
| 134 | +**Positive.** |
| 135 | + |
| 136 | +- A backward-compat break for a lagging third party is caught at PR time (depth + |
| 137 | + breadth) or pre-publish (live), not after release. |
| 138 | +- The public surface of `@objectstack/spec` cannot change silently — every |
| 139 | + add/remove is recorded and reviewed. |
| 140 | +- One canonical authoring form, which is also the form the AI corpus teaches. |
| 141 | + |
| 142 | +**Costs / trade-offs.** |
| 143 | + |
| 144 | +- The fixtures and the snapshot must be maintained; the freeze discipline (don't |
| 145 | + edit-to-pass) is a review-culture requirement, not just a CI check. |
| 146 | +- The api-surface snapshot is large (~4 000 exports) and regenerates on any |
| 147 | + export change — intentional churn that forces acknowledgement. |
| 148 | +- The live hotcrm gate couples the Release workflow to one external repo; it is |
| 149 | + release-only and pinned to a tag (`HOTCRM_REF`) to bound that coupling, and a |
| 150 | + hotcrm-side break can be worked around by re-pinning. |
0 commit comments