Skip to content

fix(service-datasource): the admin door reads a datasource's origin from provenance, and a metadata-door write reaches it in the same boot - #21977

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21923-datasource-origin-provenance
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21923-datasource-origin-provenance

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21923
Clause-②: no

What changes

A runtime datasource is one record that two doors serve: the datasource admin door (/api/v1/datasources) and the metadata door (/api/v1/meta/datasource). They disagreed about it in three ways. All three land in packages/services/service-datasource/src/datasource-admin-plugin.ts.

  1. Origin comes from provenance, not from the record. listDatasourceRecords and getDatasourceRecord served origin: r.origin ?? 'code'. A metadata-door body has no origin, or it asserts origin: 'code'. So after a restart, the boot restore registered the row and the admin door served it as code-defined and refused its PATCH. A new servedOrigin(ctx, name) returns code only for a name in the host's code-datasource set, and runtime for every other name, whatever the record says. That set is the code-datasource-names kernel service PR fix(service-datasource,runtime,metadata-protocol)!: a stored datasource row no longer displaces a code-defined datasource at boot, and the metadata door refuses edits to the host default #21965 landed, which the boot restore and the metadata door's refusal already read. Boot pool rehydration filters on the served origin, so such a datasource also gets its live pool after a restart.

  2. Same-boot reach. The metadata door persists to sys_metadata and the SchemaRegistry. It never registers the record in the MetadataService slot the admin door lists. At start(), the plugin now registers the protocol's awaited datasource mutation projector (ADR-0094, registerMutationProjector; no metadata-protocol edit). After each metadata-door save, publish, revert, rollback or delete, and before that door answers, the projector does three things:

    • it re-reads the stored row (sys_metadata, the same read the boot restore makes);
    • it registers or unregisters the MetadataService slot to match that row;
    • it converges the live pool through the existing convergePool.

    A name in the code set is skipped, because code wins. So the metadata door's repair DELETE of a stored shadow row under a code name keeps the code definition served.

  3. The reverse direction. persistDatasourceRow wrote the sys_metadata row with no checksum. The metadata door's reads serve row.checksum ?? hashSpec(body) as the version, but its writes compare the parent against the raw column. So every metadata-door PUT and DELETE of an admin-created datasource answered 409 METADATA_CONFLICT. The row now carries hashSpec(record, 'datasource'), imported from @objectstack/metadata-core. That is the same computation SysMetadataRepository.put stamps (hashSpec(body, ref.type)). @objectstack/metadata-core moves from devDependencies to dependencies; in the lockfile only the importer entry moves.

convergePool is the receive half of the datasource cluster bridge, and the projector's pool step. It now decides "code" from the same set instead of row.origin !== 'runtime'. It pools every other stored row as runtime, and stamps the record origin: 'runtime' before the record reaches the connect context.

Editing a code-defined datasource is still refused at both doors.

Why Clause-②: no

The origin docblock in packages/spec/src/data/datasource.zod.ts reads: "runtime — created via the Studio wizard, persisted in the runtime metadata store, environment-scoped, editable", and "Never accepted from client input". The published contract already says such a datasource is editable, so the admin door's refusal of a metadata-door datasource as code-defined was a false refusal. No key, export or stored shape moves.

projectionApplied now appears on the answer to a datasource write through the metadata door. That key is already declared optional on the protocol's write answer, so this adds no new key to a published payload.

Measured on the base (c9761cd2fb): the new door pin is red

datasource-meta-door-reaches-admin-door.dogfood.test.ts, run on the unmodified base, gave 4 failed and 2 passed:

  • :174 same boot, after PUT /meta/datasource/dogfood_meta_none_21923 answered 200: expected undefined to match object { origin: 'runtime', …(1) }. The admin door did not list it.
  • :193 an admin-created datasource, then PUT /meta/datasource/dogfood_admin_rt_21923: 409 METADATA_CONFLICT, "Expected parent hmac-sha256:… but current is null."
  • :206 after a restart: received "origin": "code", expected "runtime".

Mechanism hypotheses, measured

  1. Does any code registration reach the MetadataService without its name in the set? This was measured with bootStack on HEAD 493c13dbb3, comparing metadata.list('datasource') against code-datasource-names:

    composition listed at boot code set listed but outside the set
    showcase default, showcase_external default, showcase_external none
    crm crm_analytics, crm_primary, default crm_analytics, crm_primary, default none
    multi-package default default none

    A package installed after boot does not register datasources in the MetadataService at all: registerApp and the sys_packages rehydrate write only the engine registry. So the admin door never lists one. The three residual paths, read and not measured on a boot, are under Acceptance notes. The fix never falls back to ?? 'code'.

  2. Same-boot reach. Verified on main (the :174 failure above). The seam is the awaited projector, not onMetadataMutation: the metadata door answers only after the admin door lists the record, and a projection failure is reported on that answer as well as logged.

    • A live pool is needed. The admin door's create calls registerPool, which connects. The dogfood pin asserts connected for a metadata-door save in the same boot and again after a restart. Before the fix there was no pool, and after a restart the served code kept the datasource out of pool rehydration.
  3. Checksum. The fix lands inside service-datasource by importing the repository's own computation. The residual, rows already stored without a checksum, is under Acceptance notes.

  4. Carrier notes.

    • convergePool is covered by the provenance rule (above).
    • The restoreRuntimeDatasources and rehydratePools warnings that go only to options.logger are not changed. The bounded in-place-fix condition "same defect class" does not hold for them; see Acceptance notes.

Tests (head 493c13dbb3)

  • pnpm --filter @objectstack/service-datasource typecheck: exit 0. The package's tsc --listFiles includes the edited test file.
  • pnpm --filter @objectstack/service-datasource test: 41 files and 760 tests pass.
    • 7 new cases in datasource-admin-plugin.test.ts: the served origin, the projector registration, a metadata-door save, an edit and a delete reaching the admin door with their pool, a write under a code name changing nothing, a peer signal pooling by provenance, and the checksum.
    • One fixture re-judged: the "artefact (code) datasource" case now carries the code set, as the runtime fills it, instead of relying on a missing origin.
    • datasource-system-context.pin.test.ts follows convergePool's new ctx parameter.
  • pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • Four dogfood files pass, 24 tests (after pnpm --filter @objectstack/service-datasource build, dist marker preflight present): the new pin, datasource-restore-code-wins, meta-door-code-datasource, and external-import-code-datasource-namespace.
  • The rejection pin asserts code plus status: the admin PATCH of showcase_external still answers 400 DATASOURCE_ADMIN_ERROR, with the message's first sentence.

