Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21922-host-code-datasource-set
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21922-host-code-datasource-set

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Part of #21922
Fixes #21944
Clause-②: no (narrowing)

What changes

A code-defined datasource (a *.datasource.ts the installed artifact declares, or the host's own default) is read-only by published contract: DatasourceSchema.origin says "code — authored as *.datasource.ts, GitOps-owned, read-only in the UI", the datasource registry entry in metadata-plugin.zod.ts says code-defined datasources "win on name collision", and datasource-admin-service.ts says "A runtime datasource never shadows a code one (code wins on collision)". The datasource-admin plugin's boot restore broke all three, and the metadata door could not see default at all.

The fix is the one host-owned set of code datasources the two cards' triage asked for ("One set serves both, so do not build two"):

  • The set (packages/runtime/src/code-datasource-names.ts, new). One in-memory Set of the datasource names the host registers from code, on the kernel service code-datasource-names. contributeCodeDatasourceNames registers it on first use and adds to it after that, the shape seed-summary uses.
  • Its producers, both in init(). AppPlugin.init() adds every datasource the artifact declares: the same list its start() registers in the MetadataService, now memoized so the two phases read one answer. DefaultDatasourcePlugin.init() adds default. Phase 1 completes before any start(), so the set is whole before the restore runs, whatever order the plugins were composed in.
  • The restore (restoreRuntimeDatasources, packages/services/service-datasource/src/datasource-admin-plugin.ts). A stored row under a name in the set is not registered over the code definition. It is kept, and one boot warning names it with the repair. The warning goes to the host's options.logger, or to the kernel logger when the host passes none (os serve passes none).
  • The resolver (isDeclaredCodeDatasource, packages/metadata-protocol/src/protocol.ts, nothing else in that file). It reads the same set beside the installed packages, so the metadata door answers default the way it answers every code-defined datasource since PR fix(metadata-protocol)!: the metadata door refuses an edit of a code-defined datasource, and removes only a stored row left under one #21942.

⛔ "Code" is never read from a stored row's origin, the MetadataService slot's origin, the connection service's ConnectResult, or a request body's origin.

Measured on a booted showcase

The harness is the @objectstack/verify bootStack with the datasource-admin routes mounted the way serve.ts mounts them, in a temp cwd. The stored rows assert origin: 'runtime' and their own config.filename (the cards' case (b)). They were written through the metadata door's repository on the runtime-only intent, then the stack restarted. BEFORE is 76fec88b16; AFTER is this branch at 1d840709ae. The readings come from a throwaway probe that was never committed; the committed pins below assert the AFTER column.

Reading after the restart BEFORE AFTER
admin list, showcase_external origin: runtime, label "Shadow 21922" origin: code, "External Analytics (SQLite)"
PATCH /api/v1/datasources/showcase_external 200 400 DATASOURCE_ADMIN_ERROR "… is code-defined and cannot be edited at runtime."
live pool named default a second pool opened on the stored row's file; verdict already-registered became connected none; verdict stays already-registered
showcase_ext_customer read 3 rows (code fixture) 3 rows (code fixture)
boot warning naming each stored row none one per row
PUT /api/v1/meta/datasource/default 200 "Saved datasource 'default'" 403 NOT_OVERRIDABLE
DELETE /api/v1/meta/datasource/default, no stored row 200 403 NOT_OVERRIDABLE
DELETE /api/v1/meta/datasource/showcase_external (repair), then meta GET in the same boot 200, but the meta GET kept serving the stored edit until the next restart 200, and the meta GET serves the code definition

The dispatch's mechanism hypotheses

  • H1, start order. In all three compositions that load service-datasource (serve.ts, standalone-stack.ts, the verify harness), DefaultDatasourcePlugin and AppPlugin are use()d before DatasourceAdminServicePlugin. None of the three declares an ordering edge to another, so their start()s run in insertion order: the code registrations did land before the restore, but by list position alone, which ADR-0116 says proves nothing. The answer is the first branch: the set is filled by a phase that precedes the restore (init()). It is pinned by a boot whose reader plugin is composed first, ahead of every producer. The restore pin also covers a code registration that lands after it.
  • H2, the seam. It is a kernel service read through the services registry the protocol already resolves (getServicesRegistry()), with no packages/spec change. The ObjectQL registry was rejected: the engine's datasource definitions mix both origins, and a host package record would be a fabricated provenance.
  • H3, the admin refusal. Measured, not assumed. The pins' stored rows carry origin: 'runtime', and the slot the refusal reads holds AppPlugin's explicit origin: 'code'. Under ablation A the admin door served the stored row as origin: runtime, so the refusal cannot come from the admin read's origin ?? 'code' default.
  • H4, the live pool. For default: yes. The restored row reached rehydratePools, which opened a second pool named default on the row's file. Routing did not move, because the engine never routes to a driver named default; the default driver keeps its natural name. It is the same defect and the same decision fixes it, pinned by getDriverByName('default') and the connect verdict. For showcase_external, nothing was re-pointed at boot in this composition: AppPlugin's connect ran first, so the rehydrate answered already-registered. The admin PATCH 200 was the open door to a re-point (an update that changes connectivity rebuilds the pool), and it is now refused.

Seam and the Clause-② limb (for the seat)

  • Published exports added: none. code-datasource-names.ts is not re-exported from packages/runtime/src/index.ts. service-datasource and metadata-protocol spell the service name privately and read the value structurally as has(name), the way 'datasource-connection' is read today.
  • What the seam does add is one kernel service entry, code-datasource-names, which two packages read by name. Whether that is the claim's "service contract" limb is the seat's call. The line above stays as the claim wrote it, and the changeset grades all three packages minor, which holds under either reading.

Named gap: the metadata door's read while a stored row exists

GET /api/v1/meta/datasource/:name still serves a stored row under a code-defined name for as long as the row exists. The door reads its stored overlay first (ADR-0005's read order), whatever the MetadataService holds. The AFTER boot measured it: the admin door served the code definition while the meta GET served the stored row, for showcase_external and for default. So triage's pins "both doors serve the code definition" and "removes it with no change to what is served" hold for the admin door. For the metadata door they hold once the repair DELETE has run, in the same boot. That read lives in getMetaItem's overlay step, outside isDeclaredCodeDatasource, and protocol.ts is held by #21934 in other regions, so it is left to the seat. meta-door-code-datasource.dogfood.test.ts already pins that read as it is.

#21922 stays open for that read: this PR is Part of it, and the seat routes the remaining half through triage when it merges.

Landing beyond the claim's named files

packages/runtime/src/app-plugin.ts is the producer of the packages' half of the set, in the declared runtime package. The memo also makes its residual-owner warning print once instead of once per phase. packages/runtime/src/code-datasource-names.ts is new in the same package.

Patch round 1 (head d77e150701)

Review 6011282321 on #21922. The Tests, Ablations and Gates sections below are round 0's, at 80fbcfdea6; this section carries the readings on the current head.

  • The plugin-dev pin (CI red on round 0). AppPlugin.init() now contributes its datasource names as a function that the host's code-datasource set resolves at its first read. The set is still contributed to only in Phase 1, before any start(). The resolution is deferred because the names come from the artifact's collections, which walk packages[]. AppPlugin.init()'s manifest registration is the one thing in init() allowed to touch packages[], pinned by plugin-dev's malformed-stack falsifier, which this PR's first head turned red. The kernel service now holds a CodeDatasourceNames, a set with pending contributions; readers still use has(name) only. A contribution that throws stays pending and rethrows to every reader.
  • The default refusal names what defines it: the host's database configuration (the database URL the server starts with). It names no *.datasource.ts, because none declares default. Every package-declared datasource's sentence is byte-identical, and code, status and the refused set are unchanged (packaged-base-regime.ts, the datasource row's hostOwned).
  • const listOf = ( spacing restored in app-plugin.ts. Merged origin/main 80f9f7e6ba as 49421a8fe6.
  • Ablation D: the old sentence put back for default turned 6 unit cases and 1 dogfood case red, and was restored by blob.
  • Tests at d77e150701 (each VERDICT command-exit 0):
    • service-datasource: 748 / 748;
    • metadata-protocol: 27978 passed, 19 skipped;
    • runtime local: 4668 passed, 19 skipped;
    • plugin-dev: 86 / 86;
    • the two dogfood files: 11 / 11;
    • downstream consumers of runtime (cli, client, verify, http-conformance, cloud-connection): all passed;
    • typechecks for the five packages: green.
  • Gates at d77e150701: dispatch-gates --commands derived 72, reconciled with --ran as 72 run and 0 not measured, plus check:init-service-contract and check:startup-registry-verdict. All exit 0.
  • Docs: the 18 hand-written pages the Docs Drift Check lists were read page by page. None states anything this PR makes false, so no docs are edited.

Tests (head 80fbcfdea6)

  • pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2: 41 files, 748 passed.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 218 files passed, 3 skipped; 27976 tests passed, 19 skipped.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --project local: 331 files, 4658 passed, 19 skipped.
  • Dogfood, --project isolated: datasource-restore-code-wins.dogfood.test.ts (new) and meta-door-code-datasource.dogfood.test.ts, 2 files, 11 passed.
  • typecheck green for service-datasource, metadata-protocol, runtime (including its check:test-typecheck ledger, held) and dogfood. tsc --listFiles counts each touched test file once in its program.
  • Each package's run includes its new pins: 5 in datasource-admin-plugin.test.ts (the restore), 4 in code-datasource-names.test.ts (the set and its phase), and 2 resolver plus 8 door cases in protocol.code-defined-datasource-door.test.ts (default, on both kernel shapes).

Ablations

Each one was committed first and mutated with scripts/ablation-replace.mjs in wrap mode, under a shell trap. For subjects resolved through dist/, the package was rebuilt and ablation-dist-preflight.mjs proved the marker was present. The restore leg was rebuilt and proven --absent, the blob equalled HEAD, git diff HEAD was empty, and the tree was clean.

  • A, the restore registers over a code name (datasource-admin-plugin.ts; marker in 2 files of service-datasource/dist). Unit: 3 failed, 16 passed. The slot served the stored row, the warning was not called, and the order-independent case registered the row. Dogfood: 2 failed, 3 passed. The admin list served showcase_external as the stored row, and the repair case read the same.
  • B, the resolver does not know the set (protocol.ts; marker in 2 files of metadata-protocol/dist). Unit: 5 failed, 30 passed (the resolver case, and PUT plus no-row DELETE of default on both kernels). Dogfood: PUT /meta/datasource/default answered 200. The next case then failed as a cascade, because the row that PUT stored made the seed conflict. The repair DELETE and runtime controls stayed green, as expected.
  • C, AppPlugin's init() contribution deleted (app-plugin.ts; the subject resolves from source). The reader-first boot saw ['default'], not ['app_wh', 'default']. So the set comes from init(); the start() registration never fills it.

Gates (head 80fbcfdea6)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 72 commands on the actual change, a superset of the 52 at dispatch. All 72 ran with exit codes captured before any pipe. --ran printed "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED".

  • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) because eight packages outside this diff had no dist/. After building them it measured green.
  • Two families the derivation does not name were also run, both green: check:init-service-contract ("34 declared / 1 self-provided / 3 without a workspace provider") and check:startup-registry-verdict ("none recording a verdict the boot can contradict").
  • check-changeset-no-major's level axis needs a PR payload, so CI reads it.
  • Lint is a proven narrowing, not the repo-wide run. eslint --no-inline-config --format json on the 9 touched source and test files reported 9 files, 0 errors and 0 warnings. eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules, as its own comment states), so this diff cannot move any untouched file's verdict.

Acceptance notes


Generated by Claude Code

claude added 4 commits October 6, 2026 06:20
… displaces a code datasource; the host's code-datasource set decides

The runtime keeps one in-memory set of the datasource names it registers
from code (the artifact's declared datasources and the host's `default`),
filled from AppPlugin's and DefaultDatasourcePlugin's init() so it is
complete before any start(). The datasource-admin plugin's boot restore
skips a stored row under one of those names, keeps it, and warns naming
it; the metadata door's code-datasource resolver reads the same set, so
it refuses edits to `default` as the admin door does.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…wins at the restore, the set's phase, and the /meta answer for default

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…he metadata door refuses edits to default

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 3 package(s): @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/service-datasource, touching 23 documentable anchor(s).

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 80f9f7e6ba5d2097a4cb32ca696908dcf9678102.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 6 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 — 31 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 80f9f7e6ba5d2097a4cb32ca696908dcf9678102 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9fe98ea34f3e235cbf284678fcec055b824cd7e2 — the merge of head d77e15070132990d8f99405b33e22afd4256abb5 into base 80f9f7e6ba5d2097a4cb32ca696908dcf9678102, 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 9fe98ea34f3e235cbf284678fcec055b824cd7e2 && git checkout 9fe98ea34f3e235cbf284678fcec055b824cd7e2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 80f9f7e6ba5d2097a4cb32ca696908dcf9678102 d77e15070132990d8f99405b33e22afd4256abb5 && git checkout -B drift-repro 80f9f7e6ba5d2097a4cb32ca696908dcf9678102 && git merge --no-ff d77e15070132990d8f99405b33e22afd4256abb5

node scripts/docs-audit/affected-docs.mjs --json 80f9f7e6ba5d2097a4cb32ca696908dcf9678102

⚠️ 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 80f9f7e6ba5d2097a4cb32ca696908dcf9678102 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI status · domain:services seat 2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-06T07:14Z


Generated by Claude Code

claude added 2 commits October 6, 2026 07:21
…n the set resolves at first read, so init() never walks packages[]

AppPlugin.init()'s manifest registration is the one thing in init() allowed
to touch the artifact's packages[]; resolving the owner list there made a
malformed packages[] refuse from the collections getter too. The host's
code-datasource set now holds deferred contributions, resolved at its first
read (still after every init(), before any reader in start()), and a
contribution that throws stays pending and reaches every reader. Also
restores the space in `const listOf = (`.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…st's database configuration, not a *.datasource.ts that does not exist

The origin-gated datasource row gains `hostOwned`: names the host defines
from its own configuration. `default` is the one, reserved by contract
(AppPlugin refuses an artifact declaring it, the admin service refuses to
create it), so its PUT / no-row DELETE refusal now says what defines it and
to restart the server; every package-declared datasource's sentence is
byte-identical. Code, status and the refused set are unchanged.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… decision in words instead of a tracker number (stage 25) (objectstack-ai#21975)

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

Stage 25 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 second and last name-ordered `api/` group: the 13
id-bearing test files directly under `packages/spec/src/api/` from
`plugin-rest-api.handler-status-retirement.test.ts` to
`zod-issues-to-fields.test.ts`. Those files carried 89 messages and 95
tracker ids, citing 43 records. All 95 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.
With this stage, `api/` carries no tracker id in a test string.

## Census at the base (`5a22eb5619`)

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 24 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 `5a22eb5619`, the claim's
base and stage 24's landing. Both instruments read **371 messages / 392
ids in 84 files**, the seat's reading and stage 24's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `system/` | 34 | 154 / 167 | 128 / 138 | 26 / 29 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `api/` (this PR: all 13 files) | 13 | 89 / 95 | 86 / 92 | 3 / 3 |
| `ui/` | 5 | 7 / 7 | 0 | 7 / 7 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **84** | **371 / 392** | **331 / 349** | **40 / 43** |

The group reads **89 messages / 95 ids in 13 files**, the seat's figures
file for file:

| file (under `api/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `plugin-rest-api.handler-status-retirement.test.ts` | 4 / 4 | 3 / 3 |
1 / 1 |
| `plugin-rest-api.schema-refs.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `plugin-rest-api.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `protocol.test.ts` | 46 / 50 | 46 / 50 | 0 |
| `registry-retirement.test.ts` | 2 / 2 | 1 / 1 | 1 / 1 |
| `rest-api-config-dead-keys-retirement.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `rest-server.test.ts` | 19 / 19 | 18 / 18 | 1 / 1 |
| `router.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `sortability.test.ts` | 3 / 4 | 3 / 4 | 0 |
| `storage.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `validate-data.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `websocket.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `zod-issues-to-fields.test.ts` | 2 / 3 | 2 / 3 | 0 |
| **13 files** | **89 / 95** | **86 / 92** | **3 / 3** |

Five more test files sit in the same name range and carry no id
(`query-adapter.test.ts`, `realtime-shared.test.ts`, `realtime.test.ts`,
`retired-error-codes.test.ts`, `versioning.test.ts`). The three "other"
strings are expect failure messages, rewritten and declared to the
text-only tool: `plugin-rest-api.handler-status-retirement.test.ts:179`
and `rest-server.test.ts:768` (template literals) and
`registry-retirement.test.ts:89` (one leaf of a `+` chain).

- **Controls.** Lit: `ui/notification.test.ts` (1 id) and
`system/book.test.ts` (2 ids), outside the group, read the same at the
base and at the head. Dark: `protocol.test.ts` reads 0 at the head while
65 of its lines still carry a number, every one of them a comment.
Planted in a scratch tree: an id put into a `storage.test.ts` title
reads 1 / 1 (`title:it`), and an id put into a `sortability.test.ts`
comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in all 13 files at the base, and 0 in all 13 at the head.
- **At the head:** 282 messages / 297 ids in 71 files. The 13 files read
0 / 0, `api/` leaves the table, and no other file moved.

## How the area was chosen

`api/` has no subdirectory, so it is taken in name-ordered file groups
near the ~100-id bound, the rule stages 20 to 24 used. Stage 24's cut
named this group at 95 ids, and this census reads 95, so no re-cut was
needed. `protocol.test.ts` (50 ids) fits one PR and one text-only proof,
so it is not split.

**Named for the next stages** (cut from the head census, 282 / 297):
- **`system/`**, 167 ids in 34 files (one of them in
`system/constants/`), two stages:
- **first group:** `auth-config.test.ts` through
`metadata-form-declared-rows.pin.test.ts`, 18 files, 91 messages / 97
ids (`i18n-resolver.test.ts` alone 53 / 56);
- **second group:** `metadata-form-zod-reconciliation.test.ts` through
`worker.test.ts`, 16 files, 63 / 70. Its first file carries 17 "other"
strings, its ledger `why` entries.
- The files directly in `src/`, 120, one stage.
- The needles: the three docblock needles, the kept
`ui/component-props-unknown-members.pin.test.ts:322` and stage 22's two.
One stage, with an at-tier review. The four colour literals stay, as
stage 21 decided.

## What each id became

- **10 literals (11 ids)** now state a decision in words.
- **12 literals (16 ids)** get their subject back in words, where the
number stood for a thing.
- **67 literals (68 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. 43 records are cited: 36 answer 200 and 7 answer 404.
Two more were read for context: objectstack-ai#14478, whose ruling B objectstack-ai#15677 executes,
and PR objectstack-ai#11426, objectstack-ai#11006's landing. The seven that answer 404 were read
from what landed, through the commits endpoint (this checkout is
shallow), each commit found through the CHANGELOG entry or the commit
list of `protocol.test.ts`:
- **objectstack-ai#6037**, from `18189983dd` (objectstack-ai#6474): `DataProtocol.validateData` asks
the write path for its verdict and persists nothing, objectstack-ai#4633 ruling D;
- **objectstack-ai#6239**, from `f549a0d4ad` (objectstack-ai#6526): `ViewProtocol`'s five
viewId-addressed methods and ten schemas are retired;
- **objectstack-ai#6361**, from `90bbf25107` (objectstack-ai#6866): the notification-list `cursor`
is tombstoned on both halves (maintainer ruling 2026-08-07, option A);
- **objectstack-ai#9740**, from `11b779e0f9` (objectstack-ai#9773):
`MetadataProtocol.getMetaItemLayered` is declared, and the dead
`'overlay'` `lockSource` arm is dropped;
- **objectstack-ai#9741**, from `2a29caa532` (objectstack-ai#9804): `previewDrafts` / `state` are
declared where the implementation enforces them, and `environmentId` is
recorded as transport-level. Its changeset
(`packages/spec/CHANGELOG.md:31798`) names it "maintainer ruling
2026-08-18", and `cccbe51bf7` cites "the objectstack-ai#9741 ruling";
- **objectstack-ai#11006**, from `cccbe51bf7` (objectstack-ai#11426): `publishMetaItem` is declared
as an optional member with `PublishMetaItemRequest` (maintainer ruling
2026-08-22, option B);
- **objectstack-ai#14691**, from `b3a63d32c9` (objectstack-ai#14868): the ten inert
`RestServerConfig` keys the liveness ledger recorded as `dead` are
retired.

**The same-id titles stage 24 listed in this group:**
- **`[objectstack-ai#5672]` x2** (`protocol.test.ts:508`, `:526`): objectstack-ai#5672's maintainer
ruling A (`5199159328`): one closed capability vocabulary, emitted in
full by both discovery producers, with an absent capability `enabled:
false` rather than a missing key. `:508` now reads "strips a capability
key outside the closed vocabulary". The old verb was "rejects", but the
body pins the opposite: the parse stays green and the key does not
survive it. `:526` now reads "… (ruled: an absent capability is
`enabled: false`, not a missing key)".
- **`(objectstack-ai#12038)` x5** (`:2575` to `:2686`): these five "declares the …
body" describes are the describe-only transcriptions that the five-part
ruling's implementation plan names (`5434804846`). None of them pins a
lettered sub-ruling, so no letter is named; the title already says the
decision, and only the number goes.
- **`(objectstack-ai#12038 1C)`** (`:2710`): now "GetPublishedMetaItemResponseSchema
stays opaque (ruled: no shape frozen against the current type
registry)", ruling 1C's own reason. Its children pin the `unknown` body.
- **`(objectstack-ai#19543, door ③)`** (`:2726`): door ③ is the AI-conversation list,
which the schema name already names, and "declares the next-page signal"
is that door's spec half (letter A, re-derivation `5825819437`). Only
the number and the door label go.
- **`(objectstack-ai#15677)`** in `plugin-rest-api.test.ts:694` and
`websocket.test.ts:712`: now "… durations carry their unit in the key
name", objectstack-ai#14478's ruling B (`5518649320`, population ruling `5548763981`),
which objectstack-ai#15677 executes for `api/`. In `router.test.ts:565` the title
already shows the rename (`RouteDefinition.timeout → timeoutMs`), so
only the number goes.

**Stated in words:**

| record | literal (under `api/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#14478 via objectstack-ai#15677 | `plugin-rest-api.test.ts:694`,
`websocket.test.ts:712` | "… durations carry their unit in the key name"
| Ruling B: a `z.number()` duration key carries its unit in its name;
the old spellings are `retiredKey()` tombstones. |
| objectstack-ai#5672 | `protocol.test.ts:508` | "strips a capability key outside the
closed vocabulary" | Ruling A (2026-08-06): one closed vocabulary. |
| objectstack-ai#5672 | `protocol.test.ts:526` | "rejects a capability map that is
missing part of the vocabulary (ruled: an absent capability is `enabled:
false`, not a missing key)" | Ruling A: both producers emit the whole
vocabulary. |
| objectstack-ai#9406 | `protocol.test.ts:1313` | "probes is opaque BY DECLARATION
(ruled: modeled only once a consumer needs a field): …" | Maintainer
ruling 2026-08-18 (`5322875103`): `probes` gets a deliberately opaque
passthrough, upgraded to a modeled schema only when a consumer needs a
field of it. |
| objectstack-ai#9343 | `protocol.test.ts:1383` | "PublishPackageDraftsResponseSchema
published[].advisories (ruled: advisory findings ride each published
element)" | Maintainer ruling 2026-08-17 (`5321046016`): `advisories`
rides each `published[]` element, with no parallel top-level map. |
| objectstack-ai#9741 | `protocol.test.ts:1739` | "environmentId stays OUT of the
meta-read request shape — transport-level by decision, not omission" |
The 2026-08-18 ruling, as landed in `2a29caa532`: `environmentId` is the
transport-level multi-kernel routing key. |
| objectstack-ai#12038 | `protocol.test.ts:2710` | "GetPublishedMetaItemResponseSchema
stays opaque (ruled: no shape frozen against the current type registry)"
| Ruling 1C (`5434804846`): a thin envelope with the body opaque, no
union frozen against today's type registry. |
| objectstack-ai#6037 | `validate-data.test.ts:25` | "ValidateDataRequest — asks the
write path for its verdict instead of predicting it" | What landed in
`18189983dd`: the dry run stops predicting the write's verdict and asks
for it. |
| objectstack-ai#6037 | `validate-data.test.ts:57` | "ValidateDataResponse — the
verdict the write path would reach, persisting nothing" | The same
commit: `validateData` reports the write path's verdict on candidate
rows and persists nothing. |

**Subject back in words** (12 literals):
- "zero holders after objectstack-ai#13823" becomes "zero holders after its
retirement", and "[objectstack-ai#13823] ADR-0087 registration" becomes "handlerStatus
retirement — ADR-0087 registration", the form of the repo's other
retirement registration describes (objectstack-ai#13823 ruled remove, `5494755488`);
- "the routes wired in objectstack-ai#3899" becomes "the routes wired to the
request-schema gate", the gate the file's header names;
- "(objectstack-ai#13155 — carries objectstack-ai#5745 to the third verb)" becomes "(carries the
declared = returned discipline to the third verb)", the discipline objectstack-ai#7294
and objectstack-ai#13155 name objectstack-ai#5745 for;
- "(objectstack-ai#4717 — objectstack-ai#4463 D3 on the response)" and "(objectstack-ai#9176 — objectstack-ai#4463 D3 on the
publish door)" become "(advisory findings ride the 2xx response)" and
"(advisory findings ride the 2xx on the publish door too)": objectstack-ai#4463's D3
sends gating findings to 422 and lets advisory findings ride the 2xx;
- "the objectstack-ai#9612-gate class" becomes "the package-closure publish-gate
class": objectstack-ai#9612's gate judges a publish against the written package's
closure;
- "objectstack-ai#10235 the objectstack-ai#7865 anchor category" becomes "the unprovisioned
injected-anchor category", the platform anchors injected into an
external object whose storage the platform does not provision (objectstack-ai#7865,
ruling B);
- the two "pre-objectstack-ai#3689" storage shapes become shapes "from before the
shared success envelope";
- "the objectstack-ai#4052 non-repeat" becomes "the non-repeat of the retired
`validateOnly` dry-run flag";
- "every objectstack-ai#8055-shaped fixture" becomes "every malformed-flow-body
fixture".

**Dropped where already stated** (67 literals, 68 ids). A number goes
only where the title already says its decision. Examples: the two
`[objectstack-ai#13823]` describes and the twelve `[objectstack-ai#14691]` / `(objectstack-ai#14691)` retirement
titles ("REJECTS `patterns` with the retirement prescription — …", "the
tombstones reject one key each, not the config — …"); `[objectstack-ai#11983]` x3,
`[objectstack-ai#4579]` x2, `[objectstack-ai#4939]`, `[objectstack-ai#6361]`, `[objectstack-ai#20294]` and `objectstack-ai#3899 —`; the
`objectstack-ai#10235` prefixes on "resolveObjectSortability — the closed category
set" and "wire validity — …"; the five "transport-level by the objectstack-ai#9741
ruling" titles, which now read "transport-level by ruling"; the
parenthesized `(objectstack-ai#5745 — …)`, `(objectstack-ai#7294 — …)`, `(objectstack-ai#9406 — …)`, `(objectstack-ai#10524 —
…)` x2, `(objectstack-ai#9726 — …)`, `(objectstack-ai#9741 — …)` and `(objectstack-ai#4717 — …)` pairs, which keep
their words; and the tails `(objectstack-ai#6239)`, `(objectstack-ai#4286)`, `(objectstack-ai#9740)`, `(objectstack-ai#11006)`
x2, `(objectstack-ai#11678)` x3, `(objectstack-ai#9426)`, `(objectstack-ai#12005)` x3, `(objectstack-ai#11679)` x2, `(objectstack-ai#12004)`
x2, `(objectstack-ai#3718)`, `(objectstack-ai#4572)`, `(objectstack-ai#4579)`, `(objectstack-ai#20294)`, `(objectstack-ai#8124/objectstack-ai#8055)`, the
five `(objectstack-ai#12038)` and the `(objectstack-ai#4738, …)` aside in one expect message. The
404 numbers among them (objectstack-ai#6239, objectstack-ai#6361, objectstack-ai#9740, objectstack-ai#9741, objectstack-ai#11006, objectstack-ai#14691) go
only where the title already states what landed.

**No file is renamed.**

## Readers

- **Needles:** none. The three declared strings are assertion failure
messages (the second argument of `expect`), none is an expected value,
and no title or message in the group is matched against a source
docblock or another file's text. The one self-read in the group,
`rest-api-config-dead-keys-retirement.test.ts:519`, reads its own file
for the id-free describe title "tree-scoped absence", which this PR does
not touch.
- **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 13 files calls a snapshot matcher.
- **Projects:** `rest-api-config-dead-keys-retirement.test.ts` is in the
`repo` project (`packages/spec/vitest.repo-tests.json:31`); the other 12
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 (270 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 6 hits are sibling test titles: the two `(objectstack-ai#15677)`
describes in this group hit each other (both rewritten here),
`client/src/client.test.ts:1134` shares "query.distinct (objectstack-ai#4286)", and
`metadata-protocol/src/protocol.validate-data.test.ts:102` shares "the
objectstack-ai#4052 non-repeat".

## 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 three
expect-message lines named above.

- **Result:** 13 of 13 files SAME on all three legs, with the per-file
counts predicted in writing before any edit.
- **Totals:** 89 changed string leaves in 89 literals: 86 titles and 3
declared. The diff's `+` and `-` lines are exactly the 89 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 13 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 509 tests in 13 files, all passed, with the same count and
status sequence per file in 13 of 13. 250 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, and no head
name carries `#` plus digits (250 base names did). `router.test.ts:565`
writes its arrow as a `→` escape; the plan's anchor there starts after
the escape, so the comparison tool, which reads escapes literally, met
none, and vitest prints "RouteDefinition.timeout → timeoutMs …" on both
sides.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
13 touched files are in it, and no `*.test.ts` at all. The controls
`src/api/protocol.zod.ts`, `src/api/rest-server.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 `c63eba0adf`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (`VERDICT command-exit 0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 619 files, 18480 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 13 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: stage 24's
80 without `check:error-code-casing`, whose named sources this diff does
not touch. 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` `230e4944b0` 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 13 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 13 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 178 changed lines (+89 /
-89).
- A control-byte scan over the 13 changed files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was six commits
past the base (`c9761cd2fb`: objectstack-ai#21966, objectstack-ai#21951, objectstack-ai#21963, objectstack-ai#21969, objectstack-ai#21965,
objectstack-ai#21962). They touch 28 files, none of the 13 and none under
`packages/spec/src/api/`, so `main` was not merged. The two
`packages/spec/src` files they change
(`data/datasource-credential-redaction.ts` and its test) read 0 / 0 in
the census at `c9761cd2fb`: the one id they add is a code comment. `git
merge-tree` onto `c9761cd2fb` is clean, and none of the 4 open PRs
touches any of the 13 files.

## Acceptance notes

- **Same-id test titles in this card's later stages** go with those
stages: `system/book.test.ts:413` (`(objectstack-ai#12038)`).
- **Same-id test titles in other packages** stay: 97 lines in 15
packages (`runtime` 25, `objectql` 13, `lint` 12, `metadata-protocol`
12, `rest` 12, `client` 8, `metadata-core` 4, `qa/dogfood` 2,
`service-automation` 2, `service-storage` 2, and one each in
`examples/app-crm`, `examples/app-showcase`, `driver-sql`,
`plugin-sharing` and `types`), each package's share under the objectstack-ai#20513
lane children.
- **Code comments with live ids** remain in these files and their
sources, among them the `* objectstack-ai#3899 —` header in
`plugin-rest-api.schema-refs.test.ts`, the `* objectstack-ai#8124 —` header in
`zod-issues-to-fields.test.ts`, the `// [objectstack-ai#5672] This fixture used to
lead with …` comment above `protocol.test.ts:508`, and the `/** [objectstack-ai#20294]
… */` docblock in `rest-api-config-dead-keys-retirement.test.ts`. 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
…rom provenance, and a metadata-door write reaches it in the same boot (objectstack-ai#21977)

Fixes objectstack-ai#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 objectstack-ai#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); objectstack-ai#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 objectstack-ai#6024)
- `.changeset/21923-datasource-origin-from-provenance.md`
(`@objectstack/service-datasource` patch)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ource's code definition while a stored row exists (objectstack-ai#21985)

Fixes objectstack-ai#21922
Clause-②: no

## What changes

The metadata door no longer serves a stored `sys_metadata` row under a
datasource name the host registers from code. While such a row exists,
`GET /api/v1/meta/datasource/:name`, the `GET /api/v1/meta/datasource`
list and the `effective` layer of `/layers` all serve the in-memory code
definition. That is what the admin door and the boot restore already
serve. This is the remaining half of this card, after the boot restore
and admin door half landed in PR objectstack-ai#21965.

The change is the shape triage's answer A names (comment 6013780883),
the one the shipped-flow read already uses. In
`packages/metadata-protocol/src/protocol.ts`:

- **One predicate.** `declinesStoredRow(type, name)` asks "is this a
name whose stored row the active reads never adopt?". It holds for
exactly two kinds of name: a shipped flow name (`isShippedFlowName`,
unchanged) and a code-defined datasource name
(`isDeclaredCodeDatasource`, unchanged, reading the host's
`code-datasource-names` set and then the installed packages). Its
registry twin, `isStoredEntryOfDeclinedName`, catches the hydrated
bare-key copy of such a row (`!isCodeArtifactBody`). ⛔ No third
precedence path, and ⛔ no decision read from any `origin`.
- **`getMetaItem`**: the stored-row step skips the row on the ACTIVE
read (`storedRowDeclined`). Step 2 then serves the MetadataService's
in-memory registration. The row is still FOUND (`storedRowServed`), so
`servedLockState` keeps `deletable: true` while it exists. A draft read
(`state: 'draft'`, or `previewDrafts`) is answered as a draft.
- **The list** (`readFlattenedMetaItems`, both faces): the registry half
drops the hydrated copy, and the stored-row half keeps the row out of
the merge and out of the package stand-ins. The MetadataService base,
merged in below, supplies the code definition.
- **`getMetaItemLayered`**: `effective` asks the same predicate. The
spec defines `effective` as "the value an ordinary `GET
/meta/:type/:name` would return under `item`"
(`GetMetaItemLayeredResponseSchema`), so it has to move with the by-name
read. The row stays reported in `overlay`.
- Every other type keeps ADR-0005's read order. The `/meta` DELETE path
is untouched: its own row probe still finds and removes the row.

## Hypotheses (Partition 2), measured at `f76c6221ac`

- **H1, confirmed.** `getMetaItem` adopted the row at
`protocol.ts:10038` (`findServedOverlayRow`) and `:10047` (`if (record
&& !shippedFlowActiveRead)`), whatever the MetadataService held.
- **H2, confirmed and extended.** The objectstack-ai#20946 shape is the by-name
stored-row half (`:9952`–`:9978`), the list's registry half
(`:8923`–`:8926`) and the list's stored-row half (`:8979`–`:8998`).
`isDeclaredCodeDatasource` (`:16326`) reads the host set at `:16328`.
Measured addition: the list needs the registry half too. The boot pull
(`loadMetaFromDb`) and an unscoped list hydrate the row under the bare
key, and the registry layer is merged OVER the MetadataService base
(`:9287`). Ablation B below shows that half is load-bearing on its own.
`getMetaItem` needs no registry half for datasource, because step 2
answers before step 3.
- **H3, confirmed with no new rule.** `servedLockState` (`:16488`) reads
`storedRowServed`, which is set from the row FOUND (`:10045`) before the
adoption check, so a declined row keeps `deletable: true`. That is
exactly what `originGatedRemovalRefusal`'s docblock states ("`deletable`
true exactly while the read found a stored row"). `deleteMetaItem`
(`:25771`) decides from its own probe and never reads the served body.
The `_lock` gate's overlay layer is read whether or not the row is
adopted, as for flows. Triage's stop condition is not met.
- **H4, confirmed disjoint.** objectstack-ai#21967's branch touches `protocol.ts`
hunks at `:2003`, `:9051`–`:9107`, `:9610`–`:9715` and `:19206` (read
from its pushed branch). None of this PR's sites overlaps them.
`origin/main` was merged (`65d7b740b2`); objectstack-ai#21967 had not landed.

## Pins

- **Unit, `metadata-protocol`**
(`protocol.code-defined-datasource-door.test.ts`, a new `[objectstack-ai#21922]` block
on both kernel shapes, 14 cases):
- (a) by name (both spellings), in the list before and after the row is
hydrated, `default` through the host set, and the layered `effective`;
- (b) the DELETE still removes the row, and the reads serve code after
it;
- (c) a runtime datasource's row is still served by name, in the list
and as `effective`;
  - (d) the strict draft read and the preview arm serve the draft row.
- The block reuses the file's pinned engine double, so the pinned ledger
is untouched. That double's `find` now honours `where` and `limit` (it
returned every row), and its registry gains an opt-in hydrating slot
plus `isPackageDisabled`, which the list calls.
- **Dogfood** (`datasource-restore-code-wins.dogfood.test.ts`, one
case): after a restart over stored rows under `showcase_external` and
`default`, `GET /api/v1/meta/datasource/:name` and the `/meta` list
serve the code definition (label, `origin: 'code'`, `_packageId`, and
never the row's file). A runtime datasource's row is still what both
doors serve. The repair case that follows still removes each row.
- **Fixture triage:** `meta-door-code-datasource.dogfood.test.ts` read
`SHADOW_LABEL` after its restart. That read pinned exactly the branch
this change removes, so it now expects the code label. The restore
file's header paragraph saying the meta door serves the row is replaced.

## Reverse verification (on committed HEAD, restore proven by blob)

- **Ablation A (unit):** `return typeof name === 'string' && name !== ''
&& this.isDeclaredCodeDatasource(type, name);` was replaced by `return
false; // ABLATION-21922` via `scripts/ablation-replace.mjs` (anchor 1
to 0, blob `7890c4b99f6e` to `6d70364fc822`). **10 failed / 41 passed**:
(a)x4 and (d) on both kernels. (b) and (c) stay green, as predicted,
because they do not depend on the decline. Restored: blob == HEAD
(`7890c4b99f6e`), `git diff HEAD` empty.
- **Ablation B (unit, registry half only):** the list's filter reverted
to `isStoredFlowEntryOfShippedName`. **4 failed / 47 passed**: exactly
the post-hydration list cases (`showcase_external` and `default`) on
both kernels. Restored by blob.
- **Ablation C (public door, through `dist/`):** the same arm replaced
by `return name === 'ABLATION-21922';`, then `pnpm --filter
@objectstack/metadata-protocol build`, then `ablation-dist-preflight.mjs
@objectstack/metadata-protocol ABLATION-21922` (exit 0, marker in dist),
then both dogfood files: **3 failed / 9 passed**. The two direct reds
are the new case and the flipped read. The third ("after the repair and
a restart") is a cascade: the flipped case failed before its DELETE, so
the row survived the restart and the ablated read served it. Restore
leg: blob == HEAD, rebuild, then `ablation-dist-preflight --absent`
(exit 0).

## Readings at head `65d7b740b2` (after the `origin/main` merge)

- `pnpm --filter @objectstack/metadata-protocol test`: **218 files
passed, 3 skipped; 28010 tests passed, 19 skipped**. `typecheck` (`tsc
--noEmit`): exit 0. The package `tsconfig` includes `src/**/*`, so the
edited test file is compiled.
- Dogfood, both files: **12/12 passed**.
- `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up
to date (spec moved on the `main` side of the merge).
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths; 5 paths vs
merge base `04e776b39`): **68 derived; `--ran`: 68 run, 0 NOT-MEASURED,
0 UNRUN**. `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT
MET` (exit 3, eight packages outside the dogfood closure had no
`dist/`). It answered exit 0 after those were built, and the record
carries that reading.
- Artifact-roster block (53 rows, outside the total): 50 exit 0.
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths` answered NOT WIRED (exit 2: no PR context
existed yet). They are NOT MEASURED here, and the report carries their
run against this PR.
- Symbol-anchor sweeps: `check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and
`check:adr-anchors` all exit 0.
- **Lint, a proven narrowing** (repo-wide `pnpm lint` is CI's): `eslint
--no-inline-config --format json` over the four touched TypeScript files
at `65d7b740b2` reports 4 files linted, none ignored, 0 errors and 0
warnings. The config never enables type-aware linting (no
`parserOptions.project`, as `eslint.config.mjs` states), so this diff
cannot move any untouched file's verdict.

## Changeset, measured against the built entry declarations

`@objectstack/metadata-protocol` patch, with `Clause-②: no`. The BASE
(`f76c6221ac`) and HEAD builds of `dist/index.d.ts` and `index.d.cts`
differ in two non-comment lines only: `private declinesStoredRow;` and
`private isStoredEntryOfDeclinedName;` on
`ObjectStackProtocolImplementation`. No public member, parameter or
return type moves.

## Named gap, not moved here: the `/published` door

Measured with a throwaway probe at `8c4bd140de` (never committed): after
a restart over a stored row, `GET
/api/v1/meta/datasource/showcase_external` serves the code definition,
and `/layers` answers `effective` = code with `overlay` = the row. `GET
/api/v1/meta/datasource/showcase_external/published` still serves the
row (`origin: runtime`). The REST door and its runtime dispatcher twin
decide by asking the public `isShippedFlowName` alone. The spec
describes that route as serving the active overlay row, so no published
text is made false. But following the layered read there needs a public
predicate on the exported class, which is a widening this claim's
`Clause-②: no` does not cover. It is reported to the seat with its
options.

## Acceptance notes

- `getMetaItem`'s step-3 registry half stays flow-only. A code
datasource is never a SchemaRegistry item, and step 2 has already served
it.
- A declined row is still hydrated into the registry as the tenant row
it is, following the flow precedent. `git grep` finds no reader of
registry `datasource` entries outside `protocol.ts`.
- Read-only inference, not measured: a name declared by a package
installed after boot is code to `isDeclaredCodeDatasource` (manifest)
with no in-memory registration. For such a name the reads now fall
through to whatever the MetadataService loaders hold, not the row.
- Deviation, self-inflicted and recovered: a declaration measurement's
restore (`git checkout HEAD -- protocol.ts`) discarded one uncommitted
docblock edit. It was detected by a marker count, re-applied and
committed as `0b2a164598`. Every reading above was taken after it.

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

---------

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

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants