diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a381e1a1d74..145b26d95d9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -601,6 +601,21 @@ jobs: continue-on-error: true run: pnpm check:adr-anchors + # #26076 ADR index, REPORT-ONLY by the maintainer's ruling (门禁不加). + # docs/adr/README.md is generated by scripts/adr-index.mjs from the + # records' one `**Status**:` line; the alias runs the generator's + # `--self-test` (fixtures only — it never asserts on what the records + # say) and then `--report`, which prints a `::warning::` for a stale + # index or a record whose status line is missing or unreadable and exits + # 0 whatever it finds. So the only red this step can show is a broken + # generator, never a record. `pnpm adr:index` regenerates; `--check` is + # the gate's spelling for the day a maintainer names one. Alias form per + # the GATE INVOCATION IDIOM note at the top of this file. + - name: ADR index is current (report-only — warnings, never a red on a record) + id: adr-index-current-report-only + continue-on-error: true + run: pnpm check:adr-index + # @objectstack/verify stand-in erasure guard (#6399). The third and # narrowest member of the slot-lookup and query-options erasure ratchets # (in the scoped lanes since #22991). `checkReadCoercion` / diff --git a/docs/adr/0006-project-environment-split.md b/docs/adr/0006-project-environment-split.md index a695f415d55..40554b7bcb8 100644 --- a/docs/adr/0006-project-environment-split.md +++ b/docs/adr/0006-project-environment-split.md @@ -1,6 +1,7 @@ # ADR-0006: Environment & Project as Independent Siblings — v3 **Status**: Superseded by v4 (`0006-project-environment-split.v4.md`) — 2026-05-20 +**Versions of this number**: [v2](./0006-project-environment-split.v2.md) · [v3](./0006-project-environment-split.md) · [v4, current](./0006-project-environment-split.v4.md) — one decision revised twice; the number is shared only by its own revisions (AGENTS.md Prime Directive 13). A version is cited as `ADR-0006 v4` in prose or, stem-qualified, as `ADR-0006-project-environment-split.v4`. **Date**: 2026-05-20 (v3) **Deciders**: ObjectStack Protocol Architects **Supersedes**: v1 (strict tree), v2 (siblings + sys_deployment join) diff --git a/docs/adr/0006-project-environment-split.v2.md b/docs/adr/0006-project-environment-split.v2.md index 46b20c1a038..30c3eb42c2a 100644 --- a/docs/adr/0006-project-environment-split.v2.md +++ b/docs/adr/0006-project-environment-split.v2.md @@ -1,6 +1,7 @@ # ADR-0006: Three-Layer Tenancy — Organization, Project, Environment **Status**: Superseded by v4 (`0006-project-environment-split.v4.md`) — 2026-05-20 +**Versions of this number**: [v2](./0006-project-environment-split.v2.md) · [v3](./0006-project-environment-split.md) · [v4, current](./0006-project-environment-split.v4.md) — one decision revised twice; the number is shared only by its own revisions (AGENTS.md Prime Directive 13). A version is cited as `ADR-0006 v4` in prose or, stem-qualified, as `ADR-0006-project-environment-split.v4`. **Date**: 2026-05-20 (v1) / 2026-05-20 (v2 — same day revision) **Deciders**: ObjectStack Protocol Architects **Builds on**: ADR-0002 (Environment-Per-Database Isolation), ADR-0003 (Package as First-Class Citizen), ADR-0005 (Metadata Customization Overlay) diff --git a/docs/adr/0006-project-environment-split.v4.md b/docs/adr/0006-project-environment-split.v4.md index 1b4bb0be715..3ea2da5b58f 100644 --- a/docs/adr/0006-project-environment-split.v4.md +++ b/docs/adr/0006-project-environment-split.v4.md @@ -1,6 +1,7 @@ # ADR-0006: Environment & Project — v4 (drop dev-workspace Project, unify on Package) **Status**: Accepted (v4 — supersedes v3) — API-surface vocabulary boundary recorded 2026-08-27 (#12473): the v5.0 `project` → `environment` rename stops at the CLI's user-facing vocabulary; three API surfaces keep `project` deliberately (see the addendum). **D1 retired by execution (2026-08-28, #12867 — see the second addendum):** D2 fired on the maintainer's 2026-08-28 ruling; all three of those surfaces are renamed to `environment` / `environments` with no aliases, authored and parked for one coordinated release. Read the first addendum's D1 as history — the boundary it draws no longer exists. +**Versions of this number**: [v2](./0006-project-environment-split.v2.md) · [v3](./0006-project-environment-split.md) · [v4, current](./0006-project-environment-split.v4.md) — one decision revised twice; the number is shared only by its own revisions (AGENTS.md Prime Directive 13). A version is cited as `ADR-0006 v4` in prose or, stem-qualified, as `ADR-0006-project-environment-split.v4`. **Date**: 2026-05-20 (v4) **Deciders**: ObjectStack Protocol Architects **Supersedes**: v1 (strict tree), v2 (siblings + sys_deployment join), v3 (siblings + deferred dev-workspace `sys_project`) diff --git a/docs/adr/0009-execution-pinned-metadata.md b/docs/adr/0009-execution-pinned-metadata.md index 536ca07fe77..04a6e9eaaae 100644 --- a/docs/adr/0009-execution-pinned-metadata.md +++ b/docs/adr/0009-execution-pinned-metadata.md @@ -1,6 +1,6 @@ # ADR-0009: Execution-Pinned Metadata -**Status:** Accepted +**Status**: Accepted **Date:** 2026-05-22 **Supersedes:** — **Builds on:** ADR-0008 (Metadata Repository & Change Log) diff --git a/docs/adr/0010-metadata-protection-model.md b/docs/adr/0010-metadata-protection-model.md index 8f6b6e16008..16b326d284c 100644 --- a/docs/adr/0010-metadata-protection-model.md +++ b/docs/adr/0010-metadata-protection-model.md @@ -2,7 +2,8 @@ > **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. -**Status:** Proposed +**Status**: Proposed +**Shares its number with**: [`0010-nl-to-flow-authoring`](./0010-nl-to-flow-authoring.md) (Natural-Language → Flow Authoring) — an unrelated decision under the same number. Numbers are never reassigned (AGENTS.md Prime Directive 13); cite this record unambiguously as `ADR-0010-metadata-protection-model`. **Date:** 2026-05-29 **Supersedes:** — **Builds on:** ADR-0005 (Metadata Customization Overlay), ADR-0008 (Metadata Repository & Change Log), ADR-0009 (Execution-Pinned Metadata) diff --git a/docs/adr/0010-nl-to-flow-authoring.md b/docs/adr/0010-nl-to-flow-authoring.md index 092c3e8b731..ec41a3895d4 100644 --- a/docs/adr/0010-nl-to-flow-authoring.md +++ b/docs/adr/0010-nl-to-flow-authoring.md @@ -1,6 +1,7 @@ # ADR-0010: Natural-Language → Flow Authoring (Platform Capability) **Status**: Draft (2026-05-24) +**Shares its number with**: [`0010-metadata-protection-model`](./0010-metadata-protection-model.md) (Metadata Protection Model) — an unrelated decision under the same number. Numbers are never reassigned (AGENTS.md Prime Directive 13); cite this record unambiguously as `ADR-0010-nl-to-flow-authoring`. **Authors**: HotCRM (objectstack-ai/hotcrm) — surfacing requirements from the v1 launch story **Consumers**: `@objectstack/service-ai`, `@objectstack/spec` (automation), `objectui` (plugin-designer, plugin-chatbot), every app that ships flows diff --git a/docs/adr/0014-record-form-field-type.md b/docs/adr/0014-record-form-field-type.md index 48a05bd5373..5b6db098ee1 100644 --- a/docs/adr/0014-record-form-field-type.md +++ b/docs/adr/0014-record-form-field-type.md @@ -1,6 +1,6 @@ # ADR-0014 — `record` Form Field Type -- **Status:** Accepted +**Status**: Accepted - **Date:** 2026-04-17 - **Supersedes:** — - **Related:** ADR-0005 (Metadata Customization Overlay), Prime Directive #1 (Zod First) diff --git a/docs/adr/0019-app-as-consumer-unit.md b/docs/adr/0019-app-as-consumer-unit.md index fa49cc3ac5c..8cb9457f51f 100644 --- a/docs/adr/0019-app-as-consumer-unit.md +++ b/docs/adr/0019-app-as-consumer-unit.md @@ -1,6 +1,7 @@ # ADR-0019: App as the Consumer-Facing Unit **Status**: Proposed (v3 — narrowed to the consumer-surface decision) +**Shares its number with**: [`0019-approval-as-flow-node`](./0019-approval-as-flow-node.md) (Collapse Approval into Flow — approval as a durable-pause node) — an unrelated decision under the same number. Numbers are never reassigned (AGENTS.md Prime Directive 13); cite this record unambiguously as `ADR-0019-app-as-consumer-unit`. **Date**: 2026-05-31 **Deciders**: ObjectStack Protocol Architects **Builds on**: ADR-0003 (Package as First-Class Citizen), ADR-0006 (Three-Layer Tenancy), ADR-0016 (Studio Package Authoring & Publish) diff --git a/docs/adr/0019-approval-as-flow-node.md b/docs/adr/0019-approval-as-flow-node.md index e9691a93572..e74cc614c76 100644 --- a/docs/adr/0019-approval-as-flow-node.md +++ b/docs/adr/0019-approval-as-flow-node.md @@ -1,6 +1,7 @@ # ADR-0019: Collapse Approval into Flow — one engine, approval as a durable-pause node **Status**: Accepted (2026-05-31) — fully implemented (A1–A5) +**Shares its number with**: [`0019-app-as-consumer-unit`](./0019-app-as-consumer-unit.md) (App as the Consumer-Facing Unit) — an unrelated decision under the same number. Numbers are never reassigned (AGENTS.md Prime Directive 13); cite this record unambiguously as `ADR-0019-approval-as-flow-node`. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0018](./0018-unified-node-action-registry.md) (open action registry — approval becomes a consumer), [ADR-0009](./0009-execution-pinned-metadata.md) (execution pinning — reconcile to one mechanism), [ADR-0012](./0012-notification-platform.md) (outbox / `notify`), [ADR-0010](./0010-nl-to-flow-authoring.md) + [ADR-0011](./0011-actions-as-ai-tools.md) (AI authoring — the design center) **Revises**: ADR-0018's premise that Approval stays a separate paradigm with its own closed `ApprovalActionType` enum, and the "Workflow-Rule → Flow compiler" (M5) — both dropped here (greenfield, no legacy). diff --git a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md index ca158a853e4..6b2da9c37fe 100644 --- a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md +++ b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md @@ -1,6 +1,7 @@ # ADR-0057: ERP-Grade Authorization Core — Business-Unit Partitioning, Scope-Depth Grants, and Hierarchy Rollup **Status**: Accepted in part (2026-06-21) — D1–D3/D8 implemented (scope-depth enum + evaluator, `sys_business_unit` tree, hierarchy-resolver seam, conformance rows); **D4–D7 superseded by ADR-0090 D3 / ADR-0095**: the `sys_role`/`sys_user_role`/`role_and_subordinates` vocabulary never shipped — the code realizes them as flat `sys_position`/`sys_user_position` with rollup on the BU tree. This ADR's own implementation-status table predates that supersession; read it with the rename applied. · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3/D13): the flat `sys_position` row object named above is itself retired by #15204 stage 8 (`66949b7729`, 2026-10-13), and its `sys-position.object.ts`, which the path note below names, is deleted; a position is a metadata-registry definition, and the `sys_user_position` assignment stays. +**Shares its number with**: [`0057-system-data-lifecycle-and-retention`](./0057-system-data-lifecycle-and-retention.md) (System data has a lifecycle — declarative retention, rotation, and reclamation) — an unrelated decision under the same number. Numbers are never reassigned (AGENTS.md Prime Directive 13); cite this record unambiguously as `ADR-0057-erp-authorization-core-business-units-and-scope-depth`. **Deciders**: ObjectStack Protocol Architects **Path note (2026-09), paths only — no decision changes**: this record cites the **pre-rename** vocabulary, and those four paths no longer resolve. `sys-department.object.ts` and `department-graph.ts` diff --git a/docs/adr/0057-system-data-lifecycle-and-retention.md b/docs/adr/0057-system-data-lifecycle-and-retention.md index 7319035951f..55ba6ae3760 100644 --- a/docs/adr/0057-system-data-lifecycle-and-retention.md +++ b/docs/adr/0057-system-data-lifecycle-and-retention.md @@ -1,6 +1,7 @@ # ADR-0057: System data has a lifecycle — declarative retention, rotation, and reclamation for platform-generated objects **Status**: Accepted — **P0–P4 implemented** (P0 shipped with this ADR, [#2079](https://github.com/objectstack-ai/objectstack/pull/2079); P1–P4 in [#2791](https://github.com/objectstack-ai/objectstack/pull/2791), tracked in [#2786](https://github.com/objectstack-ai/objectstack/issues/2786)) (proposed 2026-06-20 · implemented 2026-07-10) +**Shares its number with**: [`0057-erp-authorization-core-business-units-and-scope-depth`](./0057-erp-authorization-core-business-units-and-scope-depth.md) (ERP-Grade Authorization Core — business-unit partitioning, scope-depth grants, hierarchy rollup) — an unrelated decision under the same number. Numbers are never reassigned (AGENTS.md Prime Directive 13); cite this record unambiguously as `ADR-0057-system-data-lifecycle-and-retention`. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0052](./0052-audit-is-not-the-activity-feed.md) (decomposed audit / activity / collaboration into bounded contexts — this ADR adds the *orthogonal* axis those contexts never specified: **how long each one lives and how its space is reclaimed**), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared `retention` that drives no sweeper is dead surface; this ADR wires it to a runtime consumer), [ADR-0030](./0030-notification-platform-convergence.md) (notification objects are messaging-owned with their own lifecycle), [ADR-0021](./0021-analytics-dataset-semantic-layer.md) (precedent for moving event-shaped data off the primary OLTP store) **Consumers**: `@objectstack/spec` (`lifecycle` object property + `LifecycleClass`), `@objectstack/objectql` (LifecycleService: Reaper / Rotator / Archiver), `@objectstack/plugin-audit` (audit/activity exclusion of telemetry objects — shipped P0), `@objectstack/driver-sql` (`auto_vacuum=INCREMENTAL` default + incremental-vacuum hook — shipped P0), `@objectstack/dogfood` (storage-growth regression gate), platform spec-liveness gate diff --git a/docs/adr/0071-dataset-semantic-layer-depth.md b/docs/adr/0071-dataset-semantic-layer-depth.md index 7d69d4210ab..b0b8b746bd2 100644 --- a/docs/adr/0071-dataset-semantic-layer-depth.md +++ b/docs/adr/0071-dataset-semantic-layer-depth.md @@ -1,10 +1,6 @@ # ADR-0071: Dataset semantic-layer depth — multi-hop joins -## Status - -Proposed. (Refocused — see **Correction** below. The first merged draft also -proposed matrix/across pivoting; a code check found that already implemented, so -this ADR now covers only the remaining gap: multi-hop joins.) **Multi-hop to-one joins (≤3 hops / 4 objects) have since been implemented & are live — header pending formal update.** +**Status**: Proposed. (Refocused — see **Correction** below. The first merged draft also proposed matrix/across pivoting; a code check found that already implemented, so this ADR now covers only the remaining gap: multi-hop joins.) **Multi-hop to-one joins (≤3 hops / 4 objects) have since been implemented & are live — header pending formal update.** ## Correction (2026-06-24) diff --git a/docs/adr/0079-record-display-name.md b/docs/adr/0079-record-display-name.md index 359efcaa750..0e85e9fa804 100644 --- a/docs/adr/0079-record-display-name.md +++ b/docs/adr/0079-record-display-name.md @@ -1,6 +1,6 @@ # ADR-0079: The record display-name contract — `nameField` is the one canonical primary-title pointer -- **Status**: Accepted — implemented. **This file is a RETROACTIVE RECONSTRUCTION written 2026-08-08**, not a contemporaneous record; see [Provenance](#provenance-read-this-before-citing-this-file) before you cite it. +**Status**: Accepted — implemented. **This file is a RETROACTIVE RECONSTRUCTION written 2026-08-08**, not a contemporaneous record; see [Provenance](#provenance-read-this-before-citing-this-file) before you cite it. - **Decided**: 2026-06-28 (the decision), reconstructed 2026-08-08 (this file) - **Reconstructed by**: #6634 - **Implemented by**: [#2434](https://github.com/objectstack-ai/objectstack/pull/2434) (foundation — `nameField` + resolver), [#2458](https://github.com/objectstack-ai/objectstack/pull/2458) (designate-only provisioning at the registry seam), [#2463](https://github.com/objectstack-ai/objectstack/pull/2463) (the author-time gate) diff --git a/docs/adr/0091-grant-lifecycle-and-recertification.md b/docs/adr/0091-grant-lifecycle-and-recertification.md index 8a19343f057..c037e24917d 100644 --- a/docs/adr/0091-grant-lifecycle-and-recertification.md +++ b/docs/adr/0091-grant-lifecycle-and-recertification.md @@ -1,6 +1,6 @@ # ADR-0091: Grant lifecycle — effective-dated assignments, delegation, break-glass, recertification substrate -- **Status:** Accepted +**Status**: Accepted · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3/D13): `sys_position_permission_set`, which D1 and the non-goals leave undated, and the `sys_position` row L2 adds `delegatable` to, are retired by #15204 stage 8 (`66949b7729`, 2026-10-13); a position's sets and its `delegatable` flag are its metadata-registry definition. D1's dated assignment rows stand. - **Date:** 2026-07-09 (proposed) · 2026-07-10 (accepted) - **Deciders:** jack@objectstack.ai diff --git a/docs/adr/0092-sys-user-profile-field-delegation.md b/docs/adr/0092-sys-user-profile-field-delegation.md index 82011c75704..b909d98926a 100644 --- a/docs/adr/0092-sys-user-profile-field-delegation.md +++ b/docs/adr/0092-sys-user-profile-field-delegation.md @@ -1,6 +1,6 @@ # ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist -- **Status:** Accepted +**Status**: Accepted - **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)** · **2026-09-05 (amended: D1 — Tier 1 becomes `{name, image, locale}`, carrying the 2026-09-03 ruling on #14787; see the D1 Amendment)** - **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816) - **Deciders:** ObjectStack Protocol Architects diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md index dc690be7941..a57bde8d18e 100644 --- a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -1,6 +1,6 @@ # ADR-0093: Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle -- **Status:** Accepted (2026-07-13) — implemented: `tenancy` kernel service (`plugin-auth/src/tenancy-service.ts`), membership reconciler (`reconcile-membership.ts`), locked by `dogfood/test/membership-reconciler.dogfood.test.ts` +**Status**: Accepted (2026-07-13) — implemented: `tenancy` kernel service (`plugin-auth/src/tenancy-service.ts`), membership reconciler (`reconcile-membership.ts`), locked by `dogfood/test/membership-reconciler.dogfood.test.ts` - **Date:** 2026-07-13 - **Deciders:** ObjectStack Protocol Architects - **Implementation:** #2882 (Phase 0 — tactical create-user bind, merged) → this PR (Phases 1–3 — `tenancy` service, fail-fast boot guard, membership reconciler, consumer migration, backfill, docs). One revision from the original plan, ratified in D2: the endpoint-level create-user bind **delegates to the shared reconciler** (one implementation, two call sites) instead of being deleted. Runtime verification confirmed the hook fires for `admin.createUser`, but better-auth *defers* `user.create.after` post-commit (#1881), so the endpoint keeps its delegated call to report `organizationId` / `membershipCreated` deterministically in its response. Cloud-host semantics (personal-org hook precedence, multi-org non-binding, D5 blast radius) verified against `objectstack-ai/cloud` — see D2/D3/D5. diff --git a/docs/adr/0098-pinyin-search-companion-column.md b/docs/adr/0098-pinyin-search-companion-column.md index 353c7f43c63..086fec186a7 100644 --- a/docs/adr/0098-pinyin-search-companion-column.md +++ b/docs/adr/0098-pinyin-search-companion-column.md @@ -1,6 +1,6 @@ # ADR-0098: Pinyin Search via a Locale-Gated Companion Column -- **Status**: Accepted +**Status**: Accepted - **Date**: 2026-07-16 - **Issue**: #2486 - **Extends**: ADR-0061 (record search architecture — purely additive), ADR-0045 (additive materialization), ADR-0079 (display-name contract) diff --git a/docs/adr/0100-credential-field-channels.md b/docs/adr/0100-credential-field-channels.md index 9943bbe70ab..da88372910d 100644 --- a/docs/adr/0100-credential-field-channels.md +++ b/docs/adr/0100-credential-field-channels.md @@ -1,6 +1,6 @@ # ADR-0100: Credential Field Channels — `secret` (encrypted) and `password` (masked) -- **Status**: Accepted +**Status**: Accepted - **Date**: 2026-07-18 - **Issue**: #2036 (found via #2033 / #2025 / #2028 field-type round-trip work) - **Relates to**: ADR-0077 (authoring-surface boundary), ADR-0078 (no silently diff --git a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md index 2848b316c4b..a25452963b6 100644 --- a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md +++ b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md @@ -1,6 +1,6 @@ # ADR-0103: `managedBy` write policy — split the overloaded `system` bucket, enforce engine-owned writes -- **Status**: Accepted (D5 completed in v17 — see the #3355 addendum at the end: +**Status**: Accepted (D5 completed in v17 — see the #3355 addendum at the end: the residual `system` bucket is renamed `system-data` and the bare value retired) · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3/D13): `sys_position_permission_set`, one of the RBAC link tables this record files as writable data, and `sys_position` / `sys_capability`, two of the provenance-column objects it sets apart, are retired as row objects by #15204 stage 8 (`66949b7729`, 2026-10-13); the policy stands. See the notes in the Context and the two addenda. - **Date**: 2026-07-19 diff --git a/docs/adr/0104-field-runtime-value-shape-contract.md b/docs/adr/0104-field-runtime-value-shape-contract.md index 40cf2b1248a..906c53e1e06 100644 --- a/docs/adr/0104-field-runtime-value-shape-contract.md +++ b/docs/adr/0104-field-runtime-value-shape-contract.md @@ -1,6 +1,6 @@ # ADR-0104: Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-as-reference -- **Status**: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2 +**Status**: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2 landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below, and wave 2's ownership model settled by the 2026-07-27 addendum. Strict-default flip of D1/D2 tracked in #3438 — the media half flips per diff --git a/docs/adr/0110-action-identity-and-declaration-admission.md b/docs/adr/0110-action-identity-and-declaration-admission.md index d45406adf34..e96bc7e929a 100644 --- a/docs/adr/0110-action-identity-and-declaration-admission.md +++ b/docs/adr/0110-action-identity-and-declaration-admission.md @@ -392,8 +392,8 @@ ones); the engine-side aliasing alternative (deferred); any change to how before the refusal exists. 3. **D3/D6** — refusal ON by default with no opt-out, plus the `v17.mdx` migration entry. Same PR flips this - ADR to `Accepted — implemented` with evidence (the PRIORITIZATION - status-hygiene rule). + ADR to `Accepted — implemented` with evidence (the status-hygiene rule, + kept in `docs/adr/README.md`). Steps 1→3 may land in separate PRs but **must not ship in separate releases**: step 3 without step 1 refuses correctly-declared actions. diff --git a/docs/adr/0117-owning-business-unit-record-stamp.md b/docs/adr/0117-owning-business-unit-record-stamp.md index daed232a806..cb9d04f381a 100644 --- a/docs/adr/0117-owning-business-unit-record-stamp.md +++ b/docs/adr/0117-owning-business-unit-record-stamp.md @@ -1,6 +1,6 @@ # ADR-0117: 记录级业务单元归属(owning business unit) -- **状态**: **Accepted (D1/D3 scoped)**(2026-08-05)—— 仅 **D1**(`ownership` 新增 +**Status**: **Accepted (D1/D3 scoped)**(2026-08-05)—— 仅 **D1**(`ownership` 新增 `business_unit` 一档与 `owning_business_unit_id` 记录戳的命名与语义)与 **D3** (`record.organization_id == sys_business_unit(owning_business_unit_id).organization_id` 不变量)进入协议。**D5、D2 的默认盖章策略、D8 的启用门粒度、D4 的权限位选择仍为 diff --git a/docs/adr/0118-non-user-actor-contract.md b/docs/adr/0118-non-user-actor-contract.md index 4e996652952..0c0e942809d 100644 --- a/docs/adr/0118-non-user-actor-contract.md +++ b/docs/adr/0118-non-user-actor-contract.md @@ -1,6 +1,6 @@ # ADR-0118: 非用户 actor 的平台契约 —— null 表示、fail-closed、显式 `isSystem` -- **状态**: Accepted(2026-08-02,维护者按四轴框架裁决:平台长远合理性 / 防 AI 作者静默犯错 / 实际业务需求 / 创业阶段不扩边界) +**Status**: Accepted(2026-08-02,维护者按四轴框架裁决:平台长远合理性 / 防 AI 作者静默犯错 / 实际业务需求 / 创业阶段不扩边界) - **日期**: 2026-08-02 - **关联**: ADR-0049(enforce-or-remove)、ADR-0078(no-silently-inert-metadata)、 ADR-0092(better-auth 身份写守卫)、ADR-0103(`managedBy` 写策略与引擎写守卫) diff --git a/docs/adr/0121-declarative-endpoint-routing-namespace-and-channel-split.md b/docs/adr/0121-declarative-endpoint-routing-namespace-and-channel-split.md index 4bdb2c0526f..fbfea931e36 100644 --- a/docs/adr/0121-declarative-endpoint-routing-namespace-and-channel-split.md +++ b/docs/adr/0121-declarative-endpoint-routing-namespace-and-channel-split.md @@ -1,6 +1,6 @@ # ADR-0121: 声明式端点的路由归属与通道分工 —— 命名空间制、actions/apis 按调用方分工、`type: flow` 保留 -- **状态**: Accepted(2026-08-04,维护者裁决;本记录只立规则,执行体由 #5040 的 E 系列实施) +**Status**: Accepted(2026-08-04,维护者裁决;本记录只立规则,执行体由 #5040 的 E 系列实施) - **日期**: 2026-08-04 - **关联**: [ADR-0076](./0076-objectql-core-tiering.md)(D11 —— 一条路由一个属主;本 ADR **扩充**它,不取代)、 [ADR-0049](./0049-no-unenforced-security-properties.md)(enforce-or-remove,本 ADR 落在它的时序上)、 diff --git a/docs/adr/0132-multi-organization-runtime-is-open-core.md b/docs/adr/0132-multi-organization-runtime-is-open-core.md index 75ea12e0a8f..8f430ba9f4c 100644 --- a/docs/adr/0132-multi-organization-runtime-is-open-core.md +++ b/docs/adr/0132-multi-organization-runtime-is-open-core.md @@ -1,6 +1,6 @@ # ADR-0132: The multi-organization runtime is open core — single-database organization isolation ships open; only the entitlement stays commercial -- **Status**: Accepted (2026-09-07) — accepted by the merge that landed it on `main` +**Status**: Accepted (2026-09-07) — accepted by the merge that landed it on `main` ([#16215](https://github.com/objectstack-ai/objectstack/pull/16215), commit `c677cda816`), which is itself the acceptance act for a governed surface (Prime Directive #14). - **Deciders**: ObjectStack maintainer, 2026-09-06, live chat, verbatim and untranslated: the diff --git a/docs/adr/0133-org-management-open-basics.md b/docs/adr/0133-org-management-open-basics.md index 79c8c3a4310..d7c79b21fef 100644 --- a/docs/adr/0133-org-management-open-basics.md +++ b/docs/adr/0133-org-management-open-basics.md @@ -1,6 +1,6 @@ # ADR-0133: Organization management — the open basics; a mirror of cloud ADR-0081 D1/D3/D4 in the repository whose code enforces them -- **Status**: Accepted (2026-09-07) — accepted by the merge that landed it on `main` ([#16267](https://github.com/objectstack-ai/objectstack/pull/16267), commit `13c08356ab`), which is the acceptance act for a governed surface (Prime Directive #14). ⛔ Nothing in this file is a new decision; see [Provenance](#provenance--read-this-before-citing-this-file). +**Status**: Accepted (2026-09-07) — accepted by the merge that landed it on `main` ([#16267](https://github.com/objectstack-ai/objectstack/pull/16267), commit `13c08356ab`), which is the acceptance act for a governed surface (Prime Directive #14). ⛔ Nothing in this file is a new decision; see [Provenance](#provenance--read-this-before-citing-this-file). - **Mirrors**: `objectstack-ai/cloud` **ADR-0081** (Status: Accepted, founder-decided in session, 2026-07-09) — **D1, D3 and D4 only**, the half whose mechanism is open code in this repository. - **Deliberately not mirrored**: cloud ADR-0081 **D2**, its non-goals, and its commercial consequences — cited as `cloud ADR-0081`, never restated here. ⚠️ D2 is absent for a stronger reason than the other two: it has been **reversed** by [ADR-0132](./0132-multi-organization-runtime-is-open-core.md), and the multi-organization machinery it packaged now ships in this repository (`packages/plugins/organizations`). What stays in cloud is that *record* — together with the non-goals and the commercial consequences, which the reversal leaves untouched. See [Relationship to the D2 reversal](#relationship-to-the-d2-reversal-adr-0132). - **Cross-checked against** (cite, do not duplicate): [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (tenancy mode; the membership lifecycle; D9 already anchors the active-organization resolution), [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) (group tenancy posture; D12 anchors the multi-org entitlement), [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) (total organization ownership; D1/D7/D9) diff --git a/docs/adr/PRIORITIZATION.md b/docs/adr/PRIORITIZATION.md deleted file mode 100644 index 22dc06e2fce..00000000000 --- a/docs/adr/PRIORITIZATION.md +++ /dev/null @@ -1,5 +0,0 @@ -# ADR Prioritization Review - -Superseded: what to do first is set by `docs/NORTH-STAR.md` (「优先级」); this review is retired. - -One rule this file adopted stays in force until it has another home (ADR-0110 cites it as the PRIORITIZATION status-hygiene rule): a PR that lands an ADR's implementation flips that ADR's `Status:` line to `Accepted — implemented` with one line of evidence (key file + test). diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000000..9e9d1297df0 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,230 @@ +# Architecture Decision Records — index + +> **Generated** by `node scripts/adr-index.mjs` from the records in `docs/adr/` — do not edit by hand; change a record's `**Status**:` line and regenerate (`pnpm adr:index`). The lint ADR step reports a stale index as a warning and never fails on it. + +## Conventions + +- **One registry per repository, numbers never reassigned, files never moved** (AGENTS.md Prime Directive 13). A reversed decision is a new ADR or an amended status line, never a quiet changeset. Before changing behaviour in territory an ADR governs, grep this index for the surface. +- **The status line is the single fact.** Every record carries, right under its title, one line that starts `**Status**: ` followed by a keyword — `Partially implemented`, `Accepted`, `Proposed`, `Draft`, `Superseded`, `Withdrawn`, `Deferred`, `Implemented`, `Revised`, `Amended` — and any free prose after it (dates, implementation notes, amendments). This index is generated from that line by `scripts/adr-index.mjs`; a record whose line is missing or names no keyword is listed as **Unparsed** and reported, never guessed. +- **Status hygiene.** A PR that lands an ADR's implementation flips that ADR's `**Status**:` line to `Accepted — implemented` with one line of evidence (key file + test), in the same PR. (The rule the retired `PRIORITIZATION.md` review carried; it lives here now.) +- **Shared numbers are cited by stem.** Where two unrelated records carry one number, a bare `ADR-NNNN` is one of two; the unambiguous id is the record's filename stem prefixed `ADR-` (`ADR-0057-system-data-lifecycle-and-retention`), which `scripts/check-adr-anchors.mjs` resolves to that one record. Versions of one decision share a stem and add `.vN`; cite a version as `ADR-0006 v4` in prose or `ADR-0006-project-environment-split.v4` where a checker reads it. +- **The ObjectUI registry (`ui/docs/adr/`) keeps its own sequence**, imported with the ui merge. Its numbers overlap this registry's and mean something else; this index lists them under the `ui-` prefix, and a citation of one is spelled `ObjectUI ADR-NNNN`, which the anchor checker recognises as another registry's. +- **Decisions that draw the open/closed or commercial boundary live in `objectstack-ai/cloud`** and are cited from here as `cloud ADR-NNNN`, never as a bare number. + +## Summary + +143 records under `docs/adr/` (138 numbers), 11 under `ui/docs/adr/` (listed separately below). + +| Status | Records | +|---|---| +| Accepted | 108 | +| Proposed | 22 | +| Superseded | 4 | +| Draft | 3 | +| Withdrawn | 2 | +| Amended | 1 | +| Deferred | 1 | +| Partially implemented | 1 | +| Revised | 1 | +| **Total** | **143** | + +## Shared numbers + +Listed side by side, never renumbered (Prime Directive 13). A **collision** is two unrelated decisions under one number — cite each by its stem-qualified id; **versions** are one decision revised under its own number. + +### ADR-0006 — one decision, 3 versions + +A bare `ADR-0006` names the decision; its current text is [0006-project-environment-split.v4.md](./0006-project-environment-split.v4.md). Cite one revision by its version or its stem. + +| Cite as | File | Title | Status | Date | +|---|---|---|---|---| +| `ADR-0006-project-environment-split` (unversioned file, superseded) | [0006-project-environment-split.md](./0006-project-environment-split.md) | Environment & Project as Independent Siblings — v3 | Superseded — by v4 (`0006-project-environment-split.v4.md`) — 2026-05-20 | 2026-05-20 | +| `ADR-0006 v2` / `ADR-0006-project-environment-split.v2` | [0006-project-environment-split.v2.md](./0006-project-environment-split.v2.md) | Three-Layer Tenancy — Organization, Project, Environment | Superseded — by v4 (`0006-project-environment-split.v4.md`) — 2026-05-20 | 2026-05-20 | +| `ADR-0006` (current) / `ADR-0006 v4` / `ADR-0006-project-environment-split.v4` | [0006-project-environment-split.v4.md](./0006-project-environment-split.v4.md) | Environment & Project — v4 (drop dev-workspace Project, unify on Package) | Accepted — v4 — supersedes v3 — API-surface vocabulary boundary recorded 2… | 2026-05-20 | + +### ADR-0010 — 2 unrelated decisions — a collision + +| Cite as | File | Title | Status | Date | +|---|---|---|---|---| +| `ADR-0010-metadata-protection-model` | [0010-metadata-protection-model.md](./0010-metadata-protection-model.md) | Metadata Protection Model | Proposed | 2026-05-29 | +| `ADR-0010-nl-to-flow-authoring` | [0010-nl-to-flow-authoring.md](./0010-nl-to-flow-authoring.md) | Natural-Language → Flow Authoring (Platform Capability) | Draft — 2026-05-24 | 2026-05-24 | + +### ADR-0019 — 2 unrelated decisions — a collision + +| Cite as | File | Title | Status | Date | +|---|---|---|---|---| +| `ADR-0019-app-as-consumer-unit` | [0019-app-as-consumer-unit.md](./0019-app-as-consumer-unit.md) | App as the Consumer-Facing Unit | Proposed — v3 — narrowed to the consumer-surface decision | 2026-05-31 | +| `ADR-0019-approval-as-flow-node` | [0019-approval-as-flow-node.md](./0019-approval-as-flow-node.md) | Collapse Approval into Flow — one engine, approval as a durable-pause node | Accepted — 2026-05-31 — fully implemented (A1–A5) | 2026-05-31 | + +### ADR-0057 — 2 unrelated decisions — a collision + +| Cite as | File | Title | Status | Date | +|---|---|---|---|---| +| `ADR-0057-erp-authorization-core-business-units-and-scope-depth` | [0057-erp-authorization-core-business-units-and-scope-depth.md](./0057-erp-authorization-core-business-units-and-scope-depth.md) | ERP-Grade Authorization Core — Business-Unit Partitioning, Scope-Depth Grants, and Hierar… | Accepted — in part (2026-06-21) — D1–D3/D8 implemented (scope-depth enum +… | 2026-06-21 | +| `ADR-0057-system-data-lifecycle-and-retention` | [0057-system-data-lifecycle-and-retention.md](./0057-system-data-lifecycle-and-retention.md) | System data has a lifecycle — declarative retention, rotation, and reclamation for platfo… | Accepted — P0–P4 implemented (P0 shipped with this ADR, #2079; P1–P4 in #2… | 2026-06-20 | + +## Records — `docs/adr/` + +| Number | Title | Status | Date | Supersedes | Superseded by | +|---|---|---|---|---|---| +| [ADR-0001](./0001-withdrawn-metadata-service-architecture.md) | Withdrawn — this number is retired and must not be reused | Withdrawn — 2026-02-11. This file is a tombstone, not a decision: nothing i… | 2026-02-11 | — | — | +| [ADR-0002](./0002-environment-database-isolation.md) | Environment-Per-Database Isolation | Accepted | 2026-04-19 | *The v3.4/v4.0 "per-organization databas…* | — | +| [ADR-0003](./0003-package-as-first-class-citizen.md) | Package as First-Class Citizen with Versioned Releases | Accepted — revised by cloud ADR-0007 (2026-06-12): `syspackageinstallation… | 2026-04-20 | ADR-0002 | cloud ADR-0007 | +| [ADR-0004](./0004-cloud-multi-kernel.md) | Cloud Control Plane + Per-Project Kernels | Superseded — 2026-04-23 — the physical split between `apps/cloud` | 2026-04-22 | — | — | +| [ADR-0005](./0005-metadata-customization-overlay.md) | Metadata Customization Overlay (Artifact + sysmetadata Delta) | Accepted — 2026-05-16 · Amended (2026-05-22, see "Amendment: post-ADR-0006… | 2026-05-16 | — | — | +| [ADR-0006](./0006-project-environment-split.md) | Environment & Project as Independent Siblings — v3 | Superseded — by v4 (`0006-project-environment-split.v4.md`) — 2026-05-20 | 2026-05-20 | v1, v2 | ADR-0006 v4 | +| [ADR-0006 v2](./0006-project-environment-split.v2.md) | Three-Layer Tenancy — Organization, Project, Environment | Superseded — by v4 (`0006-project-environment-split.v4.md`) — 2026-05-20 | 2026-05-20 | — | ADR-0006 v4 | +| [ADR-0006 v4](./0006-project-environment-split.v4.md) | Environment & Project — v4 (drop dev-workspace Project, unify on Package) | Accepted — v4 — supersedes v3 — API-surface vocabulary boundary recorded 2… | 2026-05-20 | v1, v2, v3 | — | +| [ADR-0007](./0007-settings-manifest-and-kv-store.md) | Settings — Manifest + K/V Store + Resolver (Apple-style) | Accepted — backend implemented; UI pending (objectui) (proposed 2026-05-20… | 2026-05-20 | — | — | +| [ADR-0008](./0008-metadata-repository-and-change-log.md) | Metadata Repository, Change Log & Subscription (M0 → M4) | Accepted — 2026-05-22 · Amended 2026-04-13 — branch concept removed (see §… | 2026-05-22 | *ad-hoc HMR wiring in `packages/metadata…* | — | +| [ADR-0009](./0009-execution-pinned-metadata.md) | Execution-Pinned Metadata | Accepted | 2026-05-22 | — | — | +| [ADR-0010-metadata-protection-model](./0010-metadata-protection-model.md) | Metadata Protection Model | Proposed | 2026-05-29 | — | — | +| [ADR-0010-nl-to-flow-authoring](./0010-nl-to-flow-authoring.md) | Natural-Language → Flow Authoring (Platform Capability) | Draft — 2026-05-24 | 2026-05-24 | — | — | +| [ADR-0011](./0011-actions-as-ai-tools.md) | Actions as AI Tools (Operational Parity) | Accepted — implemented (2026-06-04) | 2026-06-04 | — | — | +| [ADR-0012](./0012-notification-platform.md) | Messaging Platform — Outbound Notifications on a Generalized Outbox | Draft — 2026-05-25 · scope-revised 2026-05-26 · Superseded by ADR-0030… | 2026-05-25 | — | ADR-0030 | +| [ADR-0013](./0013-bidirectional-messaging.md) | Bidirectional Messaging — Conversational Channels, Slack First | Draft — 2026-05-26 | 2026-05-26 | — | — | +| [ADR-0014](./0014-record-form-field-type.md) | `record` Form Field Type | Accepted | 2026-04-17 | — | — | +| [ADR-0015](./0015-external-datasource-federation.md) | External Datasource Federation | Accepted — backend/REST/CLI implemented; Studio UI + extra dialect drivers… | 2026-05-29 | — | — | +| [ADR-0016](./0016-studio-package-authoring-and-publish.md) | Studio Package Authoring & One-Click Publish | Partially implemented — revised 2026-05-30 | 2026-05-30 | — | — | +| [ADR-0017](./0017-object-has-many-view.md) | Object has-many View (Independent View Entities) | Accepted — · Implemented Phases 1–4 (2026-05-30) · Phase 5 (Studio designe… | 2026-05-30 | — | — | +| [ADR-0018](./0018-unified-node-action-registry.md) | Unified Node/Action Registry across Flow, Workflow-Rule & Approval | Accepted — 2026-05-31 — M1–M3 implemented; M4 framework-side complete (des… | 2026-05-31 | — | — | +| [ADR-0019-app-as-consumer-unit](./0019-app-as-consumer-unit.md) | App as the Consumer-Facing Unit | Proposed — v3 — narrowed to the consumer-surface decision | 2026-05-31 | — | — | +| [ADR-0019-approval-as-flow-node](./0019-approval-as-flow-node.md) | Collapse Approval into Flow — one engine, approval as a durable-pause node | Accepted — 2026-05-31 — fully implemented (A1–A5) | 2026-05-31 | — | — | +| [ADR-0020](./0020-state-machine-converge-and-enforce.md) | State Machine — converge three declaration forms to one enforced `statemachine` | Accepted — Implemented (2026-05-31) | 2026-05-31 | — | — | +| [ADR-0021](./0021-analytics-dataset-semantic-layer.md) | Analytics — one semantic `dataset` layer, `report` / `dashboard` become pure presentation | Accepted — & shipped — Phase 2 closed 2026-06-10. The author-surface conve… | 2026-06-10 | — | — | +| [ADR-0022](./0022-connectors-vs-messaging-channels.md) | Connectors vs Messaging Channels — Where "Push to Slack" Lives | Proposed — 2026-05-31 — routing decision recorded; both substrates now exi… | 2026-05-31 | — | — | +| [ADR-0023](./0023-openapi-to-connector-generator.md) | OpenAPI → Connector Generator — Bulk Connectors From Declarative API Specs | Accepted — library implemented; CLI + examples pending (proposed 2026-06-0… | 2026-06-01 | — | — | +| [ADR-0024](./0024-mcp-connectors.md) | MCP Servers as Connectors — Adopting an Open, Vendor-Neutral Tool Protocol | Accepted — library implemented; examples pending (proposed 2026-06-01 · ca… | 2026-06-01 | — | — | +| [ADR-0025](./0025-plugin-package-distribution.md) | Plugin Package Distribution (Code + Dependencies) | Proposed — partially implemented (2026-09-12 audit, superseding 2026-07-16… | 2026-09-12 | — | — | +| [ADR-0026](./0026-client-ui-plugin-distribution.md) | Client-Side UI Plugin Distribution | Proposed | — | — | — | +| [ADR-0027](./0027-metadata-authoring-lifecycle.md) | `@objectstack/metadata-authoring` — Staged Authoring, Publish & Promotion Lifecycle | Proposed — 2026-06-01 | 2026-06-01 | — | — | +| [ADR-0028](./0028-metadata-naming-and-namespace-isolation.md) | Metadata Naming & Namespace Isolation — Derived Physical Names, Namespace-Scoped Identity, and a Single Kerne… | Deferred — 2026-07-16; proposed 2026-06-01 — the target model (namespace a… | 2026-07-16 | ADR-0129 | — | +| [ADR-0029](./0029-kernel-object-ownership-and-platform-objects-decomposition.md) | Kernel Object Ownership — First-Party Capabilities as Plugins That Own Their Data, and Decomposing the `platf… | Accepted — K0/K1/K2/D7 implemented; K3 (pending ADR-0030/storage) + K4 cle… | 2026-06-01 | — | — | +| [ADR-0030](./0030-notification-platform-convergence.md) | Notification Platform Convergence (single ingress, layered pipeline) | Accepted — 2026-06-01 — P0–P3b2 shipped (P3b-2 digest collapse landed); cr… | 2026-06-01 | ADR-0012 | — | +| [ADR-0031](./0031-advanced-flow-node-executors-and-dag.md) | Structured control-flow for flows (loop / parallel / try-catch) — native + AI-authored, BPMN as interop | Accepted — 2026-06-02 — implemented in #1482 (spec + loop), #1489 (paralle… | 2026-06-02 | — | — | +| [ADR-0032](./0032-unified-expression-layer.md) | Expression layer for an AI-authored platform — validate-by-default, schema-aware, one CEL language, no silent… | Accepted — implemented; designer builders pending (objectui) (proposed 202… | 2026-06-02 | — | — | +| [ADR-0033](./0033-ai-assisted-metadata-authoring.md) | AI-assisted metadata authoring — one agent brain, draft-gated review, type-agnostic apply, open-core boundary | Accepted — 2026-06-02 | 2026-06-02 | — | cloud ADR-0025 | +| [ADR-0034](./0034-transactional-writes-and-ambient-transaction.md) | Robust multi-write transactions (ambient transaction context) | Accepted — implemented (v8.0.0) (calibrated 2026-06-12) | 2026-06-12 | — | — | +| [ADR-0035](./0035-config-driven-master-detail.md) | Config-driven master-detail (declare on the relationship, forms derive) | Accepted | — | — | — | +| [ADR-0036](./0036-app-as-rest-api-and-mcp-server.md) | Every app is a REST API + an MCP server — make it visible and connectable after build | Accepted — framework implemented; cloud surfacing pending (proposed 2026-0… | 2026-06-06 | — | — | +| [ADR-0037](./0037-live-canvas-draft-preview.md) | Live Canvas — draft-overlay live preview while you build | Accepted — 2026-06-10 — all four phases implemented: `previewDrafts` reads… | 2026-06-10 | — | — | +| [ADR-0038](./0038-build-verification-loop.md) | Build Verification Loop — the agent builds, verifies, and corrects itself | Proposed — 2026-06-11 — cloud-owned; framework slice partially implemented… | 2026-06-11 | — | cloud ADR-0025 | +| [ADR-0039](./0039-token-scope-tree-execution.md) | Concurrent durable pause — multi-instance nodes now, token/scope-tree later | Accepted — Track A implemented; Track B deferred (proposed 2026-06-11, rev… | 2026-06-11 | — | — | +| [ADR-0040](./0040-unified-assistant-and-agent-binding.md) | Unified Assistant — the end user never picks an agent | Superseded — by ADR-0063 — original: Proposed (2026-06-11) | 2026-06-11 | — | ADR-0063, cloud ADR-0025 | +| [ADR-0041](./0041-flow-trigger-family.md) | Flow trigger family — taxonomy, naming, and the open-source core set | Accepted — Tier 1 implemented (rename deprecation stubs pending) (proposed… | 2026-06-12 | — | — | +| [ADR-0042](./0042-approval-sla-escalation.md) | Approval SLA escalation — a jobs-backed scanner with audit-row idempotency | Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12) | 2026-06-12 | — | — | +| [ADR-0043](./0043-actionable-approval-links.md) | Actionable approval links — single-use tokens with a session-less confirm page | Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12) · Ame… | 2026-06-12 | — | — | +| [ADR-0044](./0044-approval-send-back-for-revision.md) | Flow-level send-back-for-revision — `revise` branch + typed back-edge re-entry | Accepted — engine + model implemented; designer pending (objectui) (propos… | 2026-06-12 | — | — | +| [ADR-0045](./0045-additive-materialization-and-visibility-gate.md) | Additive materialization with a visibility gate — drafts narrow to mutations | Accepted — 2026-06-12 · Amended 2026-08-09 (#4829, maintainer ruling 2026-… | 2026-06-12 | — | — | +| [ADR-0046](./0046-package-docs-as-metadata.md) | Package documentation as metadata — flat `src/docs/.md` compiled into the manifest | Proposed — 2026-06-12, revised — simplified from the original directory/do… | 2026-06-12 | — | — | +| [ADR-0047](./0047-object-ui-run-modes.md) | Two run modes for object UI — data views vs interface pages, user filters, and runtime visualization choice | Accepted — 2026-06-12; revised 2026-06-19 — framework scope implemented (`… | 2026-06-12 | — | — | +| [ADR-0048](./0048-cross-package-metadata-collision.md) | Cross-package metadata collision — package-id identity, namespace install gate, package-scoped resolution | Revised — 2026-06-13 — supersedes the original per-item collision | 2026-06-13 | — | — | +| [ADR-0049](./0049-no-unenforced-security-properties.md) | Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate) | Accepted — 2026-06-15 — implemented: fail-closed `DESTRUCTIVEOPERATIONS` (… | 2026-06-15 | — | — | +| [ADR-0050](./0050-form-layout-vs-presentation.md) | Form layout vs presentation — split the overloaded `FormView.type` | Proposed — 2026-06-15 · Amended (2026-10-20, letter B′ on #24692, ruling r… | 2026-06-15 | — | — | +| [ADR-0051](./0051-inline-metadata-views-in-docs.md) | Inline metadata views in docs — the ` ```metadata ` fenced block | Proposed — 2026-06-16 — P1 authoring surface implemented (2026-07-16 audit… | 2026-06-16 | — | — | +| [ADR-0052](./0052-audit-is-not-the-activity-feed.md) | Audit is not the activity feed — decompose collaboration, activity, and audit into bounded contexts | Proposed — 2026-06-16 — partially implemented (2026-07-16 audit): §5b decl… | 2026-06-16 | — | — | +| [ADR-0053](./0053-date-and-datetime-semantics.md) | `date` is a timezone-naive calendar day; `datetime` is an instant rendered in a reference timezone | Accepted — 2026-06-16 — Phase 1 + addendum D-A1 implemented (`sql-driver.t… | 2026-06-16 | — | — | +| [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) | A live authorable property must be proven at runtime, not merely have a consumer (prove-it-runs gate) | Accepted — 2026-06-18 | 2026-06-18 | — | — | +| [ADR-0055](./0055-master-detail-controlled-by-parent.md) | Master-detail "controlled by parent" permissions — derived access via pre-resolved master-id membership | Accepted — 2026-06-19 — implemented in this PR (P0–P2) · Amended (2026-09-… | 2026-06-19 | — | — | +| [ADR-0056](./0056-permission-model-landing-verification.md) | Permission Model Landing Verification — Whole-Model Enforce / Prove / Reconcile Audit | Accepted — 2026-06-20 — landed per the implementation-status addendum (D2… | 2026-06-20 | — | ADR-0090 | +| [ADR-0057-erp-authorization-core-business-units-and-scope-depth](./0057-erp-authorization-core-business-units-and-scope-depth.md) | ERP-Grade Authorization Core — Business-Unit Partitioning, Scope-Depth Grants, and Hierarchy Rollup | Accepted — in part (2026-06-21) — D1–D3/D8 implemented (scope-depth enum +… | 2026-06-21 | ADR-0056 | ADR-0090, ADR-0095 | +| [ADR-0057-system-data-lifecycle-and-retention](./0057-system-data-lifecycle-and-retention.md) | System data has a lifecycle — declarative retention, rotation, and reclamation for platform-generated objects | Accepted — P0–P4 implemented (P0 shipped with this ADR, #2079; P1–P4 in #2… | 2026-06-20 | — | — | +| [ADR-0058](./0058-expression-and-predicate-surface.md) | The Expression & Predicate Surface — One Authoring Language, Two Backends, and the Pushdown-Compiler Reconcil… | Accepted — 2026-06-21 — implemented: canonical CEL→Filter compiler (`formu… | 2026-06-21 | ADR-0056, ADR-0057 | — | +| [ADR-0059](./0059-third-party-backward-compatibility-gates.md) | Third-party backward compatibility is proven by layered gates, not by in-repo consumers | Accepted — 2026-06-21 | 2026-06-21 | — | — | +| [ADR-0060](./0060-conformance-ledger-platform-pattern.md) | Conformance Ledger as a Platform Pattern | Accepted — P1–P2 implemented; D5 landed-bar in force; D6 (CI aggregate) de… | 2026-06-21 | — | — | +| [ADR-0061](./0061-record-search-architecture.md) | Record Search Architecture — metadata-driven `$search` resolution | Accepted — 2026-06-21; Tier 1 — implemented and conformance-closed per thi… | 2026-06-21 | — | — | +| [ADR-0062](./0062-external-datasource-runtime.md) | External Datasource Runtime — connection lifecycle, credentials, visibility & query completeness | Accepted — 2026-06-22 — D1–D8 implemented. D1 fully implemented across bot… | 2026-06-22 | ADR-0015 | — | +| [ADR-0063](./0063-two-kernel-agents-skills-are-the-extension-primitive.md) | Two agents (`ask` / `build`), bound by surface; skills are the only third-party extension primitive | Proposed — 2026-06-22 — cloud-owned (2026-07-16 audit): the framework spec… | 2026-06-22 | ADR-0040, ADR-0064 | cloud ADR-0025 | +| [ADR-0064](./0064-tool-scoping-to-agent.md) | Tool scoping — an agent's tools are exactly its skills' tools | Proposed — 2026-06-22 — cloud-owned (2026-07-16 audit): the only framework… | 2026-06-22 | — | cloud ADR-0025 | +| [ADR-0065](./0065-sdui-styling-model.md) | SDUI styling model — scoped style-objects over arbitrary Tailwind classes | Accepted — 2026-06-22 — open-mechanism half implemented: `style`/`responsi… | 2026-06-22 | — | — | +| [ADR-0066](./0066-unified-authorization-model.md) | Unified authorization model — capability registry, secure-by-default posture, resource→capability contracts,… | Accepted — 2026-06-23 — D1–D5 + refinements ⑤/⑨ implemented (capability re… | 2026-06-23 | — | — | +| [ADR-0067](./0067-commit-history-and-rollback-for-ai-authoring.md) | Commit history and rollback for AI authoring — turns become atomic, revertible commits | Accepted — 2026-06-24; completed 2026-07-16 · Amended (2026-08-06, #5351/#… | 2026-06-24 | — | — | +| [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) | Unified user-context contract & built-in identity roles — one `currentuser` shape across formula/RLS/client,… | Accepted — 2026-06-24 — implemented under the position model: `EvalUser` f… | 2026-06-24 | — | — | +| [ADR-0069](./0069-enterprise-authentication-hardening.md) | Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, a… | Accepted — P1 + P2 implemented (2026-07-04); P3 partially landed (see Adde… | 2026-07-04 | — | — | +| [ADR-0070](./0070-package-first-authoring.md) | Package-first authoring — every runtime-authored item lives in a writable package (base); no orphans | Accepted — 2026-06-24 — P1–P3 implemented, merged & live-verified (see Imp… | 2026-06-24 | — | — | +| [ADR-0071](./0071-dataset-semantic-layer-depth.md) | Dataset semantic-layer depth — multi-hop joins | Proposed — Refocused — see Correction below. The first merged draft also p… | — | — | — | +| [ADR-0072](./0072-reference-scope-and-resolvability.md) | Reference Scope & Resolvability — the authored-expression data-picker model (#1934) | Proposed — 2026-06-25 | 2026-06-25 | — | — | +| [ADR-0073](./0073-automation-execution-identity.md) | Automation execution identity — a built-in non-human principal for user-less runs, `runAs` as authorization p… | Proposed — 2026-06-25 | 2026-06-25 | — | — | +| [ADR-0074](./0074-canonical-audit-timestamp-storage-on-sqlite.md) | Audit timestamps are stored in one canonical, timezone-explicit format on SQLite | Accepted — 2026-06-26. Read side extended (2026-09-07): the read repair th… | 2026-06-26 | — | — | +| [ADR-0076](./0076-objectql-core-tiering.md) | objectql is the data engine — relocate metadata management (protocol) out of it; enforce the boundary; defer… | Accepted — D1/D2/D4/D5/D6/D8/D9 implemented; D10/D12 partially landed; D11… | 2026-08-15 | — | — | +| [ADR-0077](./0077-authoring-surface-boundary-hook-flow-validation.md) | The authoring-surface boundary — route by intent, not mechanism; verifiability tier = friction tier; loud-not… | Proposed — 2026-06-28 | 2026-06-28 | — | — | +| [ADR-0078](./0078-no-silently-inert-metadata.md) | A Zod-valid metadata instance that is functionally incomplete must fail loudly at author time, uniformly acro… | Accepted — framework implemented; the cloud half of §2/§3 pending (cloud)… | 2026-06-28 | — | — | +| [ADR-0079](./0079-record-display-name.md) | The record display-name contract — `nameField` is the one canonical primary-title pointer | Accepted — implemented. This file is a RETROACTIVE RECONSTRUCTION written… | 2026-06-28 | — | — | +| [ADR-0080](./0080-ai-authored-ui-jsx-source.md) | AI-authored UI pages — JSX source compiled to the SDUI tree (parse ≠ execute); the component registry is the… | Proposed — 2026-06-29 — partially implemented (2026-07-16 audit): `sdui-pa… | 2026-06-29 | — | ADR-0065 | +| [ADR-0081](./0081-trusted-react-page-tier.md) | A trusted `kind:'react'` page tier — real React executed in the main tree, gated by a host capability; and re… | Accepted — 2026-06-30 — framework scope implemented (`html`/`react` page k… | 2026-06-30 | — | ADR-0065 | +| [ADR-0082](./0082-react-component-contract-governance.md) | Governing the react-tier component contract — spec is the protocol source of truth, the registry is a designe… | Accepted — 2026-06-30 | 2026-06-30 | — | — | +| [ADR-0084](./0084-application-builder-information-architecture.md) | Application-builder information architecture — four content pillars + Settings/Advanced | Accepted — 2026-07-01 | 2026-07-01 | — | — | +| [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) | Presentation intent on an object is declared as cross-surface semantic roles, never as per-surface hint block… | Accepted — 2026-07-16 — proposed 2026-07-02; execution complete through PR… | 2026-07-16 | *the `detail.` passthrough-block approac…* | — | +| [ADR-0086](./0086-authz-metadata-config-boundary-and-cross-package-composition.md) | Authorization metadata↔config boundary + cross-package composition — DEFINITION travels, ASSIGNMENT stays, pa… | Accepted — 2026-07-04 — substantially implemented; parts refined/supersede… | 2026-07-04 | — | ADR-0090, ADR-0094 | +| [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) | Metadata protocol upgrades for AI consumers — conversion over notification, executable migrations, machine-ve… | Accepted — 2026-07-04, #2582 · trued up to as-built 2026-07-15 (see Addend… | 2026-07-04 | — | — | +| [ADR-0088](./0088-metadata-kind-admission-and-retirement.md) | Metadata-kind admission test; retire `trigger`, `router`, `function`, `service` | Accepted — 2026-07-05 · Amended (2026-10-16, #23787 — #23128 ruling C′ ite… | 2026-07-05 | — | — | +| [ADR-0089](./0089-unify-visibility-predicate-naming.md) | Unify the conditional-visibility predicate under one name (`visibleWhen`), alias the rest | Accepted — 2026-07-14 — D1 + D2 + codemod + docs implemented in #2642/#290… | 2026-07-14 | — | — | +| [ADR-0090](./0090-permission-model-v2-concept-convergence.md) | Permission Model v2 — Concept Convergence, Final Naming, and AI-Authoring Safety | Accepted — 2026-07-09 | 2026-07-09 | ADR-0056 | — | +| [ADR-0091](./0091-grant-lifecycle-and-recertification.md) | Grant lifecycle — effective-dated assignments, delegation, break-glass, recertification substrate | Accepted | 2026-07-09 | — | — | +| [ADR-0092](./0092-sys-user-profile-field-delegation.md) | Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sysuser profile fields as the f… | Accepted | 2026-07-10 | — | — | +| [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) | Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle | Accepted — 2026-07-13 — implemented: `tenancy` kernel service (`plugin-aut… | 2026-07-13 | — | — | +| [ADR-0094](./0094-sys-permission-set-pure-projection.md) | Permission-Set Definitions Have One Authoritative Store — `syspermissionset` Becomes a Pure Projection | Accepted — 2026-07-14; D5 retired 2026-08-09 (see D5-R) · Amended (2026-10… | 2026-07-14 | — | — | +| [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) | Authorization Kernel Chain — Tenant Isolation as Layer 0, a Monotonic Posture Ladder, Capability-Derived Post… | Accepted — 2026-07-14 — implemented: D1 tenant Layer 0 (`plugin-security/s… | 2026-07-14 | — | — | +| [ADR-0096](./0096-execution-surface-identity-admission.md) | Execution-Surface Identity Admission — no data-engine call without an explicit principal or an explicit, audi… | Proposed — 2026-07-15 | 2026-07-15 | — | — | +| [ADR-0097](./0097-declarative-connector-instances.md) | Declarative Connector Instances — Provider-Bound `connectors:` Entries Materialized by Generic Executors | Accepted — 2026-07-15 — implemented in framework#2977 | 2026-07-15 | — | — | +| [ADR-0098](./0098-pinyin-search-companion-column.md) | Pinyin Search via a Locale-Gated Companion Column | Accepted | 2026-07-16 | — | — | +| [ADR-0099](./0099-posture-adjudicated-tiering-and-external-rung.md) | Posture-Adjudicated Tiering — one axis for tier decisions, the EXTERNAL rung's enforcement path, no explicit… | Accepted — 2026-07-18; proposed 2026-07-17 — P0 equivalence gate landed wi… | 2026-07-18 | — | — | +| [ADR-0100](./0100-credential-field-channels.md) | Credential Field Channels — `secret` (encrypted) and `password` (masked) | Accepted | 2026-07-18 | — | — | +| [ADR-0101](./0101-mcp-stdio-principal-admission.md) | MCP stdio Principal Admission — env-supplied API-key identity, fail-closed, no system bypass | Accepted — 2026-07-19 — v1 landed with this acceptance (#3246): key→EC thr… | 2026-07-19 | — | — | +| [ADR-0102](./0102-sandbox-cpu-budget-and-engine-variant.md) | Sandbox Execution Budget & Engine Variant — CPU-time budget with a wall ceiling; per-invocation sync WASM mod… | Accepted — 2026-07-19 — D1 + D2 landed: Phase 1 (#3295 / PR #3301) shipped… | 2026-07-19 | — | — | +| [ADR-0103](./0103-managedby-write-policy-and-engine-write-guard.md) | `managedBy` write policy — split the overloaded `system` bucket, enforce engine-owned writes | Accepted — (D5 completed in v17 — see the #3355 addendum at the end: | 2026-07-19 | — | — | +| [ADR-0104](./0104-field-runtime-value-shape-contract.md) | Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-a… | Accepted — 2026-07-22 — staged per D4. D1 landed (#3429), D2 | 2026-07-22 | — | — | +| [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) | Group Tenancy Posture — Organization Scope as a First-Class Authorization Dimension | Accepted — 2026-07-27; proposed 2026-07-25 — Phase 0/1 implemented (#3559)… | 2026-07-27 | — | — | +| [ADR-0106](./0106-metadata-plane-fls-object-schema-masking.md) | Metadata-Plane Field-Level Security — Per-Caller Masking of Object Schemas | Accepted — 2026-07-27; implemented 2026-08-08 — #3682 | 2026-07-27 | — | — | +| [ADR-0107](./0107-withdrawn-hook-body-write-set-static-gap.md) | Withdrawn — this number is retired and must not be reused | Withdrawn — 2026-07-28. This file is a tombstone, not a decision: nothing i… | 2026-07-28 | — | — | +| [ADR-0108](./0108-membership-grade-is-not-a-capability-channel.md) | Membership Grade Is Not a Capability Channel — the `sysmember.role` Vocabulary Is Closed | Accepted — 2026-07-28 — implemented in the same PR: the two `role` selects… | 2026-07-28 | — | — | +| [ADR-0109](./0109-ai-tool-authoring-model.md) | The AI tool authoring model — the default third-party path needs no tool records | Accepted — implemented (Phase 1) (2026-07-28; revised same day before acce… | 2026-07-28 | — | — | +| [ADR-0110](./0110-action-identity-and-declaration-admission.md) | An action's identity is its `name`; anything executable over a governed surface must have a declaration (the… | Accepted — implemented (2026-07-29) in protocol 17 (`@objectstack/spec@17.… | 2026-07-29 | — | — | +| [ADR-0111](./0111-record-share-management-authority-and-verb-boundary.md) | Record-share management authority and the verb boundary — sharing needs "who may manage a share" and "which v… | Accepted — 2026-07-30 — P0 + P1 + D8 (framework) implemented. P0 (D1/D2/D4… | 2026-07-30 | — | — | +| [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) | One error-code vocabulary — SCREAMINGSNAKE on the wire, a closed standard catalog plus a registered extension… | Accepted — 2026-07-30 | 2026-07-30 | — | — | +| [ADR-0113](./0113-required-write-contract-vs-column-constraint.md) | `required` is a write-time contract — the column constraint becomes its own, explicitly-authored axis | Accepted — 2026-07-30 — Q1/Q2/Q3 adjudicated same day (uniform semantics;… | 2026-07-30 | — | — | +| [ADR-0114](./0114-field-level-error-code-catalog.md) | Field-level error codes name the violated constraint — a closed lowercase catalog, and Zod stops leaking onto… | Accepted — 2026-07-30 | 2026-07-30 | — | — | +| [ADR-0115](./0115-plugin-dev-assembly-not-stub-table.md) | The stub table is retired — plugin-dev assembles real plugins, and an empty slot means the same thing in ever… | Accepted — 2026-07-30 | 2026-07-30 | — | — | +| [ADR-0116](./0116-plugin-ordering-declared-contract.md) | Plugin ordering is a declared contract — soft dependencies plus validated init-service requirements | Accepted — 2026-07-30 | 2026-07-30 | — | — | +| [ADR-0117](./0117-owning-business-unit-record-stamp.md) | 记录级业务单元归属(owning business unit) | Accepted — D1/D3 scoped(2026-08-05)—— 仅 D1(`ownership` 新增 | 2026-07-31 | — | — | +| [ADR-0118](./0118-non-user-actor-contract.md) | 非用户 actor 的平台契约 —— null 表示、fail-closed、显式 `isSystem` | Accepted — (2026-08-02,维护者按四轴框架裁决:平台长远合理性 / 防 AI 作者静默犯错 / 实际业务需求 / 创业阶段不扩边… | 2026-08-02 | — | — | +| [ADR-0119](./0119-plugin-reachable-transactions-and-honest-atomic-batch.md) | Multi-write atomicity is reachable through the contract, `atomic` means atomic or refuses, and migrations too… | Accepted — 2026-08-02 · Amended (2026-08-06, #5351/#5696 — D1's two caveat… | 2026-08-02 | — | — | +| [ADR-0120](./0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md) | Uniqueness scope is an explicit vocabulary — declared-index organization scoping and NULL-safe per-organizati… | Accepted — 2026-08-04, maintainer decision on #4986 / #5030; proposed same… | 2026-08-04 | — | — | +| [ADR-0121](./0121-declarative-endpoint-routing-namespace-and-channel-split.md) | 声明式端点的路由归属与通道分工 —— 命名空间制、actions/apis 按调用方分工、`type: flow` 保留 | Accepted — (2026-08-04,维护者裁决;本记录只立规则,执行体由 #5040 的 E 系列实施) | 2026-08-04 | — | — | +| [ADR-0122](./0122-schema-type-alias-naming-convention.md) | One naming family for schema type aliases — bare name is the author state, `XParsed` is the parsed state | Accepted — 2026-08-06 — ruled by the maintainer on 2026-08-06 on the #5551… | 2026-08-06 | — | — | +| [ADR-0123](./0123-no-active-organization-session-semantics.md) | An authenticated session with no active organization is a legal state — reads resolve to nothing, tenant-scop… | Accepted — 2026-08-13 | 2026-08-13 | — | — | +| [ADR-0124](./0124-server-enforces-client-is-courtesy.md) | The server is the enforcement point; client-side gating is a usability courtesy | Accepted — 2026-08-18 — retroactive recording of an already-practised rule… | 2026-08-18 | — | — | +| [ADR-0125](./0125-release-approval-gate-replaces-the-typed-version.md) | The human act that authorises a release is the environment approval, not a typed version string | Accepted — 2026-08-20 — accepted by the merge that landed it on `main` (#1… | 2026-08-20 | — | — | +| [ADR-0126](./0126-packaged-metadata-customization-model.md) | The platform customization model for packaged metadata — three regimes, one activation ledger | Amended — 2026-09-04 by ADR-0131 D6 — maintainer, verbatim: 「所以 ADR-0126… | 2026-09-04 | — | — | +| [ADR-0127](./0127-authorization-cache-invalidation-contract-and-ttl-bound.md) | No authorization answer is cached without a declared invalidation contract and a TTL bound | Accepted — 2026-08-25 — maintainer ruling on #11633, verbatim 「接受你的建议,继续」,… | 2026-08-25 | — | — | +| [ADR-0128](./0128-producer-discriminated-aad-for-cryptocontext.md) | The AAD binding on `CryptoContext` is producer-discriminated | Accepted — 2026-09-07 — accepted by the maintainer's reply of 2026-09-07 (… | 2026-09-07 | — | — | +| [ADR-0129](./0129-object-name-is-the-canonical-id.md) | Object `name` is the canonical id — literal module prefix, no separate `namespace` field | Accepted — (2026-08-29; maintainer ruling on #12918, option 甲 — recorded a… | 2026-08-29 | — | — | +| [ADR-0130](./0130-release-artifact-as-co-ownership-boundary.md) | The release artifact is the co-ownership boundary — one artifact, N packages | Accepted — 2026-09-01 — accepted by the merge that landed it on `main` (#1… | 2026-09-01 | — | — | +| [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) | Organization ownership is total — no NULL `organizationid`; declared metadata stays in code; a row exists onl… | Accepted — 2026-09-04 — accepted by the merge that landed it on `main` (#1… | 2026-09-04 | — | — | +| [ADR-0132](./0132-multi-organization-runtime-is-open-core.md) | The multi-organization runtime is open core — single-database organization isolation ships open; only the ent… | Accepted — 2026-09-07 — accepted by the merge that landed it on `main` | 2026-09-07 | — | — | +| [ADR-0133](./0133-org-management-open-basics.md) | Organization management — the open basics; a mirror of cloud ADR-0081 D1/D3/D4 in the repository whose code e… | Accepted — 2026-09-07 — accepted by the merge that landed it on `main` (#1… | 2026-09-07 | — | — | +| [ADR-0134](./0134-env-side-scim-provisioning.md) | Env-side SCIM provisioning — `@better-auth/scim` is the mechanism, and this platform writes the deactivation | Proposed — 2026-09-07. This file is a MIRROR of the open, mechanism half of | 2026-09-07 | — | — | +| [ADR-0135](./0135-identity-and-access-architecture.md) | Identity and access architecture — the open half, mirrored from cloud ADR-0024 | Accepted — The DECISION is — it is `objectstack-ai/cloud` ADR-0024, accept… | 2026-06-25 | — | — | +| [ADR-0137](./0137-predicate-fault-semantics-are-contract.md) | A predicate's FAULT semantics are part of the protocol — loud at submit, fail-open at render, and a blank pre… | Accepted — 2026-09-18 — this record declares; it implements nothing. D1's… | 2026-09-18 | — | — | +| [ADR-0138](./0138-guest-model-anonymous-principal-doors-grants-and-organization.md) | The guest model — one anonymous principal, a closed list of doors, one grants channel, one organization rule,… | Accepted — 2026-10-13, accepted by the maintainer's approval of the pull r… | 2026-10-13 | — | — | +| [ADR-0139](./0139-engine-internal-semi-join-leaf-readable-parent-ids.md) | One engine-internal semi-join leaf — "the ids of object X this caller may read" — pushed down by ObjectQL, ne… | Accepted — 2026-10-11, accepted by the merge that landed it on `main` | 2026-10-11 | — | — | +| [ADR-0141](./0141-inter-app-dependency-declared-checked-before-install.md) | An app may depend on another installed app — declared in `manifest.dependencies`, checked once before install… | Proposed — 2026-10-25; first draft 2026-10-24. `docs/adr/` is Tier H (Prim… | 2026-10-25 | — | — | +| [ADR-0142](./0142-ownership-queue-membership-claim-and-assignment-rules.md) | An ownership queue is a platform primitive — a queue entity, membership through the existing recipient kinds,… | Proposed — 2026-10-25. `docs/adr/` is Tier H (Prime Directive #14): this r… | 2026-10-25 | — | — | + +## ObjectUI registry — `ui/docs/adr/` (`ui-` prefix) + +objectui's own decision sequence, imported with the ui merge and kept in place (#26080). Numbers overlap the registry above and name different decisions; cite one as `ObjectUI ADR-NNNN`. + +| Number | Title | Status | Date | Supersedes | Superseded by | +|---|---|---|---|---|---| +| [ui-0001](../../ui/docs/adr/0001-master-detail-subform.md) | Master-Detail Subform (parent + line items, entered and viewed together) | Accepted — implementing (2026-06-05) | 2026-06-05 | — | — | +| [ui-0034](../../ui/docs/adr/0034-unified-runtime-metadata-persistence.md) | Runtime editing reuses studio's per-item draft → publish → version model (retire `sysview` / `sysreport` / `s… | Implemented — 2026-06-06 — see #1533 (chrome + view-create seam) and #1534 (r… | 2026-06-06 | — | — | +| [ui-0036](../../ui/docs/adr/0036-field-conditional-rules.md) | Field-level conditional rules (visibleWhen / readonlyWhen / requiredWhen) | Accepted — implementing (2026-06-07) | 2026-06-07 | — | — | +| [ui-0053](../../ui/docs/adr/0053-list-view-navigation-modes.md) | List-view navigation — two mutually-exclusive modes | Accepted — 2026-06-18; revised after implementation spike | 2026-06-18 | ADR-0047 | — | +| [ui-0054](../../ui/docs/adr/0054-ui-testability-contract.md) | UI testability contract | Proposed — 2026-06-22 | 2026-06-22 | — | — | +| [ui-0055](../../ui/docs/adr/0055-parameterized-bare-data-surface.md) | Parameterized bare data surface — the third list context | Accepted — 2026-07-05 | 2026-07-05 | — | — | +| [ui-0056](../../ui/docs/adr/0056-permission-editing-studio.md) | Permission editing belongs in Studio — Setup keeps user management and assignment | Accepted — implemented in objectstack-ai/objectui#2403 (2026-07-13). Revis… | 2026-07-13 | — | — | +| [ui-0057](../../ui/docs/adr/0057-console-ai-chat-one-conversation-docked.md) | Console AI chat is one system — surfaces are views over one conversation, docked as the canonical shell | Accepted — 2026-07-13 — IMPLEMENTED IN FULL. P1+P2 shipped | 2026-07-13 | — | — | +| [ui-0058](../../ui/docs/adr/0058-cascading-select-options.md) | Cascading & role-gated select options (`option.visibleWhen` + `dependsOn`) | Accepted — implemented (2026-07-15) | 2026-07-15 | — | — | +| [ui-0059](../../ui/docs/adr/0059-action-params-shared-field-widgets.md) | Action params render through the shared form field-widget renderer | Accepted — implemented (2026-07-19) | 2026-07-19 | — | — | +| [ui-0060](../../ui/docs/adr/0060-product-neutral-renderer.md) | objectui is a product-neutral renderer | Accepted — maintainer ruling (2026-10-12); this record and its ratchet lan… | 2026-10-12 | — | — | diff --git a/package.json b/package.json index da1cb5316ec..80ac599ac5f 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,8 @@ "check:workspace-manifest-cycles": "node scripts/check-workspace-manifest-cycles.mjs --self-test && node scripts/check-workspace-manifest-cycles.mjs", "check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs", "check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs", + "adr:index": "node scripts/adr-index.mjs", + "check:adr-index": "node scripts/adr-index.mjs --self-test && node scripts/adr-index.mjs --report", "check:symbol-anchors": "node scripts/symbol-anchors.mjs --self-test", "check:adr-symbol-anchors": "node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs", "check:scripts-symbol-anchors": "node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs", diff --git a/scripts/adr-index.mjs b/scripts/adr-index.mjs new file mode 100644 index 00000000000..676d69d2477 --- /dev/null +++ b/scripts/adr-index.mjs @@ -0,0 +1,773 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// adr-index — `docs/adr/README.md`, the index of this repository's decision +// records, GENERATED from the records themselves (#26076, maintainer ruling A, +// 门禁不加). +// +// node scripts/adr-index.mjs # (re)write docs/adr/README.md from the records +// node scripts/adr-index.mjs --check # exit 1 when the committed index is stale or a record is unreadable +// node scripts/adr-index.mjs --report # REPORT-ONLY (the lint ADR step's call): `::warning::` lines, exit 0 always +// node scripts/adr-index.mjs --json # the parsed registry, machine-readable, on stdout +// node scripts/adr-index.mjs --self-test # verify the generator itself +// +// ## The failure this exists for +// +// 143 decision records and no list of them. AGENTS.md Prime Directive 13 sends +// every author to "grep the ADRs for the surface you are touching", and a +// registry with no index is grepped by filename guesswork — a decision nobody +// can find is a decision that will be reversed. Four numbers are carried by more +// than one file (0006 by three versions of one decision; 0010, 0019 and 0057 by +// two unrelated decisions each) and nothing listed them side by side. The +// records spelled their status line four ways (`**Status**:`, `- **Status:**`, +// `## Status`, `- **状态**:`), so "is this ADR still in force?" was a question +// two greps answered differently — the decision card measured 104 Accepted by +// one instrument and 98 by another, over the same files. +// +// ## What it does, and what it deliberately does not +// +// - The index is NEVER hand-written. The record's `**Status**:` line is the +// single fact and this script projects it; a hand edit to README.md is +// overwritten by the next run, by design. Regenerate with `pnpm adr:index`. +// - ONE status-line spelling: a line that STARTS `**Status**:` — bold key, +// colon outside the bold, nothing before it. A record without one is listed +// as UNPARSED and reported, never guessed from a heading or a bullet: a +// guess is a second spelling with extra steps, and the one-instrument +// property is the whole point. +// - The status KEYWORD is read off the value (`STATUS_KEYWORDS` below: +// `Accepted`, `Proposed`, `Superseded` …); the rest of the line is free +// prose and stays so. `check-adr-anchors.mjs`'s header says why the status +// line is not that gate's business, and this script agrees: it REPORTS a +// keyword it does not know, it does not refuse the record. +// - Shared numbers are listed side by side and NEVER renumbered or moved +// (Prime Directive 13): the three colliding pairs under the stem-qualified +// id `check-adr-anchors.mjs` resolves to one record +// (`ADR-0057-system-data-lifecycle-and-retention`), the 0006 versions as one +// row group. Each such record's header names its sibling(s) too. +// - `ui/docs/adr/` — objectui's own sequence, imported with the cutover — is +// indexed under the `ui-` prefix with its files untouched (#26080, ruling A). +// Its numbers overlap this registry's and mean something else, so an index +// row says `ui-0057`, never `ADR-0057`, and a citation of one keeps the +// `ObjectUI ADR-NNNN` spelling the anchor checker recognises as another +// registry's. +// - REPORT-ONLY in CI. The lint ADR step calls `--report`, which prints +// `::warning::` annotations for a stale index or an unreadable record and +// exits 0 whatever it finds; `--check` is the gate's spelling, kept for the +// day a maintainer names one (pm-dispatch ④: 新增门禁默认否, and the ruling +// said so explicitly). +// - `docs/adr/PRIORITIZATION.md`, the retired review that used to sit in the +// registry, left when this index arrived; the one rule it still carried — +// status hygiene on landing an implementation — lives in `CONVENTIONS` +// below, where every reader of the index meets it. +// +// ## Self-test shape +// +// Battery names and floors, a module-level verdict flag, and a dispatch that +// refuses a self-test that returned without its verdict — copied, not +// imported, from `scripts/check-agent-model-declared.mjs` (AGENTS.md, "Writing +// a --self-test"). `--report` runs the same batteries first and turns a red +// one into a warning rather than a red step: a broken generator must not red +// the lane the maintainer kept advisory, and must not go quiet either. + +import { appendFileSync, existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; + +export const ADR_DIR = 'docs/adr'; +export const UI_ADR_DIR = 'ui/docs/adr'; +export const INDEX_PATH = 'docs/adr/README.md'; +const SELF_PATH = 'scripts/adr-index.mjs'; + +/** `0057-system-data-lifecycle-and-retention.md`, `0006-project-environment-split.v4.md` — same grammar as check-adr-anchors.mjs. */ +export const ADR_FILENAME = /^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.v(\d+))?\.md$/; +/** Markdown in a registry directory that is not a record: this index itself. */ +export const NON_RECORD_FILES = new Set(['README.md']); +/** THE status-line spelling. Anything else is not a status line. */ +export const STATUS_LINE = /^\*\*Status\*\*:[ \t]*(.*)$/; +/** The keyword vocabulary, multi-word entries first so `Partially implemented` is not read as nothing. */ +export const STATUS_KEYWORDS = Object.freeze([ + 'Partially implemented', + 'Accepted', + 'Proposed', + 'Draft', + 'Superseded', + 'Withdrawn', + 'Deferred', + 'Implemented', + 'Revised', + 'Amended', +]); +/** The keyword column of a record whose status line is missing or whose value names no keyword. */ +export const UNPARSED = 'Unparsed'; +/** A record's header: the lines read for `**Key**:` fields (date, supersedes, superseded by). */ +const HEADER_WINDOW = 40; +/** How far into a status value a keyword may sit — `The DECISION is Accepted …` counts, a keyword in sentence three does not. */ +const KEYWORD_WINDOW = 80; +const ISO_DATE = /\d{4}-\d{2}-\d{2}/; +const TOMBSTONE_SLUG_PREFIX = 'withdrawn-'; + +// ── The self-test's own battery roster and floor ─────────────────────────── +const SELF_TEST_BATTERIES = Object.freeze({ + 'record parsing: filename, title, status, date (#26076)': 12, + 'the one status-line spelling: every other spelling is Unparsed, never guessed': 5, + 'supersession references are extracted, not invented': 8, + 'shared numbers and versions group side by side, registries never merge': 6, + 'rendering is deterministic and lists both registries': 7, + 'drift verdicts: --check reds, --report never does': 4, + 'Live tree: the generator reads the real registry (the records themselves are --report\'s business)': 4, +}); +const SELF_TEST_BATTERY_FLOOR = 7; +const UNATTRIBUTED_BATTERY = '(no battery open)'; +let selfTestReachedVerdict = false; + +// ── Parsing ──────────────────────────────────────────────────────────────── + +/** + * `**Key**: value` / `**Key:** value` / `- **Key**: value` header fields of one record. + * @param {string[]} lines + * @returns {{ key: string, value: string }[]} + */ +export function headerFields(lines) { + const out = []; + for (const line of lines) { + const m = /^(?:-[ \t]*)?\*\*([^*]+?)(?:\*\*[ \t]*[::]|[::]\*\*)[ \t]*(.*)$/.exec(line); + if (m) out.push({ key: m[1].trim(), value: m[2].trim() }); + } + return out; +} + +/** + * The status keyword of a status value: the vocabulary entry that appears + * EARLIEST in the (markdown-stripped) value, within the keyword window; ties go + * to the longer entry. `Unparsed` when none does — reported, never guessed. + * @param {string} value + */ +export function statusKeywordOf(value) { + const plain = value.replace(/[*_`>]/g, ' ').replace(/\s+/g, ' ').trim(); + let best = null; + for (const keyword of STATUS_KEYWORDS) { + const index = plain.indexOf(keyword); + if (index < 0 || index > KEYWORD_WINDOW) continue; + if (best === null || index < best.index || (index === best.index && keyword.length > best.keyword.length)) { + best = { keyword, index }; + } + } + return best ? best.keyword : UNPARSED; +} + +/** + * The decision references a free-prose value names: `ADR-0063`, `ADR-0006 v4`, + * `cloud ADR-0025`, `ObjectUI ADR-0001`, a `./0063-two-kernel….md` link or a + * bare `0006-project-environment-split.v4.md` filename, a bare `v2`. Nothing is + * invented: a value with none of these yields `[]`, and the renderer shows a + * snippet of the prose instead. + * @param {string} value + * @returns {string[]} + */ +export function refsIn(value) { + const refs = []; + const push = (r) => { + if (!refs.includes(r)) refs.push(r); + }; + const re = + /\b(cloud|objectui|object-ui)[ \t]+ADR-(\d{4})\b|\bADR-(\d{4})(?:-[a-z0-9]+)*(?:\.v(\d+))?(?:[ \t]+v(\d+)\b)?|(?:\.\/|\b)(\d{4})-[a-z0-9]+(?:-[a-z0-9]+)*(?:\.v(\d+))?\.md\b|\bv(\d)\b(?!\.\d)/gi; + for (const m of value.matchAll(re)) { + if (m[1]) push(`${m[1].toLowerCase() === 'cloud' ? 'cloud' : 'ObjectUI'} ADR-${m[2]}`); + else if (m[3]) push(`ADR-${m[3]}${m[4] || m[5] ? ` v${m[4] ?? m[5]}` : ''}`); + else if (m[6]) push(`ADR-${m[6]}${m[7] ? ` v${m[7]}` : ''}`); + else if (m[8]) push(`v${m[8]}`); + } + return dropBareVersions(refs); +} + +/** A bare `vN` already named by an `ADR-NNNN vN` in the same list is one reference, not two. */ +function dropBareVersions(refs) { + return refs.filter((r) => !/^v\d$/.test(r) || !refs.some((o) => o.endsWith(` ${r}`))); +} + +/** Markdown → plain text, for a table cell: links to their text, emphasis and quotes dropped, whitespace collapsed. */ +export function plainText(markdown) { + return markdown + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_>]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** A table cell: pipes escaped, long values cut with an ellipsis. */ +export function cell(text, max = 72) { + const t = plainText(text).replace(/\|/g, '\\|'); + return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t; +} + +/** + * Parse one record. Pure over the filename and the text so the self-test drives + * it with fixtures. + * @param {string} name the filename, e.g. `0057-system-data-lifecycle-and-retention.md` + * @param {string} text + * @returns {null | { + * file: string, number: string, slug: string, stem: string, version: string|null, tombstone: boolean, + * title: string, statusValue: string|null, keyword: string, date: string|null, + * supersedes: string[], supersedesText: string, supersededBy: string[], supersededByText: string, + * }} null when the filename is not a record's + */ +export function parseRecord(name, text) { + const m = ADR_FILENAME.exec(name); + if (!m) return null; + const [, number, slug, version] = m; + const lines = text.split('\n'); + const titleLine = lines.find((l) => /^#[ \t]+\S/.test(l)) ?? ''; + const title = + titleLine + .replace(/^#[ \t]+/, '') + .replace(/^ADR-\d{4}[ \t]*[::—–-][ \t]*/, '') + .trim() || `(untitled) ${name}`; + let statusValue = null; + for (const line of lines) { + const s = STATUS_LINE.exec(line); + if (s) { + statusValue = s[1].trim(); + break; + } + } + const keyword = statusValue === null ? UNPARSED : statusKeywordOf(statusValue); + const header = lines.slice(0, HEADER_WINDOW); + const fields = headerFields(header); + const dateField = fields.find((f) => /^(date|decided|日期)$/i.test(f.key)); + const date = (dateField && ISO_DATE.exec(dateField.value)?.[0]) ?? (statusValue && ISO_DATE.exec(statusValue)?.[0]) ?? null; + + const supersedesFields = fields.filter((f) => /supersedes/i.test(f.key) && !/superseded by/i.test(f.key)); + const supersedes = uniq(supersedesFields.flatMap((f) => refsIn(f.value))); + const supersedesText = supersedesFields.map((f) => f.value).join(' · '); + + const supersededByFields = fields.filter((f) => /superseded by/i.test(f.key)); + const afterSuperseded = (s) => s.replace(/^[\s\S]*?superseded(?:[ \t]+in[ \t]+part)?(?:[^a-z0-9]{0,40}?)\bby\b/i, '').slice(0, 160); + const supersededBy = uniq([ + ...supersededByFields.flatMap((f) => refsIn(f.value)), + ...(statusValue && /\bsuperseded\b/i.test(statusValue) ? refsIn(afterSuperseded(statusValue)) : []), + ...header.filter((l) => /^>[ \t]*\*\*[^\n]*\bsuperseded\b/i.test(l)).flatMap((l) => refsIn(afterSuperseded(l))), + ]); + const supersededByText = supersededByFields.map((f) => f.value).join(' · '); + + return { + file: name, + number, + slug, + stem: `${number}-${slug}`, + version: version ?? null, + tombstone: slug.startsWith(TOMBSTONE_SLUG_PREFIX), + title, + statusValue, + keyword, + date, + supersedes, + supersedesText, + supersededBy, + supersededByText, + }; +} + +function uniq(list) { + return dropBareVersions([...new Set(list)]); +} + +/** Sort: by number, then the unversioned file, then versions numerically. */ +export function byNumberThenVersion(a, b) { + if (a.number !== b.number) return a.number < b.number ? -1 : 1; + if (a.stem !== b.stem) return a.stem < b.stem ? -1 : 1; + return (a.version === null ? -1 : Number(a.version)) - (b.version === null ? -1 : Number(b.version)); +} + +/** + * Read a registry directory. Filenames that parse as records are parsed; the + * index itself is skipped; anything else is returned as `foreign` so the + * report can name it. + * @param {string} root + * @param {string} dir + * @returns {{ records: ReturnType[], foreign: string[] }} + */ +export function readRegistry(root, dir) { + const abs = join(root, dir); + if (!existsSync(abs)) return { records: [], foreign: [] }; + const records = []; + const foreign = []; + for (const name of readdirSync(abs).sort()) { + if (!name.endsWith('.md') || NON_RECORD_FILES.has(name)) continue; + const rec = parseRecord(name, readFileSync(join(abs, name), 'utf8')); + if (rec) records.push(rec); + else foreign.push(name); + } + records.sort(byNumberThenVersion); + return { records, foreign }; +} + +/** + * The numbers more than one file carries, each with its kind: `versions` when + * every file repeats ONE stem (0006), `collision` when two stems share it + * (0010, 0019, 0057). Pure over parsed records. + * @param {ReturnType[]} records + * @returns {{ number: string, kind: 'versions' | 'collision', records: ReturnType[] }[]} + */ +export function sharedNumbers(records) { + const byNumber = new Map(); + for (const r of records) { + if (!byNumber.has(r.number)) byNumber.set(r.number, []); + byNumber.get(r.number).push(r); + } + const out = []; + for (const [number, group] of [...byNumber].sort(([a], [b]) => (a < b ? -1 : 1))) { + if (group.length < 2) continue; + const stems = new Set(group.map((r) => r.stem)); + out.push({ number, kind: stems.size === 1 ? 'versions' : 'collision', records: [...group].sort(byNumberThenVersion) }); + } + return out; +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +/** The fixed text at the top of the index: the rules a reader of the registry needs, the status-hygiene rule among them. */ +export const CONVENTIONS = `## Conventions + +- **One registry per repository, numbers never reassigned, files never moved** (AGENTS.md Prime Directive 13). A reversed decision is a new ADR or an amended status line, never a quiet changeset. Before changing behaviour in territory an ADR governs, grep this index for the surface. +- **The status line is the single fact.** Every record carries, right under its title, one line that starts \`**Status**: \` followed by a keyword — ${STATUS_KEYWORDS.map((k) => `\`${k}\``).join(', ')} — and any free prose after it (dates, implementation notes, amendments). This index is generated from that line by \`scripts/adr-index.mjs\`; a record whose line is missing or names no keyword is listed as **${UNPARSED}** and reported, never guessed. +- **Status hygiene.** A PR that lands an ADR's implementation flips that ADR's \`**Status**:\` line to \`Accepted — implemented\` with one line of evidence (key file + test), in the same PR. (The rule the retired \`PRIORITIZATION.md\` review carried; it lives here now.) +- **Shared numbers are cited by stem.** Where two unrelated records carry one number, a bare \`ADR-NNNN\` is one of two; the unambiguous id is the record's filename stem prefixed \`ADR-\` (\`ADR-0057-system-data-lifecycle-and-retention\`), which \`scripts/check-adr-anchors.mjs\` resolves to that one record. Versions of one decision share a stem and add \`.vN\`; cite a version as \`ADR-0006 v4\` in prose or \`ADR-0006-project-environment-split.v4\` where a checker reads it. +- **The ObjectUI registry (\`ui/docs/adr/\`) keeps its own sequence**, imported with the ui merge. Its numbers overlap this registry's and mean something else; this index lists them under the \`ui-\` prefix, and a citation of one is spelled \`ObjectUI ADR-NNNN\`, which the anchor checker recognises as another registry's. +- **Decisions that draw the open/closed or commercial boundary live in \`objectstack-ai/cloud\`** and are cited from here as \`cloud ADR-NNNN\`, never as a bare number. + +`; + +const BANNER = (rootDir) => + `> **Generated** by \`node scripts/adr-index.mjs\` from the records in \`${rootDir}/\` — do not edit by hand; change a record's \`**Status**:\` line and regenerate (\`pnpm adr:index\`). The lint ADR step reports a stale index as a warning and never fails on it.\n\n`; + +function numberCell(r, { shared, relDir, prefix }) { + const link = `${relDir}/${r.file}`; + if (prefix) return `[${prefix}${r.number}](${link})`; + if (shared?.kind === 'collision') return `[ADR-${r.stem}](${link})`; + if (r.version !== null) return `[ADR-${r.number} v${r.version}](${link})`; + return `[ADR-${r.number}](${link})`; +} + +function statusCell(r) { + if (r.statusValue === null) return `**${UNPARSED}** — no \`**Status**:\` line`; + if (r.keyword === UNPARSED) return `**${UNPARSED}** — ${cell(r.statusValue, 60)}`; + const plain = plainText(r.statusValue); + const at = plain.indexOf(r.keyword); + const detail = (at >= 0 ? plain.slice(0, at) + plain.slice(at + r.keyword.length) : plain) + .replace(/^[\s—–:;,.-]+/, '') + .replace(/^\(([^()]*)\)/, '$1') + .replace(/^[\s—–:;,.-]+/, '') + .trim(); + return detail ? `${r.keyword} — ${cell(detail, 64)}` : r.keyword; +} + +function refsCell(refs, text) { + if (refs.length) return refs.join(', '); + const plain = plainText(text); + if (!plain || /^(—|–|-|none|nothing|n\/a)(?![a-z0-9])/i.test(plain)) return '—'; + return `*${cell(plain, 40)}*`; +} + +function recordRow(r, opts) { + return `| ${numberCell(r, opts)} | ${cell(r.title, 110)} | ${statusCell(r)} | ${r.date ?? '—'} | ${refsCell( + r.supersedes, + r.supersedesText, + )} | ${refsCell(r.supersededBy, r.supersededByText)} |`; +} + +const TABLE_HEAD = '| Number | Title | Status | Date | Supersedes | Superseded by |\n|---|---|---|---|---|---|\n'; + +function summaryTable(records) { + const counts = new Map(); + for (const r of records) counts.set(r.keyword, (counts.get(r.keyword) ?? 0) + 1); + const rows = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)); + return ( + '| Status | Records |\n|---|---|\n' + + rows.map(([k, n]) => `| ${k === UNPARSED ? `**${k}**` : k} | ${n} |`).join('\n') + + `\n| **Total** | **${records.length}** |\n` + ); +} + +/** + * The whole index, as text. Pure over the two parsed registries — the same + * input renders the same bytes, which is what `--check` compares. + * @param {{ root: ReturnType[], ui: ReturnType[] }} registries + */ +export function renderIndex({ root, ui }) { + const rootSorted = [...root].sort(byNumberThenVersion); + const uiSorted = [...ui].sort(byNumberThenVersion); + const shared = sharedNumbers(rootSorted); + const sharedOf = new Map(shared.flatMap((s) => s.records.map((r) => [r.file, s]))); + let out = '# Architecture Decision Records — index\n\n'; + out += BANNER(ADR_DIR); + out += CONVENTIONS; + + out += '## Summary\n\n'; + out += `${rootSorted.length} records under \`${ADR_DIR}/\` (${uniq(rootSorted.map((r) => r.number)).length} numbers), ${uiSorted.length} under \`${UI_ADR_DIR}/\` (listed separately below).\n\n`; + out += summaryTable(rootSorted) + '\n'; + + out += '## Shared numbers\n\n'; + if (shared.length === 0) out += 'None — every number names one file.\n\n'; + else { + out += + 'Listed side by side, never renumbered (Prime Directive 13). A **collision** is two unrelated decisions under one number — cite each by its stem-qualified id; **versions** are one decision revised under its own number.\n\n'; + for (const s of shared) { + const kind = s.kind === 'collision' ? `${s.records.length} unrelated decisions — a collision` : `one decision, ${s.records.length} versions`; + out += `### ADR-${s.number} — ${kind}\n\n`; + const current = s.kind === 'versions' ? s.records[s.records.length - 1] : null; + if (current) { + out += `A bare \`ADR-${s.number}\` names the decision; its current text is [${current.file}](./${current.file}). Cite one revision by its version or its stem.\n\n`; + } + out += '| Cite as | File | Title | Status | Date |\n|---|---|---|---|---|\n'; + for (const r of s.records) { + const stemId = r.version === null ? `ADR-${r.stem}` : `ADR-${r.stem}.v${r.version}`; + const citeAs = + s.kind === 'collision' + ? `\`ADR-${r.stem}\`` + : r === current + ? `\`ADR-${r.number}\` (current) / \`ADR-${r.number} v${r.version}\` / \`${stemId}\`` + : r.version === null + ? `\`${stemId}\` (unversioned file, superseded)` + : `\`ADR-${r.number} v${r.version}\` / \`${stemId}\``; + out += `| ${citeAs} | [${r.file}](./${r.file}) | ${cell(r.title, 90)} | ${statusCell(r)} | ${r.date ?? '—'} |\n`; + } + out += '\n'; + } + } + + out += `## Records — \`${ADR_DIR}/\`\n\n`; + out += TABLE_HEAD; + for (const r of rootSorted) out += recordRow(r, { shared: sharedOf.get(r.file), relDir: '.', prefix: '' }) + '\n'; + out += '\n'; + + out += `## ObjectUI registry — \`${UI_ADR_DIR}/\` (\`ui-\` prefix)\n\n`; + out += + `objectui's own decision sequence, imported with the ui merge and kept in place (#26080). Numbers overlap the registry above and name different decisions; cite one as \`ObjectUI ADR-NNNN\`.\n\n`; + if (uiSorted.length === 0) out += 'No records.\n'; + else { + out += TABLE_HEAD; + for (const r of uiSorted) out += recordRow(r, { shared: null, relDir: `../../${UI_ADR_DIR}`, prefix: 'ui-' }) + '\n'; + } + return out; +} + +// ── Verdicts ─────────────────────────────────────────────────────────────── + +/** + * What a run has to say about the tree: the records that did not parse, the + * files in a registry that are not records, and whether the committed index + * equals the rendering. Pure, so the self-test can drive it. + * @param {{ root: { records: any[], foreign: string[] }, ui: { records: any[], foreign: string[] } }} registries + * @param {string | null} committed the index file's current text, or null when absent + * @returns {{ expected: string, stale: boolean, problems: string[] }} + */ +export function verdict(registries, committed) { + const expected = renderIndex({ root: registries.root.records, ui: registries.ui.records }); + const problems = []; + for (const [dir, reg] of [ + [ADR_DIR, registries.root], + [UI_ADR_DIR, registries.ui], + ]) { + for (const r of reg.records) { + if (r.statusValue === null) problems.push(`${dir}/${r.file}: no \`**Status**:\` line — listed as ${UNPARSED}`); + else if (r.keyword === UNPARSED) { + problems.push(`${dir}/${r.file}: status value names no keyword (${STATUS_KEYWORDS.join(' | ')}) — listed as ${UNPARSED}: "${r.statusValue.slice(0, 60)}"`); + } + } + for (const f of reg.foreign) problems.push(`${dir}/${f}: not a record filename (\`NNNN-kebab-slug.md\`) and not the index — left out of the index`); + } + if (registries.root.records.length === 0) problems.push(`${ADR_DIR}/: no records found — run from the repository root`); + return { expected, stale: committed !== expected, problems }; +} + +function readRegistries(root) { + return { root: readRegistry(root, ADR_DIR), ui: readRegistry(root, UI_ADR_DIR) }; +} + +function readCommitted(root) { + const p = join(root, INDEX_PATH); + return existsSync(p) ? readFileSync(p, 'utf8') : null; +} + +// ── Modes ────────────────────────────────────────────────────────────────── + +function modeWrite(root) { + const v = verdict(readRegistries(root), readCommitted(root)); + writeFileSync(join(root, INDEX_PATH), v.expected); + for (const p of v.problems) console.log(`adr-index: note — ${p}`); + console.log(`adr-index: ${v.stale ? 'wrote' : 'unchanged'} ${INDEX_PATH} (${v.expected.split('\n').length} lines).`); + return 0; +} + +function modeCheck(root) { + const v = verdict(readRegistries(root), readCommitted(root)); + for (const p of v.problems) console.error(`adr-index: ✗ ${p}`); + if (v.stale) { + console.error(`adr-index: ✗ ${INDEX_PATH} is stale — run \`node ${SELF_PATH}\` (pnpm adr:index) and commit the result.`); + } + if (v.stale || v.problems.length) return 1; + console.log(`adr-index: OK — ${INDEX_PATH} is current and every record parsed.`); + return 0; +} + +function modeReport(root) { + // Report-only by construction: every finding below is a warning annotation, + // and the exit code is 0 whatever was found (#26076, ruling A: 门禁不加). + const lines = []; + const warn = (file, message) => { + console.log(`::warning file=${file}::adr-index: ${message}`); + lines.push(`- \`${file}\`: ${message}`); + }; + const st = runSelfTest(); + if (st.failures.length) { + warn(SELF_PATH, `self-test FAILED (${st.failures.length} of ${st.checked}) — the index generator is broken; fix it before trusting the index: ${st.failures[0].slice(0, 200)}`); + } + let v; + try { + v = verdict(readRegistries(root), readCommitted(root)); + } catch (e) { + warn(SELF_PATH, `could not read the registries — ${e && e.message ? e.message : e}`); + v = null; + } + if (v) { + for (const p of v.problems) warn(p.split(':')[0], p.slice(p.indexOf(':') + 2)); + if (v.stale) warn(INDEX_PATH, `stale — a record changed and the index did not; run \`pnpm adr:index\` and commit (report-only, not a gate: #26076)`); + } + const ok = lines.length === 0; + console.log( + ok + ? `adr-index --report: OK — ${INDEX_PATH} is current, every record parsed, self-test ${st.checked} assertions green. Report-only: this line never reds the step.` + : `adr-index --report: ${lines.length} warning(s) above. Report-only: exit 0 by design (#26076, 门禁不加); \`node ${SELF_PATH} --check\` is the gate's spelling if one is ever named.`, + ); + if (process.env.GITHUB_STEP_SUMMARY) { + try { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `### ADR index (report-only)\n\n${ok ? `✓ \`${INDEX_PATH}\` current, every record parsed.` : lines.join('\n')}\n\n`, + ); + } catch { + /* the summary is a courtesy, never a verdict */ + } + } + return 0; +} + +function modeJson(root) { + const reg = readRegistries(root); + const strip = (r) => ({ + file: r.file, + number: r.number, + stem: r.stem, + version: r.version, + tombstone: r.tombstone, + title: r.title, + status: { keyword: r.keyword, value: r.statusValue, date: r.date }, + supersedes: r.supersedes, + supersededBy: r.supersededBy, + }); + console.log( + JSON.stringify( + { + root: reg.root.records.map(strip), + ui: reg.ui.records.map(strip), + shared: sharedNumbers(reg.root.records).map((s) => ({ number: s.number, kind: s.kind, files: s.records.map((r) => r.file) })), + foreign: { [ADR_DIR]: reg.root.foreign, [UI_ADR_DIR]: reg.ui.foreign }, + }, + null, + 2, + ), + ); + return 0; +} + +// ── Self-test ────────────────────────────────────────────────────────────── + +/** + * Every assertion is NAMED and attributed to the battery most recently opened; + * the floor below requires the opened set to equal the declared set with each + * battery at or above its pinned count. Returns rather than exits so `--report` + * can run it and warn (never red) on a failure. + * @returns {{ failures: string[], checked: number }} + */ +export function runSelfTest() { + const seen = new Map(); + let openBattery = null; + const battery = (name) => { + openBattery = name; + }; + const failures = []; + let checked = 0; + const assert = (name, cond, detail) => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + seen.set(b, (seen.get(b) ?? 0) + 1); + checked++; + if (!cond) failures.push(`${name} — ${detail}`); + }; + const rec = (name, text) => parseRecord(name, text); + const canonical = (status, extra = '') => `# ADR-0042: Answer everything\n\n**Status**: ${status}\n${extra}\n---\n\n## Context\n`; + + try { + battery('record parsing: filename, title, status, date (#26076)'); + { + assert('filename-grammar-parses-a-record', rec('0042-answer.md', '# x')?.number === '0042' && rec('0042-answer.md', '# x')?.stem === '0042-answer', JSON.stringify(rec('0042-answer.md', '# x'))); + assert('filename-grammar-parses-a-version', rec('0042-answer.v3.md', '# x')?.version === '3' && rec('0042-answer.v3.md', '# x')?.stem === '0042-answer', JSON.stringify(rec('0042-answer.v3.md', '# x'))); + assert('filename-grammar-refuses-a-non-record', rec('README.md', '# x') === null && rec('notes.md', '# x') === null, 'README.md / notes.md must not parse as records'); + assert('tombstone-is-recognised-by-slug', rec('0001-withdrawn-thing.md', '# x')?.tombstone === true && rec('0042-answer.md', '# x')?.tombstone === false, 'the withdrawn- slug prefix marks a tombstone'); + const r = rec('0042-answer.md', canonical('Accepted — implemented (2026-07-15)', '**Date**: 2026-06-01\n')); + assert('title-drops-the-id-prefix', r.title === 'Answer everything', `got ${JSON.stringify(r.title)}`); + assert('title-em-dash-prefix-is-dropped-too', rec('0042-answer.md', '# ADR-0042 — `record` Form\n').title === '`record` Form', JSON.stringify(rec('0042-answer.md', '# ADR-0042 — `record` Form\n').title)); + assert('status-value-is-read-off-the-line', r.statusValue === 'Accepted — implemented (2026-07-15)', JSON.stringify(r.statusValue)); + assert('keyword-is-the-earliest-vocabulary-entry', r.keyword === 'Accepted' && statusKeywordOf('The DECISION is Accepted — it is cloud ADR-0024') === 'Accepted' && statusKeywordOf('Partially implemented (revised)') === 'Partially implemented' && statusKeywordOf('Proposed — partially implemented') === 'Proposed', 'earliest entry wins, multi-word entries are whole'); + assert('date-prefers-the-date-line', r.date === '2026-06-01', JSON.stringify(r.date)); + assert('a-decided-line-is-a-date-line', rec('0042-answer.md', canonical('Accepted — reconstructed 2026-08-08', '- **Decided**: 2026-06-28 (the decision), reconstructed 2026-08-08 (this file)\n')).date === '2026-06-28', 'the decision date, not the reconstruction date'); + assert('date-falls-back-to-the-status-line', rec('0042-answer.md', canonical('Proposed (2026-06-22)')).date === '2026-06-22', 'the first ISO date in the status value'); + assert('date-is-null-when-nothing-states-one', rec('0042-answer.md', canonical('Proposed')).date === null, 'no date is invented'); + } + + battery('the one status-line spelling: every other spelling is Unparsed, never guessed'); + { + const other = (line) => rec('0042-answer.md', `# ADR-0042: x\n\n${line}\n`); + assert('dash-bold-is-not-a-status-line', other('- **Status**: Accepted').statusValue === null && other('- **Status**: Accepted').keyword === UNPARSED, 'bullet spelling'); + assert('colon-inside-bold-is-not-a-status-line', other('**Status:** Accepted').statusValue === null, 'colon-inside spelling'); + assert('heading-is-not-a-status-line', other('## Status\n\nAccepted.').statusValue === null, 'heading spelling'); + assert('chinese-key-is-not-a-status-line', other('- **状态**: Accepted').statusValue === null, 'the Chinese key'); + assert('unknown-keyword-is-unparsed-not-guessed', other('**Status**: Frozen solid').statusValue === 'Frozen solid' && other('**Status**: Frozen solid').keyword === UNPARSED, 'a value naming no keyword is reported as Unparsed'); + } + + battery('supersession references are extracted, not invented'); + { + assert('supersedes-line-yields-ids', JSON.stringify(rec('0042-answer.md', canonical('Accepted', '**Supersedes**: [ADR-0040](./0040-old.md) and ADR-0012, plus v2\n')).supersedes) === JSON.stringify(['ADR-0040', 'ADR-0012', 'v2']), JSON.stringify(rec('0042-answer.md', canonical('Accepted', '**Supersedes**: [ADR-0040](./0040-old.md) and ADR-0012, plus v2\n')).supersedes)); + assert('superseded-by-is-read-from-the-status-line', JSON.stringify(rec('0042-answer.md', canonical('Superseded by [ADR-0063](./0063-new.md) — original: Proposed')).supersededBy) === JSON.stringify(['ADR-0063']), JSON.stringify(rec('0042-answer.md', canonical('Superseded by [ADR-0063](./0063-new.md) — original: Proposed')).supersededBy)); + assert('superseded-by-a-version-file-is-read', JSON.stringify(rec('0042-answer.md', canonical('Superseded by v4 (`0042-answer.v4.md`) — 2026-05-20')).supersededBy) === JSON.stringify(['ADR-0042 v4']), JSON.stringify(rec('0042-answer.md', canonical('Superseded by v4 (`0042-answer.v4.md`) — 2026-05-20')).supersededBy)); + assert('a-product-version-is-not-a-record-version', refsIn('The v3.4/v4.0 "per-organization database" tenant model').length === 0, JSON.stringify(refsIn('The v3.4/v4.0 "per-organization database" tenant model'))); + assert('cross-repo-refs-keep-their-registry', JSON.stringify(refsIn('superseded in part by cloud ADR-0025 and ObjectUI ADR-0001')) === JSON.stringify(['cloud ADR-0025', 'ObjectUI ADR-0001']), JSON.stringify(refsIn('superseded in part by cloud ADR-0025 and ObjectUI ADR-0001'))); + assert('blockquote-supersession-is-read', JSON.stringify(rec('0042-answer.md', canonical('Accepted', '\n> **🔶 Cloud-owned — superseded in part by cloud ADR-0025 (2026-06-25).** The runtime moved.\n')).supersededBy) === JSON.stringify(['cloud ADR-0025']), 'the header blockquote spelling five records use'); + assert('nothing-yields-no-refs', refsIn('nothing').length === 0 && refsIn('—').length === 0, 'no id is invented from a negative'); + assert('prose-without-ids-yields-no-refs', refsIn('the hand-written namespace-prefix rule').length === 0, 'no id is invented from prose'); + } + + battery('shared numbers and versions group side by side, registries never merge'); + { + const R = [ + rec('0006-split.md', canonical('Superseded by v4')), + rec('0006-split.v2.md', canonical('Superseded by v4')), + rec('0006-split.v4.md', canonical('Accepted')), + rec('0010-alpha.md', canonical('Proposed')), + rec('0010-beta.md', canonical('Draft')), + rec('0011-solo.md', canonical('Accepted')), + ]; + const s = sharedNumbers(R); + assert('versions-of-one-stem-group-as-versions', s.find((x) => x.number === '0006')?.kind === 'versions' && s.find((x) => x.number === '0006')?.records.length === 3, JSON.stringify(s)); + assert('two-stems-under-one-number-group-as-collision', s.find((x) => x.number === '0010')?.kind === 'collision', JSON.stringify(s)); + assert('a-number-with-one-file-is-not-shared', !s.some((x) => x.number === '0011') && s.length === 2, JSON.stringify(s)); + assert('shared-groups-are-sorted-by-number-then-version', s[0].number === '0006' && s[0].records.map((r) => r.version).join(',') === ',2,4', JSON.stringify(s[0].records.map((r) => r.file))); + const vtext = renderIndex({ root: R, ui: [] }); + assert('a-versions-group-cites-the-bare-number-as-the-highest-version', vtext.includes('A bare `ADR-0006` names the decision; its current text is [0006-split.v4.md](./0006-split.v4.md)') && vtext.includes('| `ADR-0006` (current) / `ADR-0006 v4` / `ADR-0006-split' + '.v4` |') && vtext.includes('| `ADR-0006-split` (unversioned file, superseded) |'), 'the superseded unversioned file is never offered as the bare citation'); + const text = renderIndex({ root: [rec('0001-root.md', canonical('Accepted'))], ui: [rec('0001-ui.md', canonical('Accepted'))] }); + assert('ui-records-never-join-a-root-number-group', !text.includes('## Shared numbers\n\nListed') && text.includes('[ui-0001](../../ui/docs/adr/0001-ui.md)'), 'the two registries are indexed apart'); + } + + battery('rendering is deterministic and lists both registries'); + { + const root = [ + rec('0010-alpha.md', canonical('Proposed')), + rec('0010-beta.md', canonical('Draft (2026-05-24)')), + rec('0011-pipe.md', '# ADR-0011: A | B\n\n**Status**: Accepted\n'), + rec('0012-silent.md', '# ADR-0012: Silent\n\nno status here\n'), + ]; + const ui = [rec('0053-nav.md', canonical('Accepted (2026-06-18)'))]; + const a = renderIndex({ root, ui }); + const b = renderIndex({ root: [...root].reverse(), ui }); + assert('same-records-render-the-same-bytes', a === b, 'input order must not change the index'); + assert('collision-rows-carry-the-stem-qualified-id', a.includes('[ADR-0010-alpha](./0010-alpha.md)') && a.includes('[ADR-0010-beta](./0010-beta.md)') && a.includes('`ADR-0010-alpha`'), 'the unambiguous id is what the reader copies'); + assert('ui-rows-carry-the-ui-prefix-and-no-adr-id', a.includes('[ui-0053](../../ui/docs/adr/0053-nav.md)') && !/\[ADR-0053\]/.test(a), 'a ui row must not read as a root citation'); + assert('unparsed-record-is-listed-and-marked', a.includes('[ADR-0012](./0012-silent.md)') && a.includes(`**${UNPARSED}** — no`), 'a record without the line is still findable'); + assert('pipe-in-a-title-is-escaped', a.includes('A \\| B'), 'a table cell must not break the table'); + assert('conventions-carry-the-status-hygiene-rule', a.includes('Accepted — implemented') && a.includes('one line of evidence') && a.includes('Prime Directive 13'), 'the rule PRIORITIZATION.md carried lives in the index'); + assert('summary-counts-by-keyword', a.includes('| Proposed | 1 |') && a.includes('| Draft | 1 |') && a.includes(`| **${UNPARSED}** | 1 |`) && a.includes('| **Total** | **4** |'), a.slice(a.indexOf('## Summary'), a.indexOf('## Shared'))); + } + + battery('drift verdicts: --check reds, --report never does'); + { + const reg = { root: { records: [rec('0001-a.md', canonical('Accepted'))], foreign: [] }, ui: { records: [], foreign: [] } }; + const v1 = verdict(reg, null); + assert('missing-index-is-stale', v1.stale === true && v1.problems.length === 0, JSON.stringify({ stale: v1.stale, problems: v1.problems })); + const v2 = verdict(reg, v1.expected); + assert('matching-index-is-current', v2.stale === false, 'byte-equal index is current'); + const v3 = verdict({ root: { records: [rec('0001-a.md', '# x\n')], foreign: ['notes.md'] }, ui: { records: [], foreign: [] } }, null); + assert('problems-name-the-unparsed-record-and-the-foreign-file', v3.problems.some((p) => p.includes('0001-a.md') && p.includes(UNPARSED)) && v3.problems.some((p) => p.includes('notes.md')), JSON.stringify(v3.problems)); + assert('report-mode-is-exit-zero-by-construction', /return 0;\s*}\s*function modeJson/.test(readFileSync(join(process.cwd(), SELF_PATH), 'utf8')) && !/process\.exit\(1\)[^\n]*report/.test(readFileSync(join(process.cwd(), SELF_PATH), 'utf8')), 'modeReport must end in `return 0` and never exit 1'); + } + + battery('Live tree: the generator reads the real registry (the records themselves are --report\'s business)'); + { + // ⛔ Nothing here asserts on what the records SAY. A record with another + // status spelling, a stray note in the directory or a stale index is + // --report's warning (#26076, ruling A: 门禁不加), never this self-test's + // red — this battery only proves the generator finds and renders the + // real tree, so a wrong ADR_DIR or a renderer that throws on live input + // cannot pass on fixtures alone. + const live = readRegistries(process.cwd()); + assert('live-root-registry-is-read', live.root.records.length >= 1, `got ${live.root.records.length} records under ${ADR_DIR}/ — run from the repository root`); + assert('live-ui-registry-is-read', live.ui.records.length >= 1, `got ${live.ui.records.length} records under ${UI_ADR_DIR}/`); + assert('live-index-renders', renderIndex({ root: live.root.records, ui: live.ui.records }).length > 10_000, 'the live rendering is not trivially small'); + const v = verdict(live, readCommitted(process.cwd())); + assert('live-verdict-is-a-reading-not-a-throw', typeof v.stale === 'boolean' && Array.isArray(v.problems) && typeof v.expected === 'string', 'verdict() over the live tree returns its three fields'); + } + } catch (e) { + failures.push(`self-test threw before finishing — ${e && e.stack ? e.stack : e}`); + } + + // ── The floor: every declared battery RAN, and ran its cases ──────────── + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + failures.push(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`); + } + for (const [name, count] of seen) { + if (!declared.includes(name)) failures.push(`self-test battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); + } + for (const name of declared) { + const count = seen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + failures.push( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned.` + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + return { failures, checked }; +} + +function selfTest() { + const { failures, checked } = runSelfTest(); + if (failures.length) { + console.error(`✗ adr-index --self-test — ${failures.length} failure(s) of ${checked} assertion(s)\n`); + for (const f of failures) console.error(' • ' + f + '\n'); + process.exit(1); + } + console.log(`✓ adr-index --self-test: ${checked} assertions over the real parseRecord() / renderIndex() / verdict() paths.`); + selfTestReachedVerdict = true; + process.exit(0); +} + +function main(argv) { + const root = process.cwd(); + if (argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error('\n✗ adr-index self-test: selfTest() returned without reaching its verdict, so no success line was printed.\n'); + process.exit(1); + } + return; + } + if (argv.includes('--report')) process.exit(modeReport(root)); + if (argv.includes('--check')) process.exit(modeCheck(root)); + if (argv.includes('--json')) process.exit(modeJson(root)); + if (argv.some((a) => a.startsWith('-'))) { + console.error(`adr-index: unknown option ${argv.find((a) => a.startsWith('-'))} — see the header of ${SELF_PATH}.`); + process.exit(2); + } + process.exit(modeWrite(root)); +} + +if (isEntrypoint(import.meta.url)) main(process.argv.slice(2)); diff --git a/scripts/check-adr-anchors.mjs b/scripts/check-adr-anchors.mjs index 29c5e980b99..54e7a522022 100644 --- a/scripts/check-adr-anchors.mjs +++ b/scripts/check-adr-anchors.mjs @@ -204,6 +204,37 @@ // still exits 0. So an empty corpus-wide index is a hard failure in the main // run, and `--self-test` pins measured floors on both the index and the scan. // +// ## The sixth thing it checks: a stem-qualified citation names ONE record (#26076) +// +// The collision allowlist above tolerates `ADR-0057` meaning one of two +// records; it cannot make it mean one. #5992's route B — slug-qualified +// references — is the spelling that can, and it is now a grammar this file +// RESOLVES rather than a convention it hopes for: a citation may carry the +// record's FILENAME STEM, `ADR-0057-system-data-lifecycle-and-retention` (the +// file without `.md`, prefixed `ADR-`), optionally pinned to a version, +// `ADR-0006-project-environment-split.v4`. Structural, like the `.vN` rule and +// the cross-repo qualifier: every record has one, nothing here lists it, and +// `ls docs/adr | grep 0057-system` finds the file. +// +// Prose glues words to ids too (`ADR-0104-conforming`, `ADR-0010-stamps` — +// measured across the tree when this landed: every glued suffix was ONE word, +// none was two), so the suffix is judged against the registry, never trusted: +// an exact stem is QUALIFIED and resolves to that one record (counted on the OK +// line); a single word that is no stem is prose and the citation stays BARE; +// two or more words that are no stem, or a `.vN` with no such file, are +// MALFORMED — someone tried to qualify and missed — and that is red, naming the +// stems the number actually has. The decision-letter layer follows the same +// split: a bare `ADR-0057 D4` is checked against the UNION of both 0057 +// records' letters (all a bare id allows), a qualified one against that +// record's alone. +// +// Anchor shards may carry the qualified form too, and then the anchored file +// must spell it; the "ambiguous anchor" note below lists only the shards still +// on a bare shared number — migrating each is amortised as its file is touched +// (the #5992 ruling's own schedule). The index at `docs/adr/README.md` +// (`scripts/adr-index.mjs`, report-only) lists the shared numbers side by side +// with these spellings, and each colliding record's header names its sibling. +// // ## Where the registry lives (#6957) // // `scripts/adr-anchors/` — **one JSON file per anchor**, named after the path it @@ -267,12 +298,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ // to prevent. Pinned instead is the part that does not move with the list: the // 20 structural cases ran AND at least one row was actually audited. 'Live tree: green as shipped, red under ablation': 21, + 'Stem-qualified citations name ONE record (#26076)': 21, 'the sharded registry\'s assembly (#6957)': 13, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 6; +const SELF_TEST_BATTERY_FLOOR = 7; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -285,8 +317,14 @@ const MAP_PATH = ANCHORS_DIR; const ADR_DIR = 'docs/adr'; const SELF_PATH = 'scripts/check-adr-anchors.mjs'; -/** An ADR id as written in code comments: `ADR-0090`. */ -const ADR_ID = /^ADR-(\d{4})$/; +/** + * An ADR id as written in an anchor shard: `ADR-0090` — or, on a number two + * records share, STEM-QUALIFIED: `ADR-0057-system-data-lifecycle-and-retention` + * (the record's filename without `.md`, prefixed `ADR-`), optionally pinned to a + * version, `ADR-0006-project-environment-split.v4`. {@link resolveQualified} + * judges the second group against the registry. + */ +const ADR_ID = /^ADR-(\d{4})((?:-[a-z0-9]+)+(?:\.v\d+)?)?$/; /** * A decision-record filename. @@ -306,8 +344,13 @@ const ADR_FILENAME = /^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.v(\d+))?\.md$/; * Kept explicit rather than pattern-matched: the directory IS the decision * registry, so a file that is not a record should have to say so once, here, * instead of slipping past the number audit because its name did not parse. + * + * `README.md` is the script-generated index (`scripts/adr-index.mjs`): it LISTS + * the records and is not one. (`PRIORITIZATION.md`, the retired review that used + * to sit here, left when the index arrived; its one live rule moved into the + * index's conventions block.) */ -const NON_RECORD_FILES = new Set(['PRIORITIZATION.md']); +const NON_RECORD_FILES = new Set(['README.md']); /** * The slug prefix that marks a record as a TOMBSTONE — a file whose whole content @@ -337,8 +380,13 @@ const TOMBSTONE_SLUG_PREFIX = 'withdrawn-'; * * Only qualifiers in {@link CROSS_REPO_QUALIFIERS} exempt a citation; every other * preceding word (`see`, `cf.`, `per`) is noise and the id must still resolve. + * + * The third group is whatever kebab text is GLUED to the number — a stem + * qualifier (`ADR-0057-system-data-lifecycle-and-retention`, optionally `.vN`) + * or plain prose (`ADR-0104-conforming`). {@link resolveQualified} tells the two + * apart against the live registry; the regex deliberately does not. */ -const ADR_CITATION = /(?:([A-Za-z0-9_./-]+)[ \t]+)?ADR-(\d{4})/g; +const ADR_CITATION = /(?:([A-Za-z0-9_./-]+)[ \t]+)?ADR-(\d{4})((?:-[a-z0-9]+)+(?:\.v\d+)?)?/g; /** * Words that, immediately before an id, say "this number belongs to ANOTHER @@ -352,6 +400,99 @@ const ADR_CITATION = /(?:([A-Za-z0-9_./-]+)[ \t]+)?ADR-(\d{4})/g; */ const CROSS_REPO_QUALIFIERS = new Set(['objectui', 'object-ui', 'cloud']); +/** + * Judge the kebab text glued to a cited number — #5992's route B, the spelling + * that makes a shared number name ONE record (#26076; the header's sixth section). + * + * The unambiguous id is the record's own filename stem prefixed `ADR-`: + * `ADR-0057-system-data-lifecycle-and-retention`. Structural, like the `.vN` + * version rule and the cross-repo qualifier: every record has one, no list in + * this file learns it, and `ls docs/adr | grep 0057-system` finds the file. + * + * The suffix is judged, never trusted, because prose glues words to ids too + * (`ADR-0104-conforming`, `ADR-0010-stamps` — measured across the tree, every + * glued suffix was ONE word): + * + * - exactly a known stem, or `.vN` of a version file that exists → QUALIFIED; + * - a single kebab word that is no stem → prose, the citation stays BARE; + * - two or more words that are no stem, or a `.vN` with no such file → + * MALFORMED: somebody tried to qualify and missed, and the gate says so. + * + * @param {string} number `0057` + * @param {string} suffix `-system-data-lifecycle-and-retention`, `-conforming`, `-x.v4`, or `` (none) + * @param {Set} stems every `NNNN-slug` under docs/adr/ + * @param {Set} versioned every `NNNN-slug.vN` that is a file + * @returns {{ kind: 'bare' } | { kind: 'qualified' | 'malformed', stem: string, version: string | null }} + */ +function resolveQualified(number, suffix, stems, versioned) { + if (!suffix) return { kind: 'bare' }; + const m = /^(-[a-z0-9]+(?:-[a-z0-9]+)*)(?:\.v(\d+))?$/.exec(suffix); + if (!m) return { kind: 'bare' }; + const stem = `${number}${m[1]}`; + const version = m[2] ?? null; + if (version !== null) { + return versioned.has(`${stem}.v${version}`) + ? { kind: 'qualified', stem, version } + : { kind: 'malformed', stem, version }; + } + if (stems.has(stem)) return { kind: 'qualified', stem, version: null }; + const words = m[1].slice(1).split('-').length; + return words >= 2 ? { kind: 'malformed', stem, version: null } : { kind: 'bare' }; +} + +/** + * Audit the stem-qualified citations: a MALFORMED one is red (it names no + * record, and its author plainly meant one), a QUALIFIED one is counted so the + * OK line can say how much of the shared-number debt is already paid. + * + * Pure over the citation rows, like {@link auditCitedNumbers} — the bare rows + * are that function's business and pass through here untouched. + * + * @param {{ file: string, number: string, qualifier: string, suffix?: string }[]} citations + * @param {Set} stems + * @param {Set} versioned + * @param {Map>} stemsByNumber number → the stems claiming it + * @returns {{ errors: string[], resolved: number }} + */ +function auditQualifiedCitations(citations, stems, versioned, stemsByNumber) { + const errors = []; + let resolved = 0; + /** `ADR-NNNN` → files citing it, for suffixes that name no record. */ + const bad = new Map(); + for (const { file, number, qualifier, suffix } of citations) { + if (CROSS_REPO_QUALIFIERS.has(qualifier)) continue; // another repo's registry, another repo's stems + const r = resolveQualified(number, suffix ?? '', stems, versioned); + if (r.kind === 'qualified') { + resolved++; + continue; + } + if (r.kind !== 'malformed') continue; + const key = `ADR-${number}${suffix}`; + if (!bad.has(key)) bad.set(key, new Set()); + bad.get(key).add(file); + } + for (const key of [...bad.keys()].sort()) { + const number = key.slice(4, 8); + const files = [...bad.get(key)].sort(); + const shown = files.slice(0, 8); + const have = [...(stemsByNumber.get(number) ?? [])].sort(); + errors.push( + `${key} is cited by ${files.length} file(s) but names no record stem under ${ADR_DIR}/ —\n` + + shown.map((f) => ` ${f}`).join('\n') + + (files.length > shown.length ? `\n … and ${files.length - shown.length} more` : '') + + '\n A stem-qualified id is the record\'s filename without `.md`, prefixed `ADR-`, spelled exactly' + + (have.length + ? `; the stem(s) under ${number} today: ${have.map((s) => `ADR-${s}`).join(', ')}.` + : `; nothing under ${ADR_DIR}/ carries the number ${number} at all.`) + + '\n Fix: copy the stem from `ls ' + + ADR_DIR + + '/`, or cite the bare number when that number names one record. ⛔ Not a fix: dropping the ' + + 'suffix on a number two records share — that is the ambiguity the qualified form exists to end.', + ); + } + return { errors, resolved }; +} + /** * ⛔ SHRINK-ONLY. ADR numbers that are cited in this repo but have no record * under `docs/adr/`, and legitimately so: the record was **withdrawn** or @@ -447,7 +588,10 @@ const RELEASE_OWNED_CITATIONS = [ /** * ⛔ SHRINK-ONLY. The number collisions that already existed when this audit * was written (#5992). Every entry is a navigation tax being paid daily, kept - * only because the maintainer ruled renumbering an accepted record out. + * only because the maintainer ruled renumbering an accepted record out. Since + * #26076 the tax has an exit that is not renumbering: cite the record by its + * stem (`ADR-NNNN-`, resolved by `resolveQualified`) and the citation + * names ONE record; the entry stays while bare citations of the number remain. * * **Adding an entry is not the fix for a red build.** If this gate just told you * your new record collides, the fix is the next free number — it names the one @@ -508,14 +652,20 @@ const { anchors, errors: registryErrors } = loadAnchors(ROOT); * * @param {string[]} filenames * @param {{ number: string, decisions: string[], why: string }[]} allowlist - * @returns {{ records: Set, nonDecisions: Set, errors: string[] }} + * @returns {{ records: Set, nonDecisions: Set, errors: string[], stems: Set, versioned: Set, stemsByNumber: Map> }} * `records` is the set of numbers that name a real record, as the citation audit * needs it; `nonDecisions` is the subset of those whose every claimant is a - * tombstone, as the anchor loop needs it (#7329). + * tombstone, as the anchor loop needs it (#7329); `stems`, `versioned` and + * `stemsByNumber` are what the stem-qualified citation audit resolves against + * (#26076). */ function auditAdrDirectory(filenames, allowlist) { const errors = []; const records = new Set(); + /** Every `NNNN-slug` stem, for stem-qualified citations (#26076). */ + const stems = new Set(); + /** Every `NNNN-slug.vN` that is a file, for version-pinned citations. */ + const versioned = new Set(); /** number → set of decision stems claiming it. */ const stemsByNumber = new Map(); /** number → set of those stems that are tombstones. */ @@ -535,11 +685,13 @@ function auditAdrDirectory(filenames, allowlist) { ); continue; } - const [, number, slug] = m; + const [, number, slug, version] = m; records.add(number); const stem = `${number}-${slug}`; if (!stemsByNumber.has(number)) stemsByNumber.set(number, new Set()); stemsByNumber.get(number).add(stem); + stems.add(stem); + if (version) versioned.add(`${stem}.v${version}`); if (slug.startsWith(TOMBSTONE_SLUG_PREFIX)) { if (!tombstoneStemsByNumber.has(number)) tombstoneStemsByNumber.set(number, new Set()); tombstoneStemsByNumber.get(number).add(stem); @@ -606,7 +758,7 @@ function auditAdrDirectory(filenames, allowlist) { } } - return { records, nonDecisions, errors }; + return { records, nonDecisions, errors, stems, versioned, stemsByNumber }; } /** @@ -619,13 +771,24 @@ function auditAdrDirectory(filenames, allowlist) { * @param {Set} nonDecisions the subset that are tombstones * @returns {string|null} why it may not, or null if it may */ -function anchorIdProblem(adr, records, nonDecisions) { +function anchorIdProblem(adr, records, nonDecisions, stems = new Set(), versioned = new Set()) { const m = ADR_ID.exec(adr); - if (!m) return `"${adr}" is not an ADR id (expected e.g. ADR-0090).`; + if (!m) { + return ( + `"${adr}" is not an ADR id (expected e.g. ADR-0090, or — on a number two records share — the ` + + 'stem-qualified form, e.g. ADR-0057-system-data-lifecycle-and-retention).' + ); + } // Anchoring a never-written or deleted record sends the next author to a // dead end (cf. ADR-0001, whose record was deleted in the 2026-02-11 // permission-protocol rewrite and never restored). if (!records.has(m[1])) return `${adr} has no record under ${ADR_DIR}/ — anchor a decision that exists.`; + if (m[2] && resolveQualified(m[1], m[2], stems, versioned).kind !== 'qualified') { + return ( + `${adr} is stem-qualified but names no record under ${ADR_DIR}/ — the stem is the record's filename ` + + `without \`.md\`, prefixed \`ADR-\`; copy it from \`ls ${ADR_DIR}/${m[1]}-*\` (#26076).` + ); + } if (nonDecisions.has(m[1])) { return ( `${adr} names a WITHDRAWN record, not a decision — nothing may be anchored to it (#7329).\n` + @@ -657,7 +820,7 @@ function citationsIn(file, text) { const raw = (m[1] ?? '').toLowerCase(); // `objectstack-ai/cloud` qualifies as `cloud`; trailing punctuation dropped. const qualifier = raw.replace(/^.*\//, '').replace(/[^a-z0-9-]+$/, ''); - out.push({ file, number: m[2], qualifier }); + out.push({ file, number: m[2], qualifier, suffix: m[3] ?? '' }); } return out; } @@ -799,7 +962,8 @@ const SUB_DECISION_LETTERED = /^([a-z])\.[ \t]+\S/; * mode this whole file is written against. */ const DECISION_CITATION = new RegExp( - '(?:([A-Za-z0-9_./-]+)[ \\t]+)?ADR-(\\d{4})[ \\t]*(?:\\r?\\n[ \\t]*(?:\\*|//|#)?[ \\t]*)?D' + DECISION_LETTER, + '(?:([A-Za-z0-9_./-]+)[ \\t]+)?ADR-(\\d{4})((?:-[a-z0-9]+)+(?:\\.v\\d+)?)?[ \\t]*(?:\\r?\\n[ \\t]*(?:\\*|//|#)?[ \\t]*)?D' + + DECISION_LETTER, 'g', ); @@ -856,17 +1020,21 @@ function decisionIndexFor(text) { * * @param {string[]} filenames * @param {(name: string) => string} read + * @param {{ byStem?: boolean }} [options] `byStem: true` keys the index by + * `NNNN-slug` instead of by number — the stem-qualified citation's view, in + * which versions of one stem still union and two stems under one number no + * longer do (#26076). * @returns {Map>} */ -function buildDecisionIndex(filenames, read) { +function buildDecisionIndex(filenames, read, { byStem = false } = {}) { const index = new Map(); for (const name of [...filenames].sort()) { if (!name.endsWith('.md') || NON_RECORD_FILES.has(name)) continue; const m = ADR_FILENAME.exec(name); if (!m) continue; // already reported by auditAdrDirectory - const number = m[1]; - if (!index.has(number)) index.set(number, new Set()); - for (const letter of decisionIndexFor(read(name))) index.get(number).add(letter); + const key = byStem ? `${m[1]}-${m[2]}` : m[1]; + if (!index.has(key)) index.set(key, new Set()); + for (const letter of decisionIndexFor(read(name))) index.get(key).add(letter); } return index; } @@ -885,7 +1053,7 @@ function letterCitationsIn(file, text) { for (const m of text.matchAll(DECISION_CITATION)) { const raw = (m[1] ?? '').toLowerCase(); const qualifier = raw.replace(/^.*\//, '').replace(/[^a-z0-9-]+$/, ''); - out.push({ file, number: m[2], letter: 'D' + m[3], qualifier }); + out.push({ file, number: m[2], suffix: m[3] ?? '', letter: 'D' + m[4], qualifier }); } return out; } @@ -913,31 +1081,47 @@ function letterCitationsIn(file, text) { * @param {Set} nonDecisions the subset that are tombstones * @returns {{ errors: string[], unverifiable: Map>, unverifiableCount: number, resolved: number }} */ -function auditDecisionLetters(citations, index, records, nonDecisions) { +function auditDecisionLetters( + citations, + index, + records, + nonDecisions, + stemIndex = new Map(), + stems = new Set(), + versioned = new Set(), +) { const errors = []; let unverifiableCount = 0; - /** `ADR-NNNN Dk` → sorted files citing it, for letters that name no decision. */ + /** `ADR-NNNN[-stem] Dk` → { files, declared, tombstone }, for letters that name no decision. */ const bad = new Map(); /** same key → files, for citations into a record with no decision letters at all. */ const unverifiable = new Map(); let resolved = 0; - const note = (map, row) => { - const key = `ADR-${row.number} ${row.letter}`; - if (!map.has(key)) map.set(key, new Set()); - map.get(key).add(row.file); + const noteBad = (key, file, declared, tombstone) => { + if (!bad.has(key)) bad.set(key, { files: new Set(), declared, tombstone }); + bad.get(key).files.add(file); }; for (const row of citations) { if (CROSS_REPO_QUALIFIERS.has(row.qualifier)) continue; // another repo's registry if (!records.has(row.number)) continue; // auditCitedNumbers owns the dangling-number fact + // A stem-qualified citation is checked against THAT record's letters; a bare + // one against the union its number declares — on a shared number, all a bare + // id can do (#26076). A malformed qualifier is auditQualifiedCitations' fact. + const q = resolveQualified(row.number, row.suffix ?? '', stems, versioned); + if (q.kind === 'malformed') continue; + const id = + q.kind === 'qualified' ? `ADR-${q.stem}${q.version === null ? '' : `.v${q.version}`}` : `ADR-${row.number}`; + const key = `${id} ${row.letter}`; if (nonDecisions.has(row.number)) { - note(bad, row); + noteBad(key, row.file, [], true); continue; } - const declared = index.get(row.number); + const declared = q.kind === 'qualified' ? stemIndex.get(q.stem) : index.get(row.number); if (!declared || declared.size === 0) { - note(unverifiable, row); + if (!unverifiable.has(key)) unverifiable.set(key, new Set()); + unverifiable.get(key).add(row.file); unverifiableCount++; continue; } @@ -945,20 +1129,19 @@ function auditDecisionLetters(citations, index, records, nonDecisions) { resolved++; continue; } - note(bad, row); + noteBad(key, row.file, [...declared].sort(), false); } for (const key of [...bad.keys()].sort()) { - const [, number, letter] = /^ADR-(\d{4}) (D.+)$/.exec(key); - const files = [...bad.get(key)].sort(); + const { files: fileSet, declared, tombstone } = bad.get(key); + const [id, letter] = key.split(' '); + const files = [...fileSet].sort(); const shown = files.slice(0, 8); - const declared = [...(index.get(number) ?? [])].sort(); - const tombstone = nonDecisions.has(number); errors.push( `${key} is cited by ${files.length} file(s), but ` + (tombstone - ? `ADR-${number} is a WITHDRAWN record — it makes no decisions, so no "${letter}" of it can exist (#7329).` - : `ADR-${number} declares no ${letter} —\n it decides: ${declared.join(', ')}`) + + ? `${id} is a WITHDRAWN record — it makes no decisions, so no "${letter}" of it can exist (#7329).` + : `${id} declares no ${letter} —\n it decides: ${declared.join(', ')}`) + '\n' + shown.map((f) => ` ${f}`).join('\n') + (files.length > shown.length ? `\n … and ${files.length - shown.length} more` : '') + @@ -967,7 +1150,7 @@ function auditDecisionLetters(citations, index, records, nonDecisions) { 'does not say what the code says it says, and every gate above this one calls it green (#8386, #9255, ' + 'the family this layer was commissioned for).\n' + ` Fix, in order of preference: (a) cite the letter that decides this — the list above is what ` + - `ADR-${number} actually decides; (b) if the decision moved to another record, cite that record; (c) if ` + + `${id} actually decides; (b) if the decision moved to another record, cite that record; (c) if ` + 'the decision is real but unwritten, it needs an ADR, not a citation.\n' + ' ⛔ Not a fix: loosening this check. A letter that resolves to nothing is the defect it exists ' + 'to name.', @@ -1021,7 +1204,10 @@ function collectCitations() { // `--untracked` so a NEW file citing a nonexistent record goes red before it // is committed, not after CI has it. Ignored paths (`node_modules/`, `dist/`) // stay excluded either way. - out = execFileSync('git', ['grep', '--untracked', '-IonE', '([A-Za-z0-9_./-]+[ \t]+)?ADR-[0-9]{4}'], { + // The glued-suffix group rides along so a stem-qualified citation reaches + // `citationsIn` whole — `-o` prints the match and nothing after it. + const pattern = '([A-Za-z0-9_./-]+[ \t]+)?ADR-[0-9]{4}(-[a-z0-9]+)*(\\.v[0-9]+)?'; + out = execFileSync('git', ['grep', '--untracked', '-IonE', pattern], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, @@ -1051,14 +1237,17 @@ function collectCitations() { return citations; } -/** Anchor ids whose number names two decisions — reported, never failed. */ +/** + * Anchor ids whose BARE number names two decisions — reported, never failed. A + * stem-qualified id names one record and is not listed (#26076). + */ function ambiguousAnchorRefs(anchorList, allowlist) { const ambiguous = new Set(allowlist.map((e) => e.number)); const hits = []; for (const entry of anchorList ?? []) { for (const adr of entry?.adrs ?? []) { const m = ADR_ID.exec(adr); - if (m && ambiguous.has(m[1])) hits.push(`${entry.file} → ${adr}`); + if (m && !m[2] && ambiguous.has(m[1])) hits.push(`${entry.file} → ${adr}`); } } return hits; @@ -1092,7 +1281,14 @@ try { } /** Decision records that actually exist, by number: `0090` → `0090-permission-model-...md`. */ -const { records, nonDecisions, errors: numberErrors } = auditAdrDirectory(adrFiles, KNOWN_NUMBER_COLLISIONS); +const { + records, + nonDecisions, + errors: numberErrors, + stems, + versioned, + stemsByNumber, +} = auditAdrDirectory(adrFiles, KNOWN_NUMBER_COLLISIONS); const errors = [...registryErrors, ...numberErrors]; let checked = 0; @@ -1100,18 +1296,29 @@ let checked = 0; // #6634 — every `ADR-NNNN` written anywhere in the tracked tree resolves to a // record, not just the ~30 ids registered in adr-anchors/. const citations = collectCitations(); +// #26076 — a stem-qualified citation (`ADR-0057-system-data-lifecycle-and-retention`) +// names ONE record even on a number two records share; one that names no stem is red. +const { errors: qualifiedErrors, resolved: qualifiedResolved } = auditQualifiedCitations( + citations, + stems, + versioned, + stemsByNumber, +); +errors.push(...qualifiedErrors); errors.push(...auditCitedNumbers(citations, records, UNRESOLVED_ADR_CITATIONS, RELEASE_OWNED_CITATIONS)); // #9592 — and one layer down: the `Dk` half of every `ADR-NNNN Dk` citation on // the ANCHORED surface must name a decision the cited record actually makes. -const decisionIndex = buildDecisionIndex(adrFiles, (name) => readFileSync(join(ROOT, ADR_DIR, name), 'utf8')); +const readRecord = (name) => readFileSync(join(ROOT, ADR_DIR, name), 'utf8'); +const decisionIndex = buildDecisionIndex(adrFiles, readRecord); +const stemDecisionIndex = buildDecisionIndex(adrFiles, readRecord, { byStem: true }); const letterCitations = collectAnchorLetterCitations(anchors); const { errors: letterErrors, unverifiable: unverifiableLetters, unverifiableCount: unverifiableLetterCount, resolved: resolvedLetters, -} = auditDecisionLetters(letterCitations, decisionIndex, records, nonDecisions); +} = auditDecisionLetters(letterCitations, decisionIndex, records, nonDecisions, stemDecisionIndex, stems, versioned); errors.push(...letterErrors); // A shape scanner degrades SILENTLY — a heading-style change turns every record @@ -1162,7 +1369,7 @@ for (const entry of anchors) { // Resolution — the id parses, names a record, and that record is a DECISION // rather than a tombstone (#7329). All three live in `anchorIdProblem` so // `--self-test` drives the real judgement. - const problem = anchorIdProblem(adr, records, nonDecisions); + const problem = anchorIdProblem(adr, records, nonDecisions, stems, versioned); if (problem) { errors.push(`${shard}: ${problem}`); continue; @@ -1192,7 +1399,8 @@ if (errors.length) { console.log( `check-adr-anchors: OK (${checked} anchored file(s), every governing ADR still referenced; ` + `${records.size} decision number(s), each naming one decision or an allowlisted pair; ` + - `${citations.length} citation(s) across ${new Set(citations.map((c) => c.file)).size} file(s) resolve; ` + + `${citations.length} citation(s) across ${new Set(citations.map((c) => c.file)).size} file(s) resolve, ` + + `${qualifiedResolved} of them stem-qualified to one record; ` + `${resolvedLetters} decision-letter citation(s) on the anchored surface name a decision the record makes).`, ); @@ -1215,14 +1423,16 @@ if (unverifiableLetters.size) { } // Reported, never failed. These anchors are correct — the number they name is -// the ambiguous thing, and #5992's route B (slug-qualified references) is the -// separate change that fixes it. Printing them keeps the size of that debt -// visible instead of leaving it to be rediscovered. +// the ambiguous thing. #5992's route B (slug-qualified references) is now a +// spelling this file resolves (`resolveQualified`, #26076); migrating each +// anchor is amortised as its file is touched, and printing the rest keeps the +// size of that debt visible instead of leaving it to be rediscovered. const ambiguous = ambiguousAnchorRefs(anchors, KNOWN_NUMBER_COLLISIONS); if (ambiguous.length) { console.log( - `check-adr-anchors: note — ${ambiguous.length} anchor reference(s) name an ADR number that two records ` + - 'share (#5992, allowlisted):', + `check-adr-anchors: note — ${ambiguous.length} anchor reference(s) name a BARE ADR number that two records ` + + 'share (#5992, allowlisted). The unambiguous spelling is the record\'s filename stem, `ADR-NNNN-` ' + + '(#26076): write it in the shard AND in the anchored file, and the entry leaves this list:', ); for (const hit of ambiguous) console.log(` · ${hit}`); } @@ -1266,7 +1476,7 @@ function selfTest() { const audit = (files, allowlist = []) => auditAdrDirectory(files, allowlist); const joined = (errs) => errs.join('\n'); - const BASE = ['0001-alpha.md', '0002-beta.md', 'PRIORITIZATION.md']; + const BASE = ['0001-alpha.md', '0002-beta.md', 'README.md']; const PAIR = [{ number: '0002', decisions: ['0002-beta', '0002-gamma'], why: 'synthetic' }]; try { @@ -1280,8 +1490,8 @@ function selfTest() { ); assert( 'companion-doc-is-not-audited', - !joined(e).includes('PRIORITIZATION'), - 'PRIORITIZATION.md must be skipped as a non-record, not reported as unparseable', + !joined(e).includes('README'), + 'README.md (the generated index) must be skipped as a non-record, not reported as unparseable', ); } @@ -2013,6 +2223,144 @@ function selfTest() { } } + // ---- stem-qualified citations name ONE record (#26076) ------------------- + // + // #5992's route B. A bare number two records share is "one of two"; the + // record's filename stem, prefixed `ADR-`, is one. The regex captures any + // kebab text glued to a number and the judgement is made against the + // registry, so the cases pin the three verdicts (qualified / bare / + // malformed) and the three consumers (number audit, anchor ids, letters). + battery('Stem-qualified citations name ONE record (#26076)'); + { + const id = (n, s = '') => 'ADR-' + n + s; + const FILES = ['0001-alpha.md', '0002-beta.md', '0002-gamma.md', '0003-delta.md', '0003-delta.v2.md', 'README.md']; + const PAIR2 = [{ number: '0002', decisions: ['0002-beta', '0002-gamma'], why: 'synthetic' }]; + const { stems, versioned, stemsByNumber, records, nonDecisions } = audit(FILES, PAIR2); + const rows = (text, file = 'src/x.ts') => citationsIn(file, text); + const q = (number, suffix) => resolveQualified(number, suffix, stems, versioned); + + assert( + 'directory-audit-returns-every-stem', + stems.has('0002-beta') && stems.has('0002-gamma') && stems.has('0003-delta') && stems.size === 4, + `got {${[...stems].join(',')}}`, + ); + assert( + 'directory-audit-returns-version-files', + versioned.has('0003-delta.v2') && versioned.size === 1, + `got {${[...versioned].join(',')}}`, + ); + { + const parsed = rows( + `${id('0002', '-beta')} and ${id('0003', '-delta.v2')} and ${id('0001', '-conforming')} and bare ${id('0001')}`, + ); + assert( + 'citation-parser-captures-the-glued-suffix', + parsed.length === 4 && + parsed[0].suffix === '-beta' && + parsed[1].suffix === '-delta.v2' && + parsed[2].suffix === '-conforming' && + parsed[3].suffix === '', + `got ${JSON.stringify(parsed)}`, + ); + } + assert( + 'exact-stem-is-qualified', + q('0002', '-beta').kind === 'qualified' && q('0002', '-beta').stem === '0002-beta', + JSON.stringify(q('0002', '-beta')), + ); + assert( + 'existing-version-is-qualified', + q('0003', '-delta.v2').kind === 'qualified' && q('0003', '-delta.v2').version === '2', + JSON.stringify(q('0003', '-delta.v2')), + ); + assert('one-word-prose-suffix-is-bare', q('0001', '-conforming').kind === 'bare', JSON.stringify(q('0001', '-conforming'))); + assert('multi-word-unknown-suffix-is-malformed', q('0002', '-beta-typo').kind === 'malformed', JSON.stringify(q('0002', '-beta-typo'))); + assert('missing-version-is-malformed', q('0003', '-delta.v9').kind === 'malformed', JSON.stringify(q('0003', '-delta.v9'))); + assert('no-suffix-is-bare', q('0002', '').kind === 'bare', JSON.stringify(q('0002', ''))); + { + const green = auditQualifiedCitations( + rows(`${id('0002', '-beta')} · ${id('0002', '-gamma')} · ${id('0001', '-conforming')} · ${id('0002')}`), + stems, + versioned, + stemsByNumber, + ); + assert( + 'qualified-citations-are-green-and-counted', + green.errors.length === 0 && green.resolved === 2, + `got resolved=${green.resolved}, errors:\n${joined(green.errors)}`, + ); + const red = auditQualifiedCitations(rows(`${id('0002', '-beta-typo')}`, 'packages/spec/src/a.ts'), stems, versioned, stemsByNumber); + assert('malformed-qualified-citation-is-red', red.errors.length === 1, `expected exactly 1 error, got ${red.errors.length}:\n${joined(red.errors)}`); + const msg = joined(red.errors); + assert( + 'malformed-message-names-the-stems-the-number-has', + msg.includes('0002-beta') && msg.includes('0002-gamma') && msg.includes('packages/spec/src/a.ts'), + `message must list the stems under the number and the citing file:\n${msg}`, + ); + const cross = auditQualifiedCitations(rows(`ObjectUI ${id('0002', '-beta-typo')}`), stems, versioned, stemsByNumber); + assert('cross-repo-qualified-citation-is-not-judged', cross.errors.length === 0, `another registry's stem is not ours to judge, got:\n${joined(cross.errors)}`); + } + { + const ok = anchorIdProblem(id('0002', '-gamma'), records, nonDecisions, stems, versioned); + assert('anchor-accepts-a-qualified-id-that-resolves', ok === null, `got: ${ok}`); + const bad = anchorIdProblem(id('0002', '-gamma-typo'), records, nonDecisions, stems, versioned) ?? ''; + assert('anchor-refuses-a-qualified-id-that-resolves-to-nothing', /names no record/.test(bad), `got: ${bad}`); + const hits = ambiguousAnchorRefs([{ file: 'a.ts', adrs: [id('0002')] }, { file: 'b.ts', adrs: [id('0002', '-beta')] }], PAIR2); + assert('ambiguity-note-lists-bare-not-qualified-anchors', hits.length === 1 && hits[0].startsWith('a.ts'), `got ${JSON.stringify(hits)}`); + } + { + const text = { + '0001-alpha.md': '', + '0002-beta.md': '### D1 — one\n', + '0002-gamma.md': '### D2 — two\n', + '0003-delta.md': '', + '0003-delta.v2.md': '', + 'README.md': '', + }; + const byNumber = buildDecisionIndex(FILES, (n) => text[n]); + const byStem = buildDecisionIndex(FILES, (n) => text[n], { byStem: true }); + const lrows = (t, file = 'src/x.ts') => letterCitationsIn(file, t); + const parsed = lrows(`${id('0002', '-beta')} D1`); + assert( + 'letter-citation-parser-captures-the-glued-suffix', + parsed.length === 1 && parsed[0].suffix === '-beta' && parsed[0].letter === 'D1', + JSON.stringify(parsed), + ); + const bare = auditDecisionLetters(lrows(`${id('0002')} D2`), byNumber, records, nonDecisions, byStem, stems, versioned); + assert('bare-letter-on-a-shared-number-resolves-against-the-union', bare.errors.length === 0 && bare.resolved === 1, `got:\n${joined(bare.errors)}`); + const qualified = auditDecisionLetters( + lrows(`${id('0002', '-beta')} D2`, 'packages/spec/src/a.ts'), + byNumber, + records, + nonDecisions, + byStem, + stems, + versioned, + ); + assert( + 'qualified-letter-resolves-against-that-stem-only', + qualified.errors.length === 1 && qualified.errors[0].includes('0002-beta D2') && qualified.errors[0].includes('D1'), + `the other record's letter must not excuse it, got:\n${joined(qualified.errors)}`, + ); + const fine = auditDecisionLetters(lrows(`${id('0002', '-beta')} D1`), byNumber, records, nonDecisions, byStem, stems, versioned); + assert('qualified-letter-that-the-stem-declares-is-green', fine.errors.length === 0 && fine.resolved === 1, `got:\n${joined(fine.errors)}`); + } + { + // Live: every grandfathered pair is citable unambiguously today, by the + // stems the allowlist itself names. + const live = audit(readdirSync(join(ROOT, ADR_DIR)), KNOWN_NUMBER_COLLISIONS); + const pairs = KNOWN_NUMBER_COLLISIONS.flatMap((c) => + c.decisions.map((stem) => ({ file: 'probe', number: c.number, suffix: stem.slice(4), qualifier: '' })), + ); + const r = auditQualifiedCitations(pairs, live.stems, live.versioned, live.stemsByNumber); + assert( + 'live-every-allowlisted-pair-is-citable-by-stem', + r.errors.length === 0 && r.resolved === pairs.length && pairs.length >= 2, + `got resolved=${r.resolved} of ${pairs.length}:\n${joined(r.errors)}`, + ); + } + } + // ---- the sharded registry's assembly (#6957) ----------------------------- // // Two of these are the properties the split was adopted for, and they pull in diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index b0013b96c7d..331f7511099 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -344,8 +344,8 @@ export const ROOT_WALK_RESIDUE_LEDGER = [ * * 324 code-position matches over the tracked JS/TS corpus, 24 of them under * `ui/` since the objectui import (objectstack#25803). 290 are the bare - * `selfTest`; the remaining 34 carry compound names over 29 distinct spellings, - * and they are the rows below. 23 are genuine self-test batteries — the + * `selfTest`; the remaining 35 carry compound names over 29 distinct spellings, + * and they are the rows below. 24 are genuine self-test batteries — the * anchor firing on them is the anchor working. ELEVEN are production code: * * scripts/check-self-test-wired.mjs carriesSelfTest @@ -372,7 +372,7 @@ export const ROOT_WALK_RESIDUE_LEDGER = [ * and it is exactly the kind of claim that stops being true without anything * going red, which is what the pin in this module's self-test exists to catch. * - * The same measurement, redone over the table's current 23 genuine rows, + * The same measurement, redone over the table's current 24 genuine rows, * is still NOT zero, and that asymmetry is what makes the classification * load-bearing rather than decorative: `fixtureSelfTest` drops * `packages/spec/spec-changes.json` and `prePushIsArmedSelfTest` drops @@ -401,8 +401,8 @@ export const ROOT_WALK_RESIDUE_LEDGER = [ * wider — and it would make the tool's self-scan differ from every other * scan, which is a hazard of its own. * - * ⇒ What ships is neither. The anchor keeps firing on all 34, the mask keeps - * blanking all 34, and the cost of the eleven accidental ones is MEASURED on + * ⇒ What ships is neither. The anchor keeps firing on all 35, the mask keeps + * blanking all 35, and the cost of the eleven accidental ones is MEASURED on * every run instead of asserted in prose. Silence was the defect; the remedy is * noise on the day it starts costing something. * @@ -426,6 +426,10 @@ export const ROOT_WALK_RESIDUE_LEDGER = [ export const COMPOUND_ANCHOR_LEDGER = [ ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'specSelfTest', false], ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'fieldRuleSelfTest', false], + // #26076: the ADR index generator's battery, reached from its `--self-test` guard and run again + // (never exited on) by its report-only `--report` mode, so a broken generator warns in CI instead + // of going quiet. A battery, so its fixture filenames are masked away as hints — genuine. + ['scripts/adr-index.mjs', 'runSelfTest', false], ['scripts/audits/14744-before-update-per-row-value-census.mjs', 'runSelfTest', false], ['scripts/check-comment-mask-corpus.mjs', 'runSelfTestCases', false], ['scripts/check-doc-authoring.mjs', 'selfTestRule3', false],