Skip to content

Commit 4557a17

Browse files
xuyushun441-sysos-zhuangclaude
authored
docs(adr): ADR-0059 — third-party backward-compatibility validation gates (#2035) (#2098)
Documents the layered strategy built across #2088/#2089/#2092/#2093/#2095/#2097 in one authoritative place: why in-repo consumers can't witness backward compat (they co-evolve with the spec), the six gates and each one's job, and — crucially — the FREEZE CONTRACT: a change that requires editing the fixtures or removing a snapshot entry is by definition breaking (bump major), never a mechanical test-update. Also formalizes `objectstack validate` as the third party's authoritative self-gate. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b871185 commit 4557a17

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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

Comments
 (0)