From ac278e60135cf41c88cedba25f73981fa6ec0b44 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:56:15 +0000 Subject: [PATCH] =?UTF-8?q?docs(adr),chore(devx):=20tombstone=20ADR-0001?= =?UTF-8?q?=20=E2=80=94=20retire=20the=20number,=20empty=20the=20citation?= =?UTF-8?q?=20allowlist=20(#7866)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0001 sat on the weaker of two remedies: an `UNRESOLVED_ADR_CITATIONS` entry in `check-adr-anchors.mjs`, which says "this citation resolves to nothing, and that is knowingly tolerated". The gate's own prose prefers a tombstone record, and since #6676 there is a working template for one. This lands that upgrade, and with it the allowlist becomes empty. Both halves are in one commit because the gate refuses either order: * tombstone first, entry kept -> red, "the UNRESOLVED_ADR_CITATIONS entry for 0001 is stale — docs/adr/ now HAS a record" * entry removed, no tombstone -> red, "ADR-0001 is cited by 3 file(s) but names no record under docs/adr/" Both measured on this branch before writing the final state. The record that held the number was `0001-metadata-service-architecture.md`, merged 2026-02-10 (`908d95c82`) and deleted 2026-02-11 (`9da8e3e72`). The deletion was NOT a withdrawal on the merits, which is the substantive difference from ADR-0107: it was collateral in a 37-path documentation sweep that removed the whole `docs/adr/` registry, README and all, under a subject about permission-protocol analysis, with an empty body and no mention of any ADR. The tombstone reconstructs that from the tree rather than quoting a withdrawal that was never written. The record must not be resurrected either way: its selected option was a hybrid dual-provider architecture in which ObjectQL registers `metadata` as a fallback, and the code has since gone single-provider — the one `registerService('metadata', ...)` in the tree is in `packages/metadata/src/plugin.ts`, while `packages/objectql/src/plugin.ts` registers `objectql`, `data`, `manifest` and `lifecycle` only. Corrects a claim the removed allowlist entry carried. It said the number was "cited as history by ADR-0002". ADR-0002 does cite `ADR-0001`, but for "one global DB + tenant column ... already discarded in v3.4's ADR-0001" — a tenancy decision, not the metadata-service one this number held, and not a document that ever existed in this repository. Verified against full history with the clone unshallowed: nothing matching `*0001-*` was ever added under any path, on any branch, other than the record above. The tombstone dissects the discrepancy; ADR-0002's own wording is left alone, being an accepted record. `--self-test` gains the live-tree pin the card asks for, mirroring #7838's 0107 pin: 0001 must land in both `records` (citations resolve) and `nonDecisions` (anchors refused). Pinned by name for 0001 and 0107, plus a structural sweep so a future `NNNN-withdrawn-*.md` inherits the rule without editing the gate, and a non-empty guard so that sweep cannot pass by reading nothing. 70 -> 74 assertions. --- ...withdrawn-metadata-service-architecture.md | 149 ++++++++++++++++++ scripts/check-adr-anchors.mjs | 148 +++++++++++------ 2 files changed, 248 insertions(+), 49 deletions(-) create mode 100644 docs/adr/0001-withdrawn-metadata-service-architecture.md diff --git a/docs/adr/0001-withdrawn-metadata-service-architecture.md b/docs/adr/0001-withdrawn-metadata-service-architecture.md new file mode 100644 index 0000000000..2d1a410137 --- /dev/null +++ b/docs/adr/0001-withdrawn-metadata-service-architecture.md @@ -0,0 +1,149 @@ +# ADR-0001: Withdrawn — this number is retired and must not be reused + +**Status**: **Withdrawn (2026-02-11)**. This file is a *tombstone*, not a decision: nothing in it is in force, and the number must never be reassigned. +**Deciders**: ObjectStack Protocol Architects (recorded retroactively — see [How this differs from ADR-0107](#how-this-differs-from-adr-0107)) +**The record that held this number**: *"Metadata Service Architecture"* — `docs/adr/0001-metadata-service-architecture.md`, Status *Accepted (2026-02-10)* +**Landed by**: `908d95c82`, 2026-02-10 09:14 UTC +**Deleted by**: `9da8e3e72`, 2026-02-11 23:35 +0800, about thirty hours later — a 37-path documentation sweep, not an ADR decision +**Tracking**: [#7329](https://github.com/objectstack-ai/objectstack/issues/7329) (the citation-resolution work that named this case), [#7866](https://github.com/objectstack-ai/objectstack/issues/7866) (this tombstone), [#6634](https://github.com/objectstack-ai/objectstack/issues/6634) (the squat failure mode both are written against) +**Consumers**: none. No code is governed by this number, and none may be — see [Do not anchor to this number](#do-not-anchor-to-this-number). + +--- + +## TL;DR + +A real record occupied ADR-0001 on `main` for about thirty hours in February 2026 and +was then deleted. Unlike [ADR-0107](./0107-withdrawn-hook-body-write-set-static-gap.md), +it was **not withdrawn on the merits** — it was swept away as collateral in a bulk +documentation cleanup that took the entire `docs/adr/` registry with it, including its +`README.md` and the ADR-0002 of that era. + +This file exists so the number resolves to that explanation instead of to nothing, and so +it is never handed to an unrelated decision. Reassigning it would retroactively re-point +every historical "ADR-0001" at a document its author never meant — the squat failure mode +[#6634](https://github.com/objectstack-ai/objectstack/issues/6634) was filed for, where +one number had silently accumulated 77 citations it did not resolve. + +**A new record takes the next free number. Not this one.** + +## What happened + +| When | What | Evidence | +|---|---|---| +| 2026-02-10 09:14 UTC | `docs/adr/0001-metadata-service-architecture.md` written and merged, Status *Accepted*, authored by an automated agent | `908d95c82` | +| 2026-02-11 23:35 +0800 | Deleted, together with `docs/adr/0002-database-driven-metadata-storage.md` and `docs/adr/README.md`, inside a 37-path docs sweep | `9da8e3e72` | +| 2026-08-08 | The bare number is grandfathered onto `check-adr-anchors`'s citation allowlist, pending this tombstone | [#6634](https://github.com/objectstack-ai/objectstack/issues/6634) | +| 2026-08-11 | [PR #7838](https://github.com/objectstack-ai/objectstack/pull/7838) makes a tombstone refuse *anchors* while still resolving *citations*, and leaves this number open because it needs a file under `docs/adr/` | [#7329](https://github.com/objectstack-ai/objectstack/issues/7329) | + +The deleting commit's subject is *"feat(docs): add comprehensive analysis of Permission +Protocol with AI-enhanced security controls and RLS implementation"*; its body is empty +and it names no ADR. Alongside the registry it removed the whole `docs/METADATA_*` +documentation family and the `examples/metadata-objectql` package. **No reasoning for +retiring the decision was recorded anywhere**, which is the substantive difference from +ADR-0107 and the reason this tombstone reconstructs the record from the tree rather than +quoting a withdrawal. + +## Do not resurrect it + +Independently of how it was deleted, the record's substance is now **contradicted by +shipped code**, so restoring the text would plant a false statement in the decision log. + +Its selected option was a **hybrid dual-provider** architecture: *both* `@objectstack/objectql` +and `@objectstack/metadata` may provide the `metadata` service, MetadataPlugin taking +precedence when loaded and **ObjectQL registering itself as the fallback provider** when +it is not. That fallback is exactly what the code stopped doing: + +- **MetadataPlugin is the sole provider** of the `metadata` service — the one + `registerService('metadata', …)` in the tree is + [`packages/metadata/src/plugin.ts`](../../packages/metadata/src/plugin.ts). +- **ObjectQL is a consumer, never a provider.** + [`packages/objectql/src/plugin.ts`](../../packages/objectql/src/plugin.ts) registers + `objectql`, `data`, `manifest` and `lifecycle` — and no longer `metadata`. It reads the + `metadata` service and degrades to its own internal registry when none is present, + which is not the same thing as claiming the slot. +- **The shared-interface principle survived, as a spec contract**: `IMetadataService` in + [`packages/spec/src/contracts/metadata-service.ts`](../../packages/spec/src/contracts/metadata-service.ts). + +The live account of that surface is the "Metadata service architecture" section of +[`ARCHITECTURE.md`](../../ARCHITECTURE.md). Read that, never this file, for what is true +today. The withdrawn text is recoverable in full at +`git show 908d95c82:docs/adr/0001-metadata-service-architecture.md` and is deliberately +kept in history rather than reprinted here — a withdrawn record reprinted inside its own +tombstone reads as a record. + +Note that the single-provider architecture which replaced it **has no ADR record of its +own**. That gap is real and is not closed by this file; re-homing it is a maintainer call. + +## What ADR-0002 actually cites + +Recorded because it is the first thing a reader arriving from ADR-0002 will want, and +because it was mis-stated in `check-adr-anchors.mjs` until this tombstone was written. + +[`0002-environment-database-isolation.md`](./0002-environment-database-isolation.md) says, +of a rejected alternative: *"One global DB + tenant column. Was never on the table — +already discarded in v3.4's ADR-0001."* That is **not** a reference to the record above. +The record that held this number decided how the `metadata` *service* is registered and +says nothing about tenancy or database topology. + +Two distinct things carry the string "ADR-0001" in this repository's past, and only one +of them was ever a file here: + +- **The v3.4-era "ADR-0001" ADR-0002 is pointing at.** Nothing matching `*0001-*` was ever + added under any path, on any branch, other than the metadata-service record — see + [Archaeology](#archaeology). Today's ADR-0002 is itself dated 2026-04-19 and supersedes + the v3.4/v4.0 per-organization database model, so its "v3.4's ADR-0001" is a reference to + a pre-registry document that this repository has never contained. +- **`docs/adr/0001-metadata-service-architecture.md`**, the record this tombstone retires. + +The citation is therefore historical narration rather than a pointer into `docs/adr/`, and +this file is what it now resolves to. Correcting ADR-0002's wording is an edit to an +accepted record and is deliberately not made here. + +## Do not anchor to this number + +`scripts/check-adr-anchors.mjs` requires every `ADR-NNNN` cited in a tracked file to name a +record under `docs/adr/`. This tombstone satisfies that check — deliberately, because the +citations below are legitimate references to a deleted record. It is **not** a licence to +cite ADR-0001 as governing anything: an anchor entry must state the invariant its ADR +decided, and this number decides nothing today. + +The citations that exist are all discussion of the deletion itself: + +- [`ARCHITECTURE.md`](../../ARCHITECTURE.md) — names the deleted path as plain text and + states the current single-provider architecture in its place; +- [`0002-environment-database-isolation.md`](./0002-environment-database-isolation.md) — + the v3.4 reference dissected above; +- `scripts/check-adr-anchors.mjs` — the gate, describing this case. + +## Archaeology + +Recorded so the next reader does not repeat it. Run against full history with the clone +**unshallowed** — a shallow clone silently answers "never existed", and the default clone +in this project's agent containers is 50 commits deep: + +```bash +git fetch --unshallow +git log --all --diff-filter=AD -- 'docs/adr/0001*' # -> exactly 2 commits, both above +git log --all --diff-filter=AD -- '*0001-*' # -> the same 2 commits, any path +git log --all --oneline -S'ADR-0001' # -> 10 commits, all accounted for +``` + +**Nothing else ever claimed this number**, on any branch, at any time: the second query +widens the first from `docs/adr/` to the whole tree and returns the same two commits, so +there is no second era of "ADR-0001" as a file and no lost content beyond the record named +above. + +## How this differs from ADR-0107 + +Both are tombstones and both are unreusable, but the two cases are not the same and the +distinction is worth keeping: + +| | ADR-0107 | ADR-0001 | +|---|---|---| +| Why it left | Owner decision, reasoning stated in the withdrawing commit | Collateral in an unrelated 37-path docs sweep; no reasoning recorded | +| Lifetime | Nine hours | About thirty hours | +| Deciders line | The withdrawal was itself the decision | Reconstructed from the tree in 2026-08; nobody decided the number should retire at the time | +| Why not resurrect | Substance reversed by later shipped code | Same — the fallback-provider half is contradicted by the code today | + +The shared conclusion is the one that matters: an ADR number that has ever named a record +does not become free again by that record's removal, however the removal happened. diff --git a/scripts/check-adr-anchors.mjs b/scripts/check-adr-anchors.mjs index 7d3d5b7707..396899e4af 100644 --- a/scripts/check-adr-anchors.mjs +++ b/scripts/check-adr-anchors.mjs @@ -96,18 +96,19 @@ // the `.vN` version rule above and unlike an allowlist — an author who cites // a sibling repo has a spelling that is both correct to a reader and clean // to the gate. Bare `ADR-0001`, meaning ours, still fails. -// - **A record that was withdrawn or deleted, cited as history.** ADR-0001's -// record was deleted in the 2026-02-11 permission-protocol rewrite and is -// cited as history by ADR-0002. Those numbers sit on -// `UNRESOLVED_ADR_CITATIONS` below — an explicit, shrink-only allowlist, -// audited in both directions like the collision list. +// - **A record that was withdrawn or deleted, cited as history.** Such numbers +// may sit on `UNRESOLVED_ADR_CITATIONS` below — an explicit, shrink-only +// allowlist, audited in both directions like the collision list. // // It is the WEAKER of the two remedies, and the list says so: preferred is -// to give the number a record, even when the record is a tombstone. ADR-0107 -// left this list that way (#6676) — withdrawn nine hours after it landed -// (#3735), it now has `docs/adr/0107-withdrawn-*.md` saying so, which both -// resolves the historical citations and makes re-use collide loudly under -// the number-uniqueness audit instead of merely going stale here. +// to give the number a record, even when the record is a tombstone. Both +// numbers that list ever held have now left it that way, and it is empty: +// ADR-0107 (#6676), withdrawn nine hours after it landed (#3735), and +// ADR-0001 (#7866), whose record was swept away with the rest of the +// `docs/adr/` registry on 2026-02-11. Each now has a +// `docs/adr/NNNN-withdrawn-*.md` saying so, which both resolves the +// historical citations and makes re-use collide loudly under the +// number-uniqueness audit instead of merely going stale here. // // ## The fourth thing it checks: a tombstone is NOT anchorable (#7329) // @@ -268,34 +269,44 @@ const CROSS_REPO_QUALIFIERS = new Set(['objectui', 'object-ui', 'cloud']); * directions: an entry whose number gains a record, or that nothing cites any * more, fails as stale — so a number cannot be quietly re-used under cover of * its own grandfather clause, which is the squatting half of #6634. + * + * **The list is EMPTY today (#7866), and that is the finished state, not an + * oversight.** Both entries it ever held left the same way — the number gained a + * tombstone, which is remedy (a) of the dangling-citation message and strictly + * stronger than this list. Keep it empty: an empty allowlist is the only + * configuration in which every cited ADR number resolves to something a reader + * can open. + * + * One consequence to know before reading the `--self-test` output: while the list + * is empty, its ablation assertions ("drop the allowlist, each entry's number must + * surface as dangling") are VACUOUSLY green — zero entries, zero expected + * findings. They are still correct, and they regain their teeth the moment an + * entry is added, which is the only time they have anything to say. The live + * assertions that do carry weight while the list is empty are the tombstone pins + * further down, which check the two numbers by name. */ const UNRESOLVED_ADR_CITATIONS = [ - { - number: '0001', - // Deleted 2026-02-11 (9da8e3e72) together with 0002-database-driven-metadata- - // storage.md and docs/adr/README.md, in the permission-protocol rewrite. - // Cited as history by `docs/adr/0002-...md` ("already discarded in v3.4's - // ADR-0001"), which is what keeps this entry earning its place. - // ARCHITECTURE.md used to carry a markdown LINK to the deleted path — a - // genuinely broken pointer rather than history, filed separately from #6634 - // and fixed in #6733: that section now names the deleted path as plain text - // and states the current single-provider architecture (MetadataPlugin is the - // sole `metadata` provider; ObjectQL consumes it) instead of pointing at a - // 404. This entry never blessed that link and does not bless any future one. - why: 'record deleted 2026-02-11 (9da8e3e72); cited as history by ADR-0002', - }, - // 0107 was the second entry until #6676. It is gone from this list because the - // number gained a record — `docs/adr/0107-withdrawn-hook-body-write-set-static- - // gap.md`, a tombstone. That is the (a) remedy the dangling-citation message - // recommends over this list, and it is strictly stronger here: an allowlist - // entry survives only as long as something still cites the number (the - // direction-B staleness rule below), and 0107's citations are a changeset + // 0107 was the first entry to leave (#6676), and 0001 the second and last + // (#7866) — both to `docs/adr/NNNN-withdrawn-*.md` tombstones. + // + // Why a tombstone beats an entry here, in the words of the 0107 case: an + // allowlist entry survives only as long as something still cites the number + // (the direction-B staleness rule below), and 0107's citations are a changeset // awaiting release plus an audit, so the grandfather clause would have expired // on its own and quietly re-freed the number. A record does not expire, and a // re-use now collides in `auditAdrDirectory` — loud, and about the right fact. - // The one thing the tombstone bought that this list never could — resolving the + // The one thing a tombstone bought that this list never could — resolving the // citations — is also the one thing it must NOT buy for anchors, which is why // `nonDecisions` exists (#7329). + // + // The 0001 entry additionally carried a claim that turned out to be wrong, and + // it is corrected rather than deleted because it is the reason the entry looked + // load-bearing: it said the number was "cited as history by ADR-0002". ADR-0002 + // does cite `ADR-0001` — but for "one global DB + tenant column … already + // discarded in v3.4's ADR-0001", which is not what the record under this number + // decided (it decided how the `metadata` SERVICE is registered) and not a + // document that ever existed here. The tombstone dissects that; this comment + // exists so nobody re-derives the discrepancy from the removed entry's text. ]; /** @@ -1095,23 +1106,70 @@ function selfTest() { ); } - // ── Tombstones on the real tree (#7329) ─────────────────────────────── + // ── Tombstones on the real tree (#7329, #7866) ──────────────────────── + // + // The live half of the synthetic assertions above, over the corpus's actual + // tombstones — and the shipped registry must be clean under the rule, since + // a green build here is what says the rule costs nothing to keep. // - // The live half of the synthetic assertions above: ADR-0107 is the corpus's - // only tombstone today, and the shipped registry must be clean under the - // new rule — a green build here is what says the rule costs nothing to keep. + // The two numbers are pinned BY NAME because each one is a migration this + // gate is meant never to lose: 0107 (#6676) and 0001 (#7866) both left + // `UNRESOLVED_ADR_CITATIONS` for a tombstone, and a regression that + // un-flagged either would re-open the number silently. The structural sweep + // that follows then covers every tombstone, including ones written later — + // so a future `NNNN-withdrawn-*.md` inherits the rule without editing this + // file, which is the property the filename marker was chosen for. { const { records: liveRecs, nonDecisions: liveTombs } = audit(liveFiles, KNOWN_NUMBER_COLLISIONS); + + // The both-directions pin, per known tombstone: in `records` so the + // citations it was written to resolve keep resolving, and in + // `nonDecisions` so no anchor may name it. The two sets deliberately + // disagree about the same file; asserting only one of them is how this + // gate read GREEN on the #7329 gap. + for (const n of ['0001', '0107']) { + assert( + `live-tombstone-${n}-is-flagged`, + liveTombs.has(n), + `docs/adr/${n}-withdrawn-*.md is a tombstone; got {${[...liveTombs].join(',')}}`, + ); + assert( + `live-tombstone-${n}-still-resolves-citations`, + liveRecs.has(n), + 'flagging the tombstone must not un-resolve the citations it was written to resolve', + ); + // Ablation, predicted RED: an anchor to the tombstone must be refused. + // This is the exact probe run on `main` @ `69fde55` for 0107, where it + // came back GREEN. + assert( + `ablation-anchoring-the-live-tombstone-${n}-is-red`, + anchorIdProblem('ADR-' + n, liveRecs, liveTombs) !== null, + 'the #7329 gap is open again — a shard citing the tombstone would pass', + ); + } + + // Structural sweep: whatever the tombstone set turns out to be, every + // member holds both halves. Catches a tombstone added later without a + // named pin above. + const halfFlagged = [...liveTombs].filter( + (n) => !liveRecs.has(n) || anchorIdProblem('ADR-' + n, liveRecs, liveTombs) === null, + ); assert( - 'live-tombstone-is-flagged', - liveTombs.has('0107'), - `docs/adr/0107-withdrawn-*.md is the corpus's tombstone; got {${[...liveTombs].join(',')}}`, + 'every-live-tombstone-resolves-and-refuses-anchors', + halfFlagged.length === 0, + `a tombstone must both resolve citations and refuse anchors; these do not: {${halfFlagged.join(',')}}`, ); + + // The set is non-empty, so the sweep above is not vacuously green. It is + // the standing guard on this whole block: if `nonDecisions` ever comes + // back empty — a broken marker, a renamed file — every assertion that + // iterates it would pass by reading nothing. assert( - 'live-tombstone-still-resolves-citations', - liveRecs.has('0107'), - 'flagging the tombstone must not un-resolve the citations it was written to resolve', + 'live-tombstone-set-is-not-empty', + liveTombs.size >= 2, + `expected at least the two known tombstones {0001,0107}, got {${[...liveTombs].join(',')}}`, ); + const anchored = (anchors ?? []).flatMap((entry) => (entry?.adrs ?? []).map((adr) => `${entry.file} → ${adr}`)); const disarmed = anchored.filter((hit) => anchorIdProblem(hit.split(' → ')[1], liveRecs, liveTombs) !== null); assert( @@ -1119,14 +1177,6 @@ function selfTest() { disarmed.length === 0, `no shipped anchor may name a tombstone, got:\n ${disarmed.join('\n ')}`, ); - - // Ablation, predicted RED: an anchor to the tombstone must be refused. This - // is the exact probe run on `main` @ `69fde55`, where it came back GREEN. - assert( - 'ablation-anchoring-the-live-tombstone-is-red', - anchorIdProblem('ADR-' + '0107', liveRecs, liveTombs) !== null, - 'the #7329 gap is open again — a shard citing the tombstone would pass', - ); } // ── The citation scan, over the real tree (#6634) ─────────────────────