Ablations (each mutation through scripts/ablation-replace.mjs, rebuilt, ablation-dist-preflight on both legs, restored to the HEAD blob a7f28b52b967, git diff HEAD empty, and the restore leg rebuilt)

put back unit (26 in file) dogfood
origin ?? 'code' in list and get 3 red new pin 3 red (:174, :206, :222)
the record's own origin first (r.origin ?? servedOrigin(…)) 2 red new pin 2 red (:175, the body asserting code served as code)
no projector (void this.projectMetadataDoorWrites;) 4 red new pin 4 red (:174, :194, :206, :222)
convergePool reads row.origin !== 'runtime' 3 red new pin 2 red (:178, no pool)
no checksum (const checksum = null) 1 red new pin 3 red (:193 and :221, 409 METADATA_CONFLICT)
projector without its code-name guard 1 red datasource-restore-code-wins 1 red (:271, the repair DELETE unregisters the code definition)

Three first attempts did not run: the no-projector, convergePool and no-checksum mutations each failed the DTS build with an unused symbol (TS6133), so nothing was measured. Each was redone with a mutation that compiles. In every void attempt the restore leg was proven the same way.

Gates (head 493c13dbb3)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack re-derived 78 families on this change. That is a superset of the 49 the dispatch listed; the additions come from the changeset, the lockfile and package.json. All 78 exit 0. --ran reconciles: 78 derived, 78 run, 0 NOT-MEASURED, each with a recorded exit code.

One gate needed a rerun. pnpm check:dual-build-cjs-loads first exited 3 with PREREQUISITE NOT MET (8 unrelated packages had no dist/). After those packages were built, it exited 0: 106 require entry points across 66 packages load.

Narrowed lint (eslint --no-inline-config --format json) over the 4 touched .ts files:

  • every one resolves a config (--print-config);
  • the JSON output counts 4 files, 0 errors and 0 warnings;
  • eslint.config.mjs enables no type-aware linting, so this diff cannot move a verdict on an untouched file.

The repo-wide pnpm lint is left to CI.

Acceptance notes

  • Rows stored before this release. An admin-created datasource row written before this release has no checksum, so the metadata door still answers it 409 until the admin door next writes it; one admin edit is the remedy. The asymmetry itself is in metadata-protocol's sys-metadata-repository.ts: rowToItem serves row.checksum ?? hashSpec(body) while put and delete compare the raw column. It holds for any type's null-checksum row, and is reported to the seat rather than edited here.

  • Code datasources the code set does not cover (read, not measured on a boot):

    • Dev artifact HMR (artifactWatch) re-registers the artifact's datasources through the MetadataPlugin door. A datasource added to the artifact after boot is not in the set, because AppPlugin memoizes its owners, so the admin door serves it as runtime until a restart.
    • The legacy FilesystemLoader (a datasource/ directory under the metadata root) lists datasources nothing adds to the set.
    • A host that composes the artifact door without AppPlugin or DefaultDatasourcePlugin registers no set, so nothing is code there; the boot restore already treats such a host that way.

    The remedy for each is on the producer side: contribute to the set. It is not a consumer default.

  • Cluster. The projector runs on the writing replica only; the protocol's cluster channel replays mutation listeners, not projectors. So a metadata-door datasource write does not converge peer replicas' MetadataService or pools until they restart. Before this change no replica converged. Cross-replica MetadataService coherence is a separate, open measurement.

  • Lost warnings under os serve. The existing restoreRuntimeDatasources and rehydratePools warnings still go only to options.logger, which os serve does not pass. This change widens the population that reaches rehydratePools, because metadata-door datasources now rehydrate. The new projector adds no log line of its own: a projection failure is reported on the metadata door's answer (projectionApplied) and logged by the protocol.

  • Not this card. The metadata door's own GET serves a stored row under a code-defined name (the overlay read); finding(service-datasource): a stored datasource row overrides a code-defined datasource at boot, so after a restart the admin door serves and edits it at runtime (restoreRuntimeDatasources has no code-collision check) #21922 remains open for that half.

Files

  • packages/services/service-datasource/src/datasource-admin-plugin.ts
  • packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts
  • packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts
  • packages/services/service-datasource/package.json, pnpm-lock.yaml (@objectstack/metadata-core becomes a dependency)
  • packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts (the door pin, declared on [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024)
  • .changeset/21923-datasource-origin-from-provenance.md (@objectstack/service-datasource patch)

Generated by Claude Code

claude added 4 commits October 6, 2026 09:22
…reeing on a runtime datasource

Red on the base: a metadata-door datasource is missing from the admin
door in the same boot and reads as code after a restart, and an
admin-created datasource answers 409 at the metadata door.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
… project metadata-door writes, stamp the row checksum

- The admin door's list and read serve origin code only for a name in the
  host's code-datasource set, runtime otherwise, never the record's own
  origin or a default for its absence.
- A datasource the metadata door saves, publishes, reverts or deletes
  reaches the admin door in the same boot through the protocol's awaited
  datasource mutation projector: the MetadataService slot follows the
  stored row and the live pool converges on it.
- Cluster convergence decides code by the same set and pools every other
  stored row as runtime.
- An admin-written sys_metadata row carries the repository's checksum, so
  the metadata door's optimistic lock matches it.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…or projector, convergence by provenance and the row checksum

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…and metadata-door reach fix

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-datasource, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-datasource/package.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/external-datasources.mdx (via DatasourceAdminServicePlugin (symbol, a top-level class))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-datasource/package.json) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f76c6221acd3997dd778fdd3e8e7d43e0bec4851 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 91bb125dbf8d2cb3ffe6140b40a0c12b706ee395 — the merge of head 493c13dbb312ec5123605bfd2bcf20522bf55d30 into base f76c6221acd3997dd778fdd3e8e7d43e0bec4851, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 91bb125dbf8d2cb3ffe6140b40a0c12b706ee395 && git checkout 91bb125dbf8d2cb3ffe6140b40a0c12b706ee395
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f76c6221acd3997dd778fdd3e8e7d43e0bec4851 493c13dbb312ec5123605bfd2bcf20522bf55d30 && git checkout -B drift-repro f76c6221acd3997dd778fdd3e8e7d43e0bec4851 && git merge --no-ff 493c13dbb312ec5123605bfd2bcf20522bf55d30

