Skip to content

Commit 38d98d8

Browse files
docs(adr): ADR-0005 states the five-part overlay key — bundle_member since 5677add (#23006) (#23037)
Fixes #23006 Clause-②: no ADR-0005's present-tense "What delivers overlay uniqueness today" block still spelled the overlay uniqueness key with four parts and quoted the declared index with four fields and the retired bare `unique: true`. Since `5677add93f` (PR #22954, #22877 ruling A) the runtime key and the declared index both carry a fifth part, `bundle_member`. This PR restates the current key from the tree and records the change as a dated amendment. Docs and ADR-anchor text only; nothing publishes. ## What changed `docs/adr/0005-metadata-customization-overlay.md` (898 → 908 lines, +19 / −9, net +10): - **Status line:** one entry appended in the record's existing format: `· **Amended** (2026-10-12, #22877 ruling A — …)`. It names the fifth part and what it is (a bundled type's member, such as an `email_template`'s locale). It also states that NULL is the canonical member, so name-only types are unchanged, and names the producer `5677add93f` (PR #22954). The entry goes on the existing line, so no line above the body moves. - **The #6771 measurement** ("a plain `DatabaseLoader` boot already stores `CREATE UNIQUE INDEX … (type, name, organization_id, package_id)` — the declared index, with the current key"): kept verbatim, because it records a measurement. A dated `2026-10-12 note` follows it, saying that key was the four-part key of its day and pointing to the status line and the two owners. - **Owner 1 (runtime, `overlay-index.ts`):** `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))` `WHERE state = 'active'`. One sentence gives the reason, taken from the module header: two members of one item are two rows. NULL is the canonical member and every row of a name-only type, and folding it to `''` constrains those rows exactly as the four-part key did. - **Owner 2 (declared, `sys-metadata.object.ts`):** the quotation now matches the tree: `fields: ['type', 'name', 'organization_id', 'package_id', 'bundle_member'], unique: 'global'`. A parenthetical notes that `'global'` is protocol 18's spelling of the bare `true` it retired (ADR-0120 D2). The trailing sentence about the coarse fallback is restated from measurement (see Acceptance notes ②). `scripts/adr-anchors/…overlay-index.ts.json` and `…sys-metadata.object.ts.json` change one key span each, from the four-part to the five-part spelling. This is a bounded in-place fix (Acceptance notes ③). The quoted code is taken from the tree at `edecd4485`: `OVERLAY_INDEX_COLUMNS` (`packages/metadata-protocol/src/migrations/overlay-index.ts:169`), `buildOverlayIndexSql` (same file), and the declared index (`packages/metadata-core/src/objects/sys-metadata.object.ts:250-254`). ## Gates (all on HEAD `a75f9f225`) I derived the gate list with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, passing no paths. It found 3 paths against merge base `edecd4485` and listed 27 commands. All 27 exit 0: `check-adr-links` (+ `--self-test`) · `check-adr-symbol-anchors` (+ `--self-test`) · `check-ci-filter-parity` · `check-closing-keyword-parity` (+ `--self-test`) · `check-comment-mask-corpus` · `check-scripts-symbol-anchors` (+ `--self-test`) · `@objectstack/lint check:doc-formula-expressions` · `check:adr-anchors` · `check:agent-test-spelling` · `check:bash32-floor` · `check:cli-command-ids` · `check:cross-package-test-inputs` · `check:doc-authoring` · `check:driver-memory-census` · `check:entry-guard` · `check:gitlink-declared` · `check:nul-bytes` · `check:parse-guard` · `check:pm-governed-merges` · `check:pm-prior-rulings` · `check:pnpm-filter-targets` · `check:refd-timer-probe` · `check:watch-hint-literal`. Key verdict lines: - `check-adr-anchors: OK (68 anchored file(s), every governing ADR still referenced; … 44138 citation(s) across 5846 file(s) resolve …)` - `check-adr-links: 730 relative link destination(s) under docs/adr/ resolve` - `check-nul-bytes: OK (scanned 11039 text file(s) …; no raw ASCII control bytes)` The first run of `check:doc-formula-expressions` exited 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` were unbuilt). I built them under the verify lock (`VERDICT command-exit 0`) and re-ran it: exit 0, `22 record-scoped formula example(s) … judged clean`. `dispatch-gates --ran` reconciliation: `27 derived famil(ies) accounted for — 27 run, 0 NOT-MEASURED (a DERIVED zero — all 27 recorded an exit code and none of them is 3)`. I also ran some roster gates outside the 27: - Exit 0: `check:overlay-whitelist-table`, `check-published-list-mirrors`, `check:dts-closure`, `check:engine-double-contract`, `check:i18n-stale-fill`. - NOT MEASURED: `check:published-readme-exports` exited 3 because it needs a full build; this diff touches no package and no README. - NOT MEASURED: `check:console-injection` skipped itself because there is no console dist. No changeset: `docs/adr/**` and repo-root `scripts/adr-anchors/**` publish nothing, so this PR takes `skip-changeset`. ## Acceptance notes 1. **Every present-tense four-part site, found by a full re-grep.** At `edecd4485` there were three, not the two the card names: the #6771 measurement (`:835`), Owner 1 (`:867`) and the Owner 2 quotation (`:878`, which the card does not name). After the edit, the only four-part spelling left in present-tense context is the preserved `:835` measurement, and the dated note now follows it. Every other four-part or older spelling is in dated historical blocks that this record keeps standing on purpose: - the Addendum (d) YAML: `project_id`, `scope`, `unique: true`, `partial`; - the 2026-05-22 amendment; - the 2026-08-09 amendment's statement that the decision "at most one active customization of a given `(type, name)` per organization" is unchanged. 2. **Owner 2's trailing sentence was false after `5677add93f` and is restated.** The old text read "it still prevents duplicate active overlays … and it stays NULL-distinct on `package_id`". The declared index is a plain UNIQUE over five columns. It is NULL-distinct on `bundle_member`, and that column is NULL for every canonical member, so the fallback no longer holds any canonical row unique. I measured this on an in-memory SQLite (better-sqlite3 12.11.1) with that exact five-column plain `CREATE UNIQUE INDEX`: - two identical packaged canonical rows (`bundle_member` NULL): both INSERTED; - two identical `zh-CN` member rows: the second REFUSED with `SQLITE_CONSTRAINT_UNIQUE`. The ADR now says "NULL-distinct on `package_id` and on `bundle_member` — NULL for every canonical member, so it holds no canonical row unique". The code comment beside the declaration (`sys-metadata.object.ts:244-249`, "it still prevents duplicate ACTIVE overlays") carries the same stale claim. It is outside this card's file surface and is not edited. I have not measured whether any real composition gets only the declared tier, so this stays a note, not a card. 3. **Bounded in-place fix: two ADR anchors.** Both `invariant` strings stated the current key in the present tense with four parts: - `overlay-index.ts.json`: "`(type, name, organization_id, COALESCE(package_id, ''))` `WHERE state = 'active'`"; - `sys-metadata.object.ts.json`: "the declared `idx_sys_metadata_overlay_active` on `(type, name, organization_id, package_id)`". Each now spells the five-part key, and nothing else in either string changed. All four conditions hold: - same defect class (a present-tense four-part key after `5677add93f`); - a mechanical one-span edit each; - both files declared in the claim's file surface; - `check:adr-anchors` is in the derived family, and it is green. 4. **The ADR line range another anchor cites did not move.** `scripts/adr-anchors/packages__spec__src__kernel__metadata-plugin.zod.ts.json` cites `docs/adr/0005-metadata-customization-overlay.md:53-64`. The status entry was appended to the existing line, so lines 53-64 are byte-identical between `edecd4485` and HEAD (`diff` exit 0). That anchor file is not touched. 5. **The same four-part statement in other places, not edited here** (other files or other governed surfaces; listed for whoever closes the family): - `docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md:669` - `docs/adr/0126-packaged-metadata-customization-model.md:313` - `skills/objectstack-data/rules/indexing.md:165` (the published skill's partial-index example quotes the platform's runtime SQL with four parts) - `docs/qa/platform-checklist/areas/cli.json:1125` (a QA step recreates "the declared NULL-distinct form" with four columns; the step still seeds the duplicates it needs, so this is drift only) 6. **Pre-existing, not introduced here.** Both indexes keep `organization_id` bare and NULL-distinct. The overlay-index module header already names this as "a separate question with its own ADR-0120 D4 ceremony". Environment rows are written with `organization_id` NULL (`sys-metadata-repository.ts`, the ADR-0131 D6 environment-rows read), so the DB-level key does not constrain those rows on either tier. This is a read-only observation, not filed. ## 维护者速读(草稿) **改了什么** - ADR-0005 状态行末尾追加一条 2026-10-12 修订:#22877 裁决 A,overlay 唯一键加入第五段 `bundle_member`。 - 「今天由谁保证 overlay 唯一」一节的两处现在时描述改为五段键,并附理由。 - 声明索引的引文与代码原文一致:五个字段,`unique: 'global'`。 - #6771 当时的实测原句保留,后面附一条带日期的注记。 - 两份 ADR 锚点 JSON 里的键写法同步为五段。 **为什么改** - 自 `5677add93f`(PR #22954)起,运行时唯一索引和声明索引都多了 `bundle_member`。例如同一封邮件模板的不同语言版本,现在各存一行。 - ADR 仍写四段键,按 ADR 理解唯一性的 agent 和作者会被带偏。 - 另一处随之失真:声明索引对 `bundle_member` 是 NULL-distinct,而规范成员该列为 NULL,所以这个兜底索引已不再约束任何规范成员行。原句「仍能防止重复的 active overlay」不再成立,本 PR 按实测改写(SQLite 实测见 Acceptance notes 第 2 条)。 **风险与代价(含回滚)** - 只改文档和锚点文字:不发布任何包,不改运行时行为。 - ADR-0005 净增 10 行(898 → 908)。 - 回滚:revert 本 PR 的单个提交即可。 **席位意见** **你要做的** - 审阅措辞,重点是声明索引那句新结论:「不再约束任何规范成员行」。 - 同意就给一个 APPROVED 评审,之后由席位落地;不同意就在 PR 上留言指出要改的句子。 --- _Generated by [Claude Code](https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2db2f8d commit 38d98d8

3 files changed

Lines changed: 21 additions & 11 deletions

‎docs/adr/0005-metadata-customization-overlay.md‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
> **Protocol 18 update (2026-10-12, ADR-0131 C5, #22340):** The per-type overlay opt-in on `MetadataTypeRegistryEntry` is spelled `allowEnvironmentOverlay` from protocol 18 (`packages/spec/src/kernel/metadata-plugin.zod.ts#allowEnvironmentOverlay`). [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D6 retired the per-organization overlay axis, so the key now names the one scope it governs — whether an environment may overlay an item of this type that a managed package ships. The old key `allowOrgOverride` stays on the registry entry schema only as a tombstone that refuses at parse with the new name in its prescription: no second key, no alias, no conversion. The lines below that name `allowOrgOverride` describe the field as it was spelled when they were written and are preserved verbatim.
66
77

8-
**Status**: Accepted (2026-05-16) · **Amended** (2026-05-22, see "Amendment: post-ADR-0006 v4 scope") · **Amended** (2026-04-13, branch concept removed — see [ADR-0008 §0](./0008-metadata-repository-and-change-log.md#0-2026-04-13-amendment--drop-project-and-branch-from-metaref)) · **Amended** (2026-08-09, #6825 — the Phase-1 overlay-index migration is deleted; see "Amendment (2026-08-09, #6825): overlay-index delivery after the Phase-1 migration was deleted") · **Amended** (2026-09-04, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D6 — the per-organization overlay axis is **retired for now**: `sys_metadata` carries no organization column, org-scoped writes of the five tier-A types are refused, and the environment layer is the whole ledger; the environment → code layered resolution and the "no overlay row = the registry" reading are unchanged) · **Amended** (2026-09-30, #20051 ruling 甲 — in "Addendum — 2026-05-16 (c)", which code cites as appendix (c), the persisted document is the **parsed body** once every round-trip key is declared, reversing the verbatim `request.item` save; a stored `view` row's round-trip keys are declared, and the storage switch itself, the ruling's last stage, is in force for `view` since PR #20868 (`9905e61ca2`) — see the amendment notes under that addendum's persisted-document bullet) · **Amended** (2026-09-30, #20051 ruling B — the parsed body a `view` stores is the parsed value of every key its request body carried, not the whole parse output: undeclared keys dropped, schema defaults not materialised, a moved key stored under its canonical spelling; every other type still stores its request body — see the second amendment note under that bullet)
8+
**Status**: Accepted (2026-05-16) · **Amended** (2026-05-22, see "Amendment: post-ADR-0006 v4 scope") · **Amended** (2026-04-13, branch concept removed — see [ADR-0008 §0](./0008-metadata-repository-and-change-log.md#0-2026-04-13-amendment--drop-project-and-branch-from-metaref)) · **Amended** (2026-08-09, #6825 — the Phase-1 overlay-index migration is deleted; see "Amendment (2026-08-09, #6825): overlay-index delivery after the Phase-1 migration was deleted") · **Amended** (2026-09-04, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D6 — the per-organization overlay axis is **retired for now**: `sys_metadata` carries no organization column, org-scoped writes of the five tier-A types are refused, and the environment layer is the whole ledger; the environment → code layered resolution and the "no overlay row = the registry" reading are unchanged) · **Amended** (2026-09-30, #20051 ruling 甲 — in "Addendum — 2026-05-16 (c)", which code cites as appendix (c), the persisted document is the **parsed body** once every round-trip key is declared, reversing the verbatim `request.item` save; a stored `view` row's round-trip keys are declared, and the storage switch itself, the ruling's last stage, is in force for `view` since PR #20868 (`9905e61ca2`) — see the amendment notes under that addendum's persisted-document bullet) · **Amended** (2026-09-30, #20051 ruling B — the parsed body a `view` stores is the parsed value of every key its request body carried, not the whole parse output: undeclared keys dropped, schema defaults not materialised, a moved key stored under its canonical spelling; every other type still stores its request body — see the second amendment note under that bullet) · **Amended** (2026-10-12, #22877 ruling A — the overlay uniqueness key gains a fifth part, `bundle_member`: the member of a bundled type, such as an `email_template`'s locale, so two members of one item are two overlay rows; NULL is the canonical member, so every row of a type whose identity is `name` alone is unchanged. In force since `5677add93f` (PR #22954) — see "What delivers overlay uniqueness today")
99
**Deciders**: ObjectStack Protocol Architects
1010
**Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (Package as first-class citizen), [ADR-0004](./0004-cloud-multi-kernel.md) (Cloud + per-project kernels)
1111
**Amended by**: [ADR-0006 v4](./0006-project-environment-split.v4.md) (drops `sys_project` entirely), [ADR-0008](./0008-metadata-repository-and-change-log.md) (re-expresses overlay as `LayeredRepository`; subsequently drops `project`/`branch` from `MetaRef`), [ADR-0029 D9](./0029-kernel-object-ownership-and-platform-objects-decomposition.md#amendment-2026-08-09-6853-a-tenant-overlay-of-an-object-is-its-own-contributor-layer-not-a-second-own) (for `object` only: the overlay is a registry contributor LAYER over the packaged owner, resolved as `base = overlay ?? own`, instead of a destructive in-place overwrite), [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) C5 / D6 (the per-organization overlay axis is retired and the per-type opt-in is spelled `allowEnvironmentOverlay` from protocol 18 — the note at the top of this record)
@@ -838,6 +838,10 @@ migration. The deleted producer could therefore only ever no-op (while reporting
838838
`status: 'created'`) or, in its one non-no-op window, install the retired key
839839
that nothing afterwards repaired.
840840

841+
> **2026-10-12 note** — "the current key" above is the four-part key #6771 measured.
842+
> Since `5677add93f` (#22877 ruling A) the declared index and the runtime key carry a
843+
> fifth part, `bundle_member` — see the status line and the two owners below.
844+
841845
### Two staleness items in the same block that PREDATE #6771
842846

843847
The YAML example there is stale for reasons of its own; attributing them to
@@ -864,22 +868,28 @@ The YAML example there is stale for reasons of its own; attributing them to
864868
(`packages/metadata-protocol/src/migrations/overlay-index.ts`, run at most once
865869
per protocol instance through its own `ensureOverlayIndex`) — the tier this ADR
866870
actually wants. Raw SQL, partial and NULL-safe:
867-
`(type, name, organization_id, COALESCE(package_id, ''))` `WHERE state = 'active'`,
871+
`(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))` `WHERE state = 'active'`,
868872
with a sibling `idx_sys_metadata_overlay_draft` so an active row and a draft row
869-
for one key can coexist. `COALESCE` is what constrains package-less (global)
870-
overlays, which a plain UNIQUE leaves unconstrained because SQL treats NULLs as
871-
distinct. On a dialect that cannot build the partial form it degrades to a
873+
for one key can coexist. The fifth part (since `5677add93f`, #22877 ruling A) is a
874+
bundled type's member — an `email_template`'s locale — so two members of one item
875+
are two rows; it is NULL for the canonical member and for every row of a name-only
876+
type. `COALESCE` is what constrains package-less (global) overlays and canonical
877+
members, which a plain UNIQUE leaves unconstrained because SQL treats NULLs as
878+
distinct; folded to `''`, those rows are constrained exactly as under the four-part
879+
key. On a dialect that cannot build the partial form it degrades to a
872880
**non-unique** composite index — deliberately, since an unrestricted UNIQUE
873881
would reject the active/draft coexistence — and reports that uniqueness is not
874882
enforced instead of claiming success. It builds under a throwaway name first and
875883
only then replaces the real one, so a failure keeps the previous index; every
876884
failure is reported in the ADR-0120 D4 shape and none blocks the boot.
877885
2. **The declaration in `metadata-core`'s `sys-metadata.object.ts`** —
878-
`{ name: 'idx_sys_metadata_overlay_active', fields: ['type', 'name', 'organization_id', 'package_id'], unique: true }`,
886+
`{ name: 'idx_sys_metadata_overlay_active', fields: ['type', 'name', 'organization_id', 'package_id', 'bundle_member'], unique: 'global' }`
887+
(`'global'` is protocol 18's spelling of the bare `true` it retired, ADR-0120 D2),
879888
materialized by `SqlDriver.syncDeclaredIndexes`. This is the coarse fallback a
880-
stack assembled without the runtime migration gets: it still prevents duplicate
881-
active overlays, at the cost of also colliding with archived and reset rows, and
882-
it stays NULL-distinct on `package_id`. It cannot express either refinement —
889+
stack assembled without the runtime migration gets: unrestricted, so it also
890+
collides with archived and reset rows, and NULL-distinct on `package_id` and on
891+
`bundle_member` — NULL for every canonical member, so it holds no canonical row
892+
unique. It cannot express either refinement —
883893
knex's `table.unique()` has no `WHERE` and no `COALESCE` — which is why the
884894
runtime tier exists rather than a richer declaration.
885895

‎scripts/adr-anchors/packages__metadata-core__src__objects__sys-metadata.object.ts.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"adrs": [
44
"ADR-0005"
55
],
6-
"invariant": "ADR-0005 overlay uniqueness (amended 2026-08-09, #6825) — the declared `idx_sys_metadata_overlay_active` on `(type, name, organization_id, package_id)` is the COARSE fallback a stack assembled without `metadata-protocol`'s runtime migration gets: unrestricted and NULL-distinct, so it also collides with archived/reset rows. That is deliberate, not an oversight to fix here — knex's `table.unique()`, which `SqlDriver.syncDeclaredIndexes` builds through, can express neither `WHERE state = 'active'` nor `COALESCE(package_id, '')`, which is why the runtime tier exists. Do NOT re-add a `partial:` key (retired at protocol 17, #5248 / #4943, ADR-0049 — no driver ever emitted the predicate) and do NOT put `environment_id` or `scope` back in the discriminator: `environment_id` is retired and NULL on new rows, `package_id` is in the key under ADR-0048 so two packages shipping the same (type, name) each keep their own overlay row."
6+
"invariant": "ADR-0005 overlay uniqueness (amended 2026-08-09, #6825) — the declared `idx_sys_metadata_overlay_active` on `(type, name, organization_id, package_id, bundle_member)` is the COARSE fallback a stack assembled without `metadata-protocol`'s runtime migration gets: unrestricted and NULL-distinct, so it also collides with archived/reset rows. That is deliberate, not an oversight to fix here — knex's `table.unique()`, which `SqlDriver.syncDeclaredIndexes` builds through, can express neither `WHERE state = 'active'` nor `COALESCE(package_id, '')`, which is why the runtime tier exists. Do NOT re-add a `partial:` key (retired at protocol 17, #5248 / #4943, ADR-0049 — no driver ever emitted the predicate) and do NOT put `environment_id` or `scope` back in the discriminator: `environment_id` is retired and NULL on new rows, `package_id` is in the key under ADR-0048 so two packages shipping the same (type, name) each keep their own overlay row."
77
}

‎scripts/adr-anchors/packages__metadata-protocol__src__migrations__overlay-index.ts.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"adrs": [
44
"ADR-0005"
55
],
6-
"invariant": "ADR-0005 overlay uniqueness (amended 2026-08-09, #6825) — `ensureMetadataOverlayIndexes` is the FINE tier that actually delivers the decision: `(type, name, organization_id, COALESCE(package_id, ''))` `WHERE state = 'active'`, plus the `_draft` sibling. Three properties look improvable from inside this file alone and are each a recorded decision: (a) the fallback on dialects without partial indexes stays NON-unique, because an unrestricted UNIQUE would reject the active+draft coexistence the two indexes exist to allow; (b) `idx_sys_metadata_overlay_active` deliberately reuses the name `metadata-core`'s `sys-metadata.object.ts` declares, since `SqlDriver.syncDeclaredIndexes` skips by name and that is what stops the coarser declared form from being re-imposed; (c) it builds under a probe name before replacing the real one, and every failure keeps the previous index and is reported per ADR-0120 D4 without blocking the boot. #6771 deleted `@objectstack/metadata`'s second, stale-keyed producer of this same index name — do not grow a third."
6+
"invariant": "ADR-0005 overlay uniqueness (amended 2026-08-09, #6825) — `ensureMetadataOverlayIndexes` is the FINE tier that actually delivers the decision: `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))` `WHERE state = 'active'`, plus the `_draft` sibling. Three properties look improvable from inside this file alone and are each a recorded decision: (a) the fallback on dialects without partial indexes stays NON-unique, because an unrestricted UNIQUE would reject the active+draft coexistence the two indexes exist to allow; (b) `idx_sys_metadata_overlay_active` deliberately reuses the name `metadata-core`'s `sys-metadata.object.ts` declares, since `SqlDriver.syncDeclaredIndexes` skips by name and that is what stops the coarser declared form from being re-imposed; (c) it builds under a probe name before replacing the real one, and every failure keeps the previous index and is reported per ADR-0120 D4 without blocking the boot. #6771 deleted `@objectstack/metadata`'s second, stale-keyed producer of this same index name — do not grow a third."
77
}

0 commit comments

Comments
 (0)