|
| 1 | +# ADR-0060: Conformance Ledger as a Platform Pattern |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-21) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: ADR-0049 (enforce-or-remove / no unenforced declaration), ADR-0054 |
| 6 | +(runtime proof per enforced high-risk primitive), ADR-0056 D10 (Authorization |
| 7 | +Conformance Matrix), ADR-0058 D7 (Expression Surface Conformance ledger), ADR-0020 |
| 8 | +(state-machine converge-and-enforce) |
| 9 | +**Consumers**: verify, dogfood, spec, plugin-security, plugin-sharing, objectql, CI |
| 10 | +**References**: #1887 (declared-but-unwired sharing condition — the canonical failure) |
| 11 | + |
| 12 | +## TL;DR |
| 13 | + |
| 14 | +The platform has now hand-written the **same conformance ledger twice** — the |
| 15 | +ADR-0056 D10 Authorization Conformance Matrix and the ADR-0058 D7 Expression |
| 16 | +Surface ledger — to defend against the **declared-but-unenforced** failure class: |
| 17 | +a property that *looks* authorable but the runtime never wires, so it silently |
| 18 | +does nothing (#1887: a sharing `condition` the interpreter understands but no |
| 19 | +compiler lowered). Two instances with near-identical shape (`id` / `state` / |
| 20 | +`enforcement-site` / `proof` + a CI test that asserts completeness and that every |
| 21 | +proof exists) is the signal to **promote the ledger from a habit to a platform |
| 22 | +pattern**: one reusable ledger model + CI helper, the two existing instances |
| 23 | +refactored onto it, and a third instance for the **object validation-rule surface** |
| 24 | +(which now includes the ADR-0020-enforced `state_machine`). A declaration surface |
| 25 | +is **"landed" iff it has a conformance ledger whose CI ratchet is green** — no |
| 26 | +ledger, not landed. |
| 27 | + |
| 28 | +## Context |
| 29 | + |
| 30 | +### The recurring failure: declared-but-unenforced |
| 31 | + |
| 32 | +ObjectStack is metadata-driven and increasingly **AI-authored**. Its defining |
| 33 | +risk is not a crash — it is a primitive that is *declarable* but *inert*: |
| 34 | + |
| 35 | +- **#1887** — `SharingRuleSchema.condition` (CEL) was authorable and type-checked, |
| 36 | + but no compiler lowered it; authoring a rule granted nothing. Silent. |
| 37 | +- **ADR-0020 (pre)** — `state_machine` existed as three declaration shapes and |
| 38 | + **zero** runtime enforcement; a Flow could drive `status` straight to `closed`. |
| 39 | +- **ADR-0049** catalogues a long tail of `[EXPERIMENTAL — not enforced]` bits |
| 40 | + (transfer/restore/purge, masking, policy, …) that read as features but enforce |
| 41 | + nothing. |
| 42 | + |
| 43 | +AI authorship **amplifies** this class: an LLM is excellent at producing |
| 44 | +configuration that *looks* complete and terrible at noticing that nothing reads |
| 45 | +it. The defence that works is not "review harder" — it is a **machine-checked |
| 46 | +ledger** that, for every declarable property, forces the answer to "where is this |
| 47 | +enforced, and what proves it?", and **breaks the build** when a new property |
| 48 | +appears with no answer. |
| 49 | + |
| 50 | +### Two hand-written ledgers, one shape |
| 51 | + |
| 52 | +- **ADR-0056 D10** — `authz-conformance.matrix.ts`: `{ id, summary, state, enforcement?, proof?, note? }` + `authz-conformance.test.ts` asserting valid state, enforced-has-site, proof-file-exists. |
| 53 | +- **ADR-0058 D7** — `expression-conformance.ledger.ts`: the same core plus `{ dialect, mode, failPolicy, covers[] }` and a **ratchet** that re-discovers every `ExpressionInputSchema` field in `packages/spec/src` and fails if any is unclassified. |
| 54 | + |
| 55 | +They share a model and a test discipline; they were written twice. The second |
| 56 | +one's **ratchet** (discover the real surface from source, assert the ledger |
| 57 | +covers it) is the strictly stronger pattern and the one worth standardizing. |
| 58 | + |
| 59 | +## Decision |
| 60 | + |
| 61 | +Governing rules: ADR-0049 (a declaration is enforced / experimental / removed — |
| 62 | +never a silent fourth state), ADR-0054 (each enforced high-risk primitive carries |
| 63 | +a runtime proof). |
| 64 | + |
| 65 | +### D1 — One reusable ledger model + CI helper |
| 66 | + |
| 67 | +Define a single conformance model and an `assertLedger` helper: |
| 68 | + |
| 69 | + interface ConformanceRow { |
| 70 | + id: string; |
| 71 | + summary: string; |
| 72 | + surface?: string; // the declaration site this row classifies |
| 73 | + state: 'enforced' | 'experimental' | 'removed'; |
| 74 | + enforcement?: string; // runtime site — REQUIRED when enforced |
| 75 | + proof?: string; // repo-root-relative; file must exist |
| 76 | + covers?: string[]; // ratchet keys this row accounts for |
| 77 | + note?: string; // REQUIRED when experimental/removed |
| 78 | + meta?: Record<string, unknown>; // per-surface extras (dialect, mode, failPolicy, …) |
| 79 | + } |
| 80 | + |
| 81 | + assertLedger(rows, { |
| 82 | + proofRoot, // resolve `proof` against this |
| 83 | + discover?: () => Set<string>, // optional: the real surface, from source |
| 84 | + highRisk?: string[], // ids that MUST carry a proof |
| 85 | + }) |
| 86 | + |
| 87 | +`assertLedger` encodes the shared invariants once: unique ids, valid state, |
| 88 | +enforced-has-enforcement, experimental/removed-has-note, every `proof` exists, |
| 89 | +each surface covered by exactly one row, and — when `discover` is supplied — the |
| 90 | +**ratchet**: every discovered surface is `covers`-ed (else "classify it") and no |
| 91 | +`covers` is stale. Per-surface extras (dialect/mode/fail-policy) live in `meta`, |
| 92 | +so the model is universal without losing the richer expression-surface fields. |
| 93 | + |
| 94 | +### D2 — Refactor the two existing ledgers onto the model |
| 95 | + |
| 96 | +`authz-conformance` and `expression-conformance` keep their data and their |
| 97 | +domain-specific `discover`/`highRisk`, but delegate all structural assertions to |
| 98 | +`assertLedger`. This deletes the duplicated test logic and proves the model is |
| 99 | +genuinely shared (not a third bespoke shape). |
| 100 | + |
| 101 | +### D3 — Third instance: the object validation-rule surface |
| 102 | + |
| 103 | +Add a `validation-conformance` ledger over the `validations` union |
| 104 | +(`state_machine`, `cross_field`, `script`, `format`, `json_schema`, `conditional`, |
| 105 | +`unique`, `required`, …) with a `discover` that enumerates the rule `type` |
| 106 | +literals from `packages/spec/src/data/validation.zod.ts`. Each rule type is |
| 107 | +classified with its enforcement site and (for enforced) a proof. This **pins the |
| 108 | +ADR-0020 `state_machine` enforcement** (and the now-unblocked `cross_field` / |
| 109 | +`script`) into CI, and makes "a new validation rule type with no enforcement" — |
| 110 | +the exact pre-ADR-0020 `state_machine` disease — a build break. |
| 111 | + |
| 112 | +### D4 — Home the helper in `@objectstack/verify` |
| 113 | + |
| 114 | +The reusable model + `assertLedger` live in `@objectstack/verify` (a small |
| 115 | +`conformance` submodule). `verify` already owns "prove the app actually behaves" |
| 116 | +via its runtime harness; the static conformance ledger is its **compile-time |
| 117 | +complement** — both answer "is this primitive real?", one by booting, one by |
| 118 | +ledger. The per-surface ledgers + their `discover` functions stay where their |
| 119 | +proofs live (dogfood), importing the helper. |
| 120 | + |
| 121 | +### D5 — "Landed" is defined by the ledger |
| 122 | + |
| 123 | +A declaration surface is **landed iff it has a conformance ledger whose ratchet |
| 124 | +is green**. This becomes the platform's definition of done for any new authorable |
| 125 | +surface: you do not get to add an `ExpressionInputSchema`-style declaration family |
| 126 | +without a ledger row + (if enforced) a proof. ADR-0049's "enforce or remove" gains |
| 127 | +a third, mechanical leg: **ledger or it isn't landed.** |
| 128 | + |
| 129 | +### D6 — A single CI aggregate (optional, P3) |
| 130 | + |
| 131 | +A meta-test can import every registered ledger and assert each passes |
| 132 | +`assertLedger`, so "the conformance surface is whole" is one green check. Until |
| 133 | +then, each ledger's own test is the gate (as today). |
| 134 | + |
| 135 | +## Consequences |
| 136 | + |
| 137 | +Positive: the declared-but-unenforced defence becomes a **reusable platform |
| 138 | +capability** rather than copy-paste; adding a new declaration surface is "fill in |
| 139 | +a ledger", not "re-derive the discipline"; the ADR-0020 state-machine enforcement |
| 140 | +stops being implicit and becomes a CI-pinned, proof-backed row; AI authorship gets |
| 141 | +a uniform, machine-checked guardrail across surfaces. |
| 142 | + |
| 143 | +Negative / cost: refactoring the two existing ledgers carries migration risk |
| 144 | +(mitigated — same data, only the assertion layer moves, tests stay green); |
| 145 | +`@objectstack/verify` gains a static-analysis responsibility alongside its runtime |
| 146 | +one (kept in a separate submodule so the boot harness is untouched). |
| 147 | + |
| 148 | +Neutral / open: which additional surfaces get a ledger next (flows, UI actions, |
| 149 | +connectors) is evidence-gated, not mandated here — D5 sets the rule, P3 applies it |
| 150 | +where the declared-but-unenforced risk is highest. |
| 151 | + |
| 152 | +## Non-goals |
| 153 | + |
| 154 | +Not a change to any **runtime** semantics — purely the conformance/verification |
| 155 | +layer. Not a replacement for the `@objectstack/verify` runtime harness (it is the |
| 156 | +static complement). Not a mandate that every surface acquire a ledger immediately |
| 157 | +— D5 is the standard going forward; existing surfaces migrate as P3 reaches them. |
| 158 | + |
| 159 | +## Alternatives considered |
| 160 | + |
| 161 | +(a) **Keep hand-writing each ledger** — rejected: every new surface re-pays the |
| 162 | +discipline and drifts (the two existing ones already diverged in richness). |
| 163 | +(b) **One giant global ledger** for the whole platform — rejected: couples |
| 164 | +unrelated surfaces, loses the per-surface `discover` that makes the ratchet sharp. |
| 165 | +(c, chosen) **One reusable model + helper, one ledger instance per surface** — |
| 166 | +shared invariants, independent ratchets, additive. |
| 167 | + |
| 168 | +## Phasing |
| 169 | + |
| 170 | +- **P1** — D1 model + `assertLedger` in `@objectstack/verify`; D2 refactor the |
| 171 | + authz + expression ledgers onto it (tests stay green; proves reuse). |
| 172 | +- **P2** — D3 validation-rule-surface ledger + ratchet; classify every rule type, |
| 173 | + pin the ADR-0020 `state_machine` enforcement with a proof. |
| 174 | +- **P3** — D5 as the documented "landed" bar; D6 CI aggregate; extend to the next |
| 175 | + highest-risk surfaces (flow conditions, UI actions, connectors) as evidence warrants. |
| 176 | + |
| 177 | +## References |
| 178 | + |
| 179 | +ADRs 0020, 0049, 0054, 0056 (D10), 0058 (D7). Issue #1887. Existing instances: |
| 180 | +`packages/dogfood/test/authz-conformance.{matrix,test}.ts`, |
| 181 | +`packages/dogfood/test/expression-conformance.{ledger,test}.ts`. Helper home: |
| 182 | +`packages/verify/`. Target surface: `packages/spec/src/data/validation.zod.ts`. |
0 commit comments