node scripts/docs-audit/affected-docs.mjs --json f76c6221acd3997dd778fdd3e8e7d43e0bec4851

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f76c6221acd3997dd778fdd3e8e7d43e0bec4851 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 10:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 10:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 8a399b2 Oct 6, 2026
38 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21923-datasource-origin-provenance branch October 6, 2026 11:17
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ed decision in words instead of a tracker number (stage 26) (objectstack-ai#21987)

Part of objectstack-ai#20749
Clause-②: no

Stage 26 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the first name-ordered `system/` group: the 18
id-bearing test files under `packages/spec/src/system/` from
`auth-config.test.ts` to `metadata-form-declared-rows.pin.test.ts`, with
`constants/system-names.test.ts` in its path position. Those files
carried 91 messages and 97 tracker ids, citing 54 records. All 97 now
either state what their record decided, in words (form D), or are
dropped where the title already says it. No needle sits in this group.
Text only: no assertion, identifier, test count or code comment changes,
and no file is renamed.

## Census at the base (`dcf3eb494a`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages
10 to 25 used. A literal counts as a test title when its folded message
is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` /
`.only` chains included. Everything else is an "other" string.

The worktree was cut from `origin/main` at `dcf3eb494a`, the claim's
base and stage 25's landing. Both instruments read **282 messages / 297
ids in 71 files**, the seat's reading and stage 25's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `system/` (this PR: 18 of its 34 files) | 34 | 154 / 167 | 128 / 138 |
26 / 29 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ui/` | 5 | 7 / 7 | 0 | 7 / 7 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **71** | **282 / 297** | **245 / 257** | **37 / 40** |

The group reads **91 messages / 97 ids in 18 files**, the seat's figures
file for file:

| file (under `system/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `auth-config.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `book.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `cache.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `collaboration.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `compliance-families-retirement.test.ts` | 7 / 7 | 4 / 4 | 3 / 3 |
| `constants/system-names.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `core-service-provider.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `data-migration-flag-column-move.pin.test.ts` | 2 / 3 | 2 / 3 | 0 |
| `disaster-recovery.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `email-config.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `email-template-floor-locale-parity.pin.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `environment-artifact.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `http-server.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `i18n-resolver.test.ts` | 53 / 56 | 50 / 53 | 3 / 3 |
| `job.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `logging.test.ts` | 1 / 3 | 1 / 3 | 0 |
| `message-queue-retirement.test.ts` | 3 / 3 | 1 / 1 | 2 / 2 |
| `metadata-form-declared-rows.pin.test.ts` | 2 / 2 | 2 / 2 | 0 |
| **18 files** | **91 / 97** | **83 / 89** | **8 / 8** |

Ten more test files sit in the same name range and carry no id
(`constants/platform-object-names`, `constants/platform-tool-names`,
`core-services`, `deploy-bundle`, `doc`, `encryption`,
`i18n-resolver.nav-label-identity`, `i18n-resolver.object-list-views`,
`job-pull-organization`, `license`). The eight "other" strings are
expect failure messages, rewritten and declared to the text-only tool:
`compliance-families-retirement.test.ts:159` (a template literal),
`:210` and `:494`; `message-queue-retirement.test.ts:87` (a template
literal) and `:127`; `i18n-resolver.test.ts:3288` (the second leaf of a
`+` chain), `:3299` and `:3303`.

- **Controls.** Lit: `system/metrics.test.ts` (4 / 5) and
`system/translation.test.ts` (12 / 12), outside the group, read the same
at the base and at the head. Dark: `i18n-resolver.test.ts` reads 0 at
the head while 47 of its lines still carry `#` plus digits: 45 comment
lines and the two `(batch objectstack-ai#58)` titles below. Planted in a scratch tree:
an id put into the rewritten `i18n-resolver.test.ts` "per-component
copy" title reads 1 / 1 (`title:describe`), and an id put into a
`job.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in 17 of the 18 files at the base. In `i18n-resolver.test.ts` it
reads two more: `:1744` and `:1757` end in "(batch objectstack-ai#58)", a two-digit
decision-batch label that the gate's three-to-five-digit pattern does
not count. Neither literal carries a counted id, so both stay, in the
form earlier stages kept batch labels (stage 21's `批 17`, stage 22's `批
14` / `batch 13`). At the head the wider pattern reads 0 in 17 files and
those 2 in `i18n-resolver.test.ts`.
- **At the head:** 191 messages / 200 ids in 53 files. The 18 files read
0 / 0, `system/` reads 63 / 70 in 16 files, and no other file moved.

## How the area was chosen

`system/` is taken in path-ordered file groups near the ~100-id bound,
the rule stages 20 to 25 used, with `constants/system-names.test.ts`
sorting at `constants/`. Stage 25's cut named this group at 97 ids, and
this census reads 97, so no re-cut was needed. `i18n-resolver.test.ts`
(56 ids; objectstack-ai#21948 landed into it at `bab76850cd`, and its titles were read
at this base) fits one PR and one text-only proof, so it is not split.

**Named for the next stages** (cut from the head census, 191 / 200):
- **the second `system/` group:**
`metadata-form-zod-reconciliation.test.ts` through `worker.test.ts`, 16
files, 63 messages / 70 ids (45 / 49 titles, 18 / 21 other). Its first
file alone carries 21 / 24, 17 of them "other" strings (its ledger `why`
entries).
- The files directly in `src/`, 120, one stage.
- The needles: one stage, with an at-tier review. The four colour
literals stay, as stage 21 decided.

## What each id became

- **23 literals (25 ids)** now state a decision in words.
- **11 literals (12 ids)** get their subject back in words, where the
number stood for a thing.
- **57 literals (60 ids)** drop a number the title already explains.

Every cited record was fetched with all its comments through REST, and
its decision was read from its ruling, ACCEPT and landing comments: a
keyword digest of every record, and full reads wherever the new words
carry a decision. 54 records are cited: 51 answer 200 (objectstack-ai#15514 is a PR,
merged as `bf1054a4c0`) and 3 answer 404. The three that answer 404 were
read from what landed, through the commits endpoint (this checkout is
shallow), each found through its CHANGELOG entry or the file's own
commit anchor:
- **objectstack-ai#10926**, from `d173125fb8` (objectstack-ai#11438): the component-translation
`submitLabel` copy key is retired (maintainer ruling 2026-08-22, option
A);
- **objectstack-ai#12961**, from `901355c3bc` (objectstack-ai#13111): `translatePage` descends into
declared `properties.children` ("Ruled 2026-08-29 (option A)";
region-level id wins a collision);
- **objectstack-ai#13218**, from `c45d8e6b4e` (objectstack-ai#13641): `walkAddressedPageComponents`
is exported as the one addressed-component walk, consumed by
`translatePage` and the CLI extractor alike.

**The same-id title stage 25 listed:** `book.test.ts:413`
"ResolvedBookSchema is the book-tree response contract (objectstack-ai#12038)". Its
body parses the real `resolveBookTree()` output, accepts the
honest-empty tree and pins each Zod schema type-identical to its
interface: a describe-only transcription, which the five-part ruling's
implementation plan names (`5434804846`). None of 1C · 2C · 3A · 4A · 5A
is pinned, so no letter is named; the title already says the decision,
and only the number goes.

**"ruled:" appears in two titles,** each on a record that carries a
ruling:
- `data-migration-flag-column-move.pin.test.ts:72`: "(ruled: one
nullable datetime on the flag row)". objectstack-ai#15989's comment `5556979386`,
headed "The four questions — ruled", answers Q1 with A: a new nullable
datetime field on `DataMigrationFlagSchema`, whose failure mode is
absence. objectstack-ai#16185's body restates it ("The mechanism was ruled on objectstack-ai#15989
as A").
- `i18n-resolver.test.ts:4944`: "(ruled: the authored label is the
default-locale text)". objectstack-ai#15711's maintainer ruling A, recorded in
`5549577889`.

**Stated in words** (23 literals):

| record | literal (under `system/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#11739 | `auth-config.test.ts:423` | "AudienceConfigSchema — one
closed audience posture, invite_only when undeclared" | One declared
posture, a closed vocabulary (`invite_only` / `email_domain` / `open`),
`invite_only` when undeclared, as the body records it ruled. |
| objectstack-ai#15679 (objectstack-ai#14478 ruling B) | `cache.test.ts:336`,
`collaboration.test.ts:1005`, `disaster-recovery.test.ts:235` | "… carry
their unit in the key name" / "… carries its unit in the key name" | The
`system/` duration keys carry their unit in the key name, stage 25's
objectstack-ai#15677 reading. |
| objectstack-ai#15513 | `compliance-families-retirement.test.ts:210`, `:494` (expect
messages) | "… being undone — the three families were retired whole,
none roadmapped" | Maintainer ruling A (`5548577921`): the three
compliance-shaped families are retired whole; none is roadmapped. |
| objectstack-ai#15989 Q1, via objectstack-ai#16185 |
`data-migration-flag-column-move.pin.test.ts:72` | "columns_moved_at —
absence is the contract (ruled: one nullable datetime on the flag row)"
| Above. |
| objectstack-ai#16185 constraint 2 |
`data-migration-flag-column-move.pin.test.ts:126` | "the ONE arbiter is
unchanged by the new member — read beside it, never inside it" |
`isDataMigrationFlagVerified` is not touched; the new field is read in
addition to the arbiter, never inside it. |
| objectstack-ai#19184 | `job.test.ts:881` | "JobSchema's own @example — copied
verbatim, it parses" | The example stops opening with the retired `id`,
so a verbatim copy is accepted. |
| objectstack-ai#8075 | `message-queue-retirement.test.ts:127` (expect message) | "…
being undone — the family had no consumer and carried an inline
credential" | Fork (b): exported but unconsumed, the credential-bearing
shape is retired whole. |
| objectstack-ai#16772 | `i18n-resolver.test.ts:1207` | "translateDashboard — global
filters, addressable from a bundle" | Finding B:
`dashboards.NAME.globalFilters` becomes a bundle group. |
| objectstack-ai#6080 | `:1573` | "per-component copy, keyed by component id" | Page
component copy gets a bundle address by component id. |
| objectstack-ai#13218 (404) | `:2215` | "walkAddressedPageComponents — the one
addressed-component walk, shared by the resolver and the CLI extractor"
| What landed in `c45d8e6b4e`. |
| objectstack-ai#5377 | `:2652` | "resolveTabLabel — filter-preset tab labels, keyed
under `_tabs`" | Tab labels get the `objects.OBJECT._tabs.TAB.label` key
and a resolver. |
| objectstack-ai#3370 | `:3049` | "translateObject inline actions — served with their
`_actions` translations" | Declared action labels are translated, not
served as English literals. |
| objectstack-ai#3833, objectstack-ai#3847 | `:3131` | "resolveObjectFieldLabels — the declared rich
entries, read from the nested translation shape" | objectstack-ai#3833: the nested
shape, never the retired flat dialect; objectstack-ai#3847: the rich `label` / `help`
/ `options` entries the response declares. |
| objectstack-ai#7679 | `:3308` | "normalizeSupportedLocales — the declared
supportedLocales narrow the advertised set" | `/i18n/locales` reports
the app's declared locales, not every loaded bundle. |
| objectstack-ai#11287 | `:3732`, `:4134` | "translateFlow — screen-flow copy from the
`flows` bundle group" / "resolveFlowScreenTitle — a screen title from
the `flows` bundle group" | The resolver half that reads
`TranslationData.flows`. |
| objectstack-ai#3786 | `:4726` | "… the REST boundary follows — one derived list, no
hand-kept copy" | Derive from the one source instead of hand-copying a
"keep in sync" list. |
| objectstack-ai#14882 | `:4853` | "a declared fallback chain, not a literal `en`, at
the resolver" | The chain honours the declared `fallbackLocale`, not a
hard-wired `['en']`. |
| objectstack-ai#15711 | `:4944` | "… for a default-locale request (ruled: the
authored label is the default-locale text)" | Above. |
| objectstack-ai#16458 | `:5011` | "resolveMetadataFormSchemaTitles — bundle labels
overlaid as JSON Schema titles, repeater rows included" | Item-level
property names get a translated title through the array's `items`. |

**Subject back in words** (11 literals):
- the two "zero holders after objectstack-ai#15513" / "after objectstack-ai#8075" template messages
become "after the families' retirement" / "after the family's
retirement", stage 25's form;
- "[objectstack-ai#15513] ADR-0087 registration" becomes "compliance families
retirement — ADR-0087 registration";
- "keeps PR objectstack-ai#15514's fourteen deadline-key registrations" becomes "keeps
the earlier deadline-key retirement's fourteen registrations": PR objectstack-ai#15514
retired the fourteen inert deadline keys before the families went whole;
- "shipped by plugin-email since objectstack-ai#5087" becomes "shipped by the SMTP
transport in plugin-email": objectstack-ai#5087 implemented that transport;
- "objectstack-ai#17614 — the published "must stay equal" claim" becomes
"EMAIL_TEMPLATE_FLOOR_LOCALE — the published "must stay equal" claim",
the constant whose docblock carries it;
- "(objectstack-ai#4666 pin)" becomes "(pinned here: a type change the key-level gates
cannot see)": objectstack-ai#4666's maintainer ruling chose direction B,
fingerprinting defaults only, so the checksum object-to-string type
change stays invisible to the key-level gates, and these parses are its
gate;
- the three "pre-objectstack-ai#20680" / "pre-objectstack-ai#8284" / "pre-objectstack-ai#20731" control titles
become "the behaviour before the override rule", the rule their
describes name ("the catalog loses to an explicit override");
- "the contract call the objectstack-ai#12961 line left open, made by objectstack-ai#16772" becomes
"the contract call the `children`-descent ruling left open, made for tab
and accordion panels": the 2026-08-29 ruling (`901355c3bc`) named
`properties.children` only, and objectstack-ai#16772 made the `items[].children` call.

**Dropped where already stated** (57 literals, 60 ids). A number goes
only where the title already says its decision. Examples: `[objectstack-ai#15513]` x2
and `[objectstack-ai#8075]` retirement describes and `[objectstack-ai#4740]`; the four other
`[objectstack-ai#15711]` prefixes, once the ruling is stated in the first; the tails
`(objectstack-ai#18124)`, `(objectstack-ai#4667)` x2, `(objectstack-ai#12038)`, `(objectstack-ai#4611)`, `(objectstack-ai#4451)`, `(objectstack-ai#5307)`,
`(objectstack-ai#14865)`, `(objectstack-ai#4938)`, `(objectstack-ai#5295)`, `(objectstack-ai#16292)`, `(objectstack-ai#19085)`, `(objectstack-ai#20439)`,
`(objectstack-ai#4854)`, `(objectstack-ai#21257)`, `(objectstack-ai#10926)`, `(objectstack-ai#12961)`, `(objectstack-ai#5775)`, `(objectstack-ai#20940)` x3,
`(objectstack-ai#16772)` x4, `(objectstack-ai#5377)` x2, `(objectstack-ai#5728)` x2, `(objectstack-ai#3847)` x2, `(objectstack-ai#7634)` x4
(one title and three expect messages), `(objectstack-ai#8284)`, `(objectstack-ai#11745)`, `(objectstack-ai#14253)`
x6; the `objectstack#14972` tail with its repository qualifier; the
ADR-bearing tails `(objectstack-ai#4740, ADR-0049)`, `(objectstack-ai#20680, ADR-0029 D9.2a)`,
`(objectstack-ai#20731, ADR-0029 D9.2a)`, `(objectstack-ai#14478, ADR-0087 …)`, which keep their
ADR; `pre-objectstack-ai#4740` before "v0 artifact"; and `(objectstack-ai#17782, objectstack-ai#15939, objectstack-ai#14478)` on
"logging duration keys → *Ms", whose title shows the rename. The two 404
numbers among them (objectstack-ai#10926, objectstack-ai#12961) go only where the title already
states what landed.

**No file is renamed.**

## Readers

- **Needles:** none. The eight declared strings are assertion failure
messages (the second argument of `expect`), none is an expected value.
The three `.pin.test.ts` files read source text, and none reads an id:
`data-migration-flag-column-move.pin.test.ts` reads
`isDataMigrationFlagVerified`'s body for `verified_at`, `blocking` and
the absence of `columns_moved_at`;
`email-template-floor-locale-parity.pin.test.ts` reads
`DEFAULT_TEMPLATE_LOCALE` and "must stay equal";
`metadata-form-declared-rows.pin.test.ts` reads registered form rows.
`job.test.ts`'s `@example` pin reads the anchor "@example Metadata Sync
Job (Cron)". No title or message in the group is matched against a
source docblock or another file's text.
- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` to vitest; the one vitest `-t`
hit is a README example under `packages/qa/dogfood` filtering its own
fixture.
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 18 files calls a snapshot matcher.
- **Projects:** `compliance-families-retirement.test.ts` and
`email-template-floor-locale-parity.pin.test.ts` are in the `repo`
project (`packages/spec/vitest.repo-tests.json`); the other 16 run in
`local`. The base-versus-head run below takes both projects.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (272 needles) was searched with `git grep` at the
base, across the tracked tree outside its own file. No gate, doc,
filter, snapshot, QA checklist entry or `scripts/check-*.mjs` self-test
reads one. The 12 hits are sibling test titles: the three `(objectstack-ai#15679)`
titles in this group hit each other (all rewritten here) and the five
`(objectstack-ai#15679)` titles of the second `system/` group (`metrics`,
`object-storage`, `registry-config`, `tracing`, `worker`), which go with
that stage.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. This stage declares the eight
expect-message lines named above.

- **Result:** 18 of 18 files SAME on all three legs, with the per-file
counts predicted in writing before any edit.
- **Totals:** 91 changed string leaves in 91 literals: 83 titles and 8
declared. The diff's `+` and `-` lines are exactly the 91 planned lines
as multisets, and every file keeps its line count.
- **Controls (14 of 14 as predicted on the first run, on scratch copies,
each anchor hit once):** identifier rename DIFF; numeric literal DIFF;
comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a
rewritten title given a new id VIOLATION; a title that was id-free at
base edited VIOLATION; one title reverted to base SAME; an `it.each` row
given an id VIOLATION; an undeclared expect message changed VIOLATION; a
title re-split into a `+` chain DIFF; a declared expect message reverted
to base SAME; a declared template expect message given a new id
VIOLATION; a declared `+`-chain leaf given a new id VIOLATION; a
template-literal title given a new id VIOLATION.
- **Templates and tables:** no `.each` title and no `$name` placeholder
changes. The two template literals change only their text after the
`${…}` span.

**Test counts:** the 18 files were run at the base, before the edit, and
at the head, in the same worktree, with `--project local --project
repo`. Both sides read 783 tests in 18 files, all passed, with the same
count and status sequence per file in 18 of 18. 354 full test names
change, and each changed name equals the base name with the planned
replacements applied: 0 mismatches. No full name repeats on either side.
No head name carries a gate-pattern id (354 base names did); two head
names carry the kept "(batch objectstack-ai#58)". No source escape sits in a planned
anchor, so the comparison tool met none.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
18 touched files are in it, and no `*.test.ts` at all (`files[]` ships
`src/**/*.zod.ts`, not tests). The controls `src/system/job.zod.ts`,
`src/system/translation.zod.ts` and `dist/index.mjs` are in it.
- In the built `dist/`, two new phrases and an old one each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `ed2bc649f3`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (turbo exit 0, recorded to a file; `VERDICT batch-last-exit
0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 619 files, 18485 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 18 group
files, counted by path with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date, against the
`dist/` the build above wrote.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
79 as stage 25. All 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0
NOT-MEASURED, 0 UNRUN, every family with its exit code recorded. The
same 79 derive from `origin/main` `4e4e881427` with this diff applied.
The five roster families marked as sharing a directory with this diff
(`check:meta-url-spelling`, `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) each exit 0.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 18 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 18 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 182 changed lines (+91 /
-91).
- A control-byte scan over the 18 changed files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was three commits
past the base (`4e4e881427`: objectstack-ai#21976, objectstack-ai#21977, objectstack-ai#21352). They touch 490
files, none of the 18 and none under `packages/spec/src/system/`, so
`main` was not merged. The census of `4e4e881427` with this diff applied
reads 191 / 200, file for file the same as the head. `git merge-tree`
onto `4e4e881427` is clean, and none of the 5 open PRs touches any of
the 18 files.

## Acceptance notes

- **Same-id test titles in this card's later stages** go with those
stages: 22 lines, among them the five `(objectstack-ai#15679)` titles of the second
`system/` group, `system/translation.test.ts`'s six (`objectstack-ai#16772`, `objectstack-ai#6080`,
`objectstack-ai#10926`, `objectstack-ai#21257`, `objectstack-ai#11287`, `objectstack-ai#4667`) and
`stack-email-template-locale-floor.test.ts`'s four `objectstack-ai#17614` titles.
- **Same-id test titles in other packages** stay: 78 lines in 14
packages (`driver-sql` 13, `cli` 12, `objectql` 10, `rest` 10, `lint` 8,
`platform-objects` 6, `plugin-auth` 6, `core` 3, `runtime` 3,
`service-i18n` 3, and one each in `client`, `plugin-approvals`,
`qa/dogfood` and `sdui-parser`), each package's share under the objectstack-ai#20513
lane children.
- **The two "(batch objectstack-ai#58)" titles** (`i18n-resolver.test.ts:1744`,
`:1757`) stay: a two-digit decision-batch label outside the gate's
pattern, with no counted id in the literal.
- **Code comments with live ids** remain in these files, among them the
`// objectstack-ai#18124 — step 3 of ruling A on objectstack-ai#18115` header in
`auth-config.test.ts`, the `[objectstack-ai#16185]` docblock in
`data-migration-flag-column-move.pin.test.ts`, the `[objectstack-ai#17614]` docblock
in `email-template-floor-locale-parity.pin.test.ts` and the `// ───
[objectstack-ai#8075]` / `[objectstack-ai#15513]` banners. Code comments are not this card's share.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…less sys_metadata row is served as (objectstack-ai#21990)

Fixes objectstack-ai#21978
Clause-②: no

## What this changes

`SysMetadataRepository`
(`packages/metadata-protocol/src/sys-metadata-repository.ts`) served a
`sys_metadata` row that has no `checksum` as the hash of its stored body
(`rowToItem`), but `put` and `delete` judged the caller's parent against
the raw column (`existing.checksum ?? null`). So a row like that could
never be written or removed through the metadata door. Every
`saveMetaItem` / `deleteMetaItem` answered `409 METADATA_CONFLICT`
("Expected parent hmac-sha256:… but current is null"), whether the
parent was the version the door served or no `If-Match` was sent at all,
because the door takes the parent from the same read. Publish, rollback
and commit revert over such a row hit the same lock, and the
post-promotion drain of a checksum-less draft was refused and silenced
as a benign race.

Per triage's direction (6014717866), with nothing narrowed and no
backfill:

- **One helper**, `servedVersion(ref, row)`: the stored `checksum`, else
`hashSpec(body, type)`. `rowToItem` now reads it, so every read hands
out this one value.
- **One lock**, `lockAccepts(ref, row, parent)`, used by `put` and
`delete`. It accepts the row's stored stamp, which is the old compare
unchanged: a row with a `checksum` is judged exactly as before, and a
`null` parent still matches a checksum-less row. For a checksum-less row
it also accepts the served version.
- **The conflict's head** (`lockHead`) is the served version, so a 409
on such a row names the version a read hands out (before this, `null`).
A checksum-less row whose bytes do not parse keeps `null` there, so a
lock refusal never becomes a parse error.
- The lineage fields (`previous_checksum`, the event's `parentHash`) and
the no-op check keep reading the raw stamp. So the first write over a
checksum-less row, even with an identical body, stamps the row as usual.
Nothing is rewritten at rest, and the header's "no backfill" non-goal
stands, now with one line on how such a row is served.

**File surface:** as dispatched. The producer that wrote such rows (the
datasource admin door) already stamps a checksum since PR objectstack-ai#21977, which
is on `main`, so the remaining work is the stored rows, and that lands
in this repository class. Two test files in the same package: the pins,
plus one fixture comment in
`protocol-publish-drafts-package-scope.test.ts` that this change made
false. Changeset: `@objectstack/metadata-protocol` patch.

## Pins (`protocol.served-content-hash.test.ts`, the existing
conflict-test double)

Through the protocol's real `saveMetaItem` / `deleteMetaItem` /
`publishMetaItem`, on a row seeded with no `checksum`:

- (a) saved and deleted with the version its read serves, in the keyed
form a door hands out: the repository's own `get` read, keyed;
- (a) unpinned (last-write-wins) save and delete succeed: the dogfood
shape;
- (b) a stale keyed token and the raw served hash are still refused with
`METADATA_CONFLICT` / `409` on both doors; `actualHead` is the served
token, the row is untouched, and retrying with that `actualHead`
succeeds;
- (c) a `null` parent still succeeds: `storedParentVersion: row.checksum
?? null`, the stored-row migration's in-process spelling;
- (d) after each write the row carries `hashSpec(newBody, 'view')`; an
identical re-save stamps it too;
- publish over a checksum-less active row; the drain removes a
checksum-less draft row;
- repository level: a row WITH a checksum whose stamp differs from its
body's hash refuses the body's hash and `null` (both name the stamp as
head) and accepts its stamp; a checksum-less row accepts `null` and its
served version, and refuses anything else with the served version as
head.

## Reverse verification (committed HEAD `5c4815a6ab`)

The mutation went through `scripts/ablation-replace.mjs` with an
EXIT/INT/TERM restore trap and absolute paths. It restored the raw
compare in both `put` and `delete` (anchor hit x2 → x0, replacement x0 →
x2, blob `dc58518587` → `494fa3f0ee`; on disk, raw-compare 0 → 2 and
`lockAccepts` call 2 → 0).

- Predicted beforehand: 7 of the 9 new pins red, and green for the
`null`-parent pin and the stamped-row pin, which guard against widening
and against narrowing rather than this mutation.
- **Observed: `Tests 7 failed | 16 passed (23)`**, the 7 predicted. The
save door reproduced the card's text verbatim: "view/case_grid has been
modified since you loaded it. Expected parent hmac-sha256:e532d121… but
current is null." The drain pin read the draft row still present, and
the repository pin read `actualHead` `null`.
- Restore was proven by observation: blob after restore `dc58518587`
equals the HEAD blob, `git diff HEAD` is empty, and `git status
--porcelain` is empty.
- An earlier invocation was a no-op: the tool refused with exit 2 before
writing, because it located the repository from the shared checkout's
cwd. On-disk counts were unchanged, and it was rerun from the worktree
root.

The subject is imported by relative `src` path (`./protocol.js`,
`./sys-metadata-repository.js`), so no `dist/` sits on the ablation's
resolution path.

## Clause-② (measured against the built entry declarations)

`packages/metadata-protocol/dist/index.d.ts` was built at HEAD, and
again with BASE `8a399b2b15`'s repository source swapped in behind a
trap. The swap was restored and proven by blob equality, and HEAD was
rebuilt, giving a byte-identical `index.d.ts`. The diff's non-comment
lines are `private servedVersion;`, `private lockHead;` and `private
lockAccepts;`, with 0 removed; everything else is doc text.
`index.d.cts` has the identical diff. No exported type or signature
moves. Behaviourally, `put` / `delete` accept for a checksum-less row
the version the same repository already serves for it, which is the
declared version token, not a new class of input.

## Tests and gates: all on HEAD `81606021e2` (after merging
`origin/main` twice, the second bringing PR objectstack-ai#21979's `protocol.ts`
change)

- `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 218
passed | 3 skipped (221)`, `Tests 28028 passed | 19 skipped (28047)`.
`typecheck`: `tsc --noEmit` clean, and the test file is in the program
(`--listFiles` count 1). Lock VERDICT command-exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived the
63 commands, and all ran at exit 0. `check:type-check-debt` ran under
the verify lock ("1 ledger entr(ies) re-measured … 26 raw tsc error(s)
total, none above its recorded number"). `check:dual-build-cjs-loads`
and `check:lean-entry-closure` ran after a full `turbo run build` (72
tasks, 71 cached). Reconciliation, `--ran` with per-command exit codes:
"63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN".
- The artifact-roster block (55 families, outside the total): 52 at exit
0. `check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths` answered NOT WIRED (exit 2, no PR context);
they are rerun against this PR and reported in the `os-dev-report`
comment.
- The four symbol-anchor sweeps (`check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`,
`check:adr-anchors`): exit 0.
- NOT MEASURED locally, owned by CI: the five path-scheduled CI jobs
(Test Core shards, Temporal Conformance, Dogfood Regression Gate,
Dogfood Verify CLI, Build Core) and the workspace type-check lanes.
`packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts`
was not run locally.

## Census: writers of `sys_metadata` that can store a row with no
`checksum`

| Writer | Where | `checksum` | Still producing such rows |
|---|---|---|---|
| `SysMetadataRepository.put` (insert / update) |
`metadata-protocol/src/sys-metadata-repository.ts` | always
`hashSpec(body, type)` | no |
| `SysMetadataRepository.delete` | same file | removes the row. Its
tombstone goes to `sys_metadata_history` with `checksum: null` by design
| n/a (history table) |
| datasource admin door `writeDatasourceRow` |
`service-datasource/src/datasource-admin-plugin.ts` | `hashSpec(record,
'datasource')` since PR objectstack-ai#21977; none before | no. Its pre-objectstack-ai#21977 rows
are the stored population this PR makes writable |
| datasource admin door delete fallback | same file | `update { state:
'inactive' }`, which keeps the column | no |
| `DatabaseLoader` save / create / `registerRollback` |
`metadata/src/loaders/database-loader.ts` | `contentHash` stamp | no |
| protocol orphan adoption (`package_id` rebind) |
`metadata-protocol/src/protocol.ts` | partial update, which keeps the
column | no |
| protocol legacy delete, permission-set overlay discard |
`protocol.ts`, `plugin-security/src/permission-set-overlay-discard.ts` |
delete only | no |
| `env_id` → `project_id` migration |
`metadata/src/migrations/migrate-env-id-to-project-id.ts` | column
rename DDL | no |
| stored-row migration, flow credential move | `protocol.ts`
`migrateStoredMetadata`,
`service-automation/src/flow-credential-migration.ts` | through
`saveMetaItem` → `put` (stamps) | no. Both were refused on such rows
before this PR and succeed now |
| generic data door, MCP data bridge, flow write nodes, hook bodies | —
| refused: `sys_metadata` declares `apiMethods: ['get', 'list']`, plus
the stored-metadata family refusals | no |

A tombstone reads back as a `delete` event with `hash: null`
(`history()` / `rowToEvent`). `getByHash` never matches it, and
`restoreVersion` refuses it with `VERSION_NOT_RESTORABLE`. **No writer
is still live after this change, so no follow-up card.**

## Acceptance notes

-
`packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts`
(about `:190`) explains its explicit `parentVersion: null` by saying a
raw-seeded row's derived parent "would 409". After this change it would
not; the `null` it passes stays valid. Comment drift in another package,
left as is. Owner: none.
- The first write over a checksum-less row records `previous_checksum:
null` / `parentHash: null`, the raw stamp. That is deliberate: no
history row carries the served hash, so naming it would be a parent link
to nothing.
- A conflict-audit note on such a row now reads "current is (withheld)"
where it read "current is null", because the head is no longer null.
- `DraftDrainFailure.draftHash` is documented as "the row's `checksum`".
It is the served version, the same value for a stamped row. This is a
doc imprecision predating this PR.
- Rollback (`restoreVersion`) and commit revert over a checksum-less
active row take the served parent and pass the same lock. This was read
in code; only publish is pinned as the representative internal caller.
- No door read serves a version token for a stored row that has no
history; the tokens come from receipts, history events and a 409's
`actualHead`. So for a legacy row, the 409 is the first place a client
sees its token. The stale-version pin covers that retry.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…objectstack-ai#21994)

Fixes objectstack-ai#21989
Clause-②: no

## What this is

This PR adds the curated release page for 17.7.0,
`content/docs/releases/v17/17-7.mdx` (1,402 lines). It was written after
the publish: npm `latest` moved on 2026-10-06, and
`@objectstack/spec@17.7.0` went out at 12:22:14Z. It also wires the page
in:

- `content/docs/releases/v17/meta.json`: `17-7` comes ahead of `17-6`.
- `content/docs/releases/v17/index.mdx`: the status blockquote, the
minors warning, the per-release list and the checklist links now name
17.7.0 as current. This is the same shape objectstack-ai#21362 used for 17.6.0.
- `scripts/docs-audit/handwritten-docs.json`: the page joins the
docs-accuracy audit scope.
- `content/docs/releases/v17/17-6.mdx`: one dated correction
(2026-10-06) on the anonymous-endpoints known issue. objectstack-ai#21158 was closed
as not planned on 2026-10-04.

The page follows the 17.6 page's structure:

1. Highlights.
2. What's new: the counts, and the runtime changes that happen silently.
3. Breaking changes and migration, triaged by door and subject. It
includes a coverage table that names the section carrying the migration
for every ADR-0087 entry added since 17.6.0 (8 conversions, 41 D3
entries).
4. New capabilities.
5. Notable fixes. Security fixes are described only as classes and
doors.
6. New in Console: each objectui declared-breaking change, with this
repo's answer.
7. The code that shipped but is not listed.
8. The upgrade checklist. Every line is marked *Not exercised.*

## Sources and counts

- The version commit `4e4e881427` (objectstack-ai#21352) consumed 323 changesets. 322
are new. One, `748b240` (objectstack-ai#21270), shipped in 17.6.0 and is listed again;
the page explains this in its own section.
- 69 CHANGELOGs carry a 17.7.0 section, and 48 of them have entries.
Their 444 entries (194 minor, 250 patch) de-duplicate to the 323
changesets.
- Two commits landed on `main` after the Version Packages PR's last
refresh and before it merged:
  - `8a399b2b15` (objectstack-ai#21977, for objectstack-ai#21923)
  - `04e776b39a` (objectstack-ai#21976, for objectstack-ai#21968)

Both are ancestors of the version commit (exit 0 for each), so the
17.7.0 packages carry their code. But the version commit did not consume
their changesets, so no 17.7.0 CHANGELOG line names them. The
release-integrity audit named both in a warning.
- objectui: the pin moved five times and ends at `0abd4f9f8769`:
  - `89cad75d5570` (objectstack-ai#21380)
  - `ab1879721595` (objectstack-ai#21625)
  - `2e818d0b51ec` (objectstack-ai#21710)
  - `9dfaca654311` (objectstack-ai#21800)
  - `0abd4f9f8769` (objectstack-ai#21827)

Across 173 commits, 254 changesets were added and 229 of them release
something. 65 are declared breaking, plus one commit marked `!`. The
Console table answers each.
- `PROTOCOL_VERSION` is still 17.0.0.

## Premise corrections

- The dispatch named two pin moves ending at `9dfaca654311`. The tree
has five, ending at `0abd4f9f8769` (`8832655`, objectstack-ai#21827). The page covers
all five.
- One new D3 id contains a word that `check:role-word` refuses on docs
pages, and release pages are not in its baseline. The coverage table
therefore names that entry by its subject and points at `os migrate meta
--from 17`.

## Fact-check

A second pass checked every cited SHA, PR number, key name and
behavioural claim against the commits, changesets and registry entries.
It corrected **31 claims** (commit `e4a6280b46`).

Two more corrections came earlier, while drafting:
- objectstack-ai#21361 is closed; it is not tracking the issue.
- There are seventeen `ui-object-*` members, not eighteen.

Mechanical checks on the final page:

- All 276 cited SHAs resolve, in objectstack or in an objectui clone
carrying the final pin's history.
- Each of the 216 distinct sha–PR pairs matches its commit subject.
- Every printable new D3 id (40) and all 8 conversions appear on the
page.
- The MDX for 17-7, 17-6 and index compiles with @mdx-js/mdx 3.1.1 and
remark-gfm 4.0.1.

After the fact-check, `main` gained `1abfc58` (objectstack-ai#21985), which lands the
read half of objectstack-ai#21922. It is not an ancestor of the version commit: the
test returned exit 1, and the control commit `753e7a1` returned exit 0.
So in 17.7.0, the metadata door's reads still serve a stored row under a
code-defined datasource name. Commit `8347e0d172` says so in the
datasource migration bullet.

## Independent fact-check and fix round

An independent, read-only fact-check of head `8347e0d172` returned
**FAIL** with 13 findings (record: objectstack-ai#21989 comment 6018402030): 2 wrong
facts (a flow's `get_record` node still reads the stored-metadata
tables, in projected form), 1 security line that named the filter shapes
and depth threshold evading 17.6.0's refusal, 1 breaking change missing
from the Breaking section (`0fc8087`, objectstack-ai#21626), 1 link whose label did
not match its target, 4 overstatements, 1 missing security fix
(`49524f6`, objectstack-ai#21420), 1 missing rollback caveat on `os secret rewrap
--apply`, and 2 minor wording issues.

All 13 were re-verified against their sources and applied in
`a53c972fa7`; none was refuted. A scan for any other line naming a
bypass shape of a fixed issue reduced two more lines to their class
(`0728cbf`, `fb69825`).

## Measured on `a53c972fa7`

- Derived gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives 57 commands; all 57
exited 0 (`--ran`: "57 run, 0 NOT-MEASURED (a DERIVED zero)"). The
verdicts include:
  - check-doc-anchors: 458 links resolve.
- check-issue-citations: 305 resolve as a PR, 9 resolve, 15 are
cross-repo; every citation this change adds resolves.
- `check:role-word`, `check:release-notes` and
`check:release-page-status`: OK.
- check-release-section-coverage: OK, both plain and with `--strict` (10
minors).
  - The docs-audit scope check and `check:nul-bytes`: OK.
- Docs production build: `TURBO_FORCE=true pnpm turbo run build
--filter=@objectstack/docs`, run under the verify lock, reports `Tasks:
2 successful, 2 total` and `Cached: 0 cached`. The built
`releases/v17/17-7.html` carries the corrected text and none of the
removed text.
- Mechanical checks: 231 distinct sha–PR pairs, 0 unresolved, 0 subject
mismatches; the MDX of 17-7, 17-6 and index compiles.
- Not measured locally: the repo-wide lint and the CI-only families. CI
owns them.

## Not in this PR

- No changeset. The PR touches only docs and a docs-audit list, nothing
a package ships (`skip-changeset`).
- Nobody has walked the 17.6.0 → 17.7.0 upgrade. The checklist says so
on each line.

---
_Generated by [Claude
Code](https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants