Skip to content

fix(service-datasource): external validate sees a federated object saved at runtime, with no restart - #21875

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21842-validate-reads-live-registry
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21842-validate-reads-live-registry

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21842
Clause-②: no

What this changes

ExternalDatasourceServicePlugin wired the federation service's listObjects and getObject to the metadata service. That service holds a copy of the engine's object registry, taken once at boot by ObjectQLPlugin's startup bridge. PUT /api/v1/meta/object/:name, and the import that saves through it since #21837, write sys_metadata and the engine registry (applyRegistryWriteThrough), never that copy. So POST /api/v1/datasources/:name/external/validate did not see a runtime-saved object until the next restart.

Both object reads now come from the engine's object registry on the objectql service (IObjectQLEngine.registry, its getAllObjects and getObject). The registry is looked up when validation runs, never at init() (AGENTS.md, "Startup registry reads"), the same way the import's save door is. There is no second copy and no hand refresh. The service's comparison is untouched, and datasource definitions, the package namespace and the catalog write still go through the metadata service exactly as before (see Decision needed below).

Files: packages/services/service-datasource/src/plugin.ts, one new unit file beside it, one new dogfood file, and a patch changeset. No packages/spec edit, no new export, no content/docs sentence changed (none describes where the validate door reads its objects).

Measured on the real composition (objectstack dev, showcase)

step base 2df3d13d this branch
validate showcase_external, before any save 2 rows, showcase_ext_customer and showcase_ext_order, both ok same
PUT /meta/object/dg21842_saved (bound to remote customers, fields name, email, ghost_col), then validate 200, then still 2 rows 200, then 3 rows; dg21842_saved is ok: false with one diff, missing_column ghost_col
import remote orders as dg21842_imported, then validate 201, then still 2 rows 201, then 4 rows; dg21842_imported ok
re-save dg21842_saved without ghost_col, then validate not run dg21842_saved ok, no diffs
restart on the same database, then validate 4 rows the same 4 rows, the same verdicts

The injected anchors (organization_id, the audit pair, owner_id, owning_business_unit_id) are not reported on the runtime-saved object: the registry's copy carries them, and the #21837 skip handles them.

H2 (does getObject already see the save?): falsified. With only listObjects moved (an intermediate build), the saved and imported rows answered unreachable with Object 'dg21842_saved' not found. and Object 'dg21842_imported' not found.. The metadata service's getObject reads the same boot copy, so both reads moved.

H3 (the boot gate), on objectstack dev: holds. Base and branch give the same boot gate output. On a fresh boot both log "all federated objects match their remote schema" with objects: 2. Booted on copies of one database that holds the stored objects, both log the same single drift warning (dg21842_saved, missing_column ghost_col). The bridge logs 109 of 109 registry objects copied, so at boot the copy and the registry hold the same objects.

Decision needed: objectstack start

Measured on objectstack start (production mode), showcase:

  • Base: the boot gate logs objects: 0, and validate answers { ok: true, results: [] }. The code-defined federated objects are not listed at all. The plugin reads the metadata service at init(), and on start that service is the kernel's in-memory fallback, registered just before the start phase and after this plugin's init() (the log shows Service 'external-datasource' registered, then Service 'metadata' registered, then Phase 2: Start plugins). So the plugin holds no metadata service for its whole life.
  • This branch: objects are listed now, because the registry is read when validation runs. The datasource definition is still read from the service captured at init(), which is absent, so validateObject takes its "not federated" branch. Every row answers ok: true with no diffs, and nothing is compared. The boot gate logs objects: 2. A saved object with ghost_col answered ok: true.

The boot gate's pass or abort does not move on either composition, but on start this branch turns "no rows" into rows that claim ok without a comparison. That is close to this dispatch's stop line, so the landing is the seat's call:

  • A. Land as is, and the init-time metadata capture becomes its own card. Cost: until that card lands, start answers per-object ok: true that it never compared.
  • B. Widen this PR: read the metadata service (datasource, namespace, catalog write) when it is used. Cost: on start the boot gate starts judging, so a deployment with drift under the default onMismatch: 'fail' refuses to boot where it started before. That is a boot-behaviour change outside this card.
  • C. The init-time capture becomes its own card and lands first; this PR then lands unchanged. Cost: this PR waits.

Recommendation: C. Each landing stays honest on every composition. The boot-behaviour change gets its own changeset and its own decision, and this PR's diff and changeset stay as they are.

Tests

  • New packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts (4 cases, relative import, measures src/). The fakes model the measured mechanism: metadata holds the boot copy, the objectql registry is live, and a save writes only the registry. A runtime-saved object is listed and judged by validateDatasource and by validateAll, and the code-defined one is still listed. A re-save is judged on what was saved. The registry is resolved when validation runs. The boot copy's object reads are never called.
  • New packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts (3 cases, booted showcase): validate lists the code-defined objects, then also an object saved through PUT /meta/object/:name, then also an imported one. Under this harness the federation service finds no metadata service at init() (the same cause as start), so this file pins the listing only. The verdict half is the unit file's, and was measured on objectstack dev above.
  • At b44c1c87bb (after merging origin/main): @objectstack/service-datasource 38 files, 721 tests pass. tsc --noEmit passes, and --listFiles includes the new test file. Dogfood: the new file and external-import-saves-like-meta.dogfood.test.ts, 2 files, 6 tests pass.
  • Ablation, with the fix committed (94e3056d62), through scripts/ablation-replace.mjs: both readers were put back to the boot-copy reads (anchor hit 1, blob 5fd02852 to cad2f3a6). Unit: 4 of 4 failed, for example expected [ 'code_cust' ] to deeply equal [ 'code_cust', 'saved_cust' ]. Dogfood: 3 of 3 failed, expected [] to deeply equal [ 'showcase_ext_customer', … ]. Under the harness all three read [], because there is no metadata service at init() there; the unit file is what separates "boot copy" from "live registry". Restore: blob equals HEAD (5fd02852), and git diff HEAD is empty. No dist/ is on either path: the unit file imports src/ relatively, and the dogfood config aliases @objectstack/service-datasource to src/.

Gates

At c68487ae6c (this head; it differs from b44c1c87bb by the changeset text only): node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 67 commands. All 67 were run and all exit 0, and --ran reconciles 67 run, 0 NOT-MEASURED, 0 UNRUN. That is the dispatch's 59 plus 8 the changeset brings (check-adr-0087-registration and check-empty-changeset, each with --self-test, release-rehearsal-clone --self-test, release-pending-publish --self-test, check:objectui-changeset, check:pm-changeset-deadline-census). check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 unrelated packages had no dist/). Those packages were built, and it was re-run to exit 0.

Narrowed eslint (--no-inline-config, --format json) on the 3 touched .ts files: 3 files, 0 errors, 0 warnings. The config enables no type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any untouched file.

Acceptance notes

Seat's append: patch round 1 (written by domain:services seat 1 from the dev's report 5999337391; the dev does not edit this body)

Patch round 1 (head 9489265ae0): main merged after #21887, and objectstack start measured again

The merge. origin/main 607463d736 was merged as 80dfcc30b7. It carries #21887 (merge bc7747cb) and #21874. There was one conflict, in plugin.ts's object readers. Resolution: getObject and listObjects read the engine registry (objectRegistry()). getDatasource, getNamespace and the persistCatalog getter keep #21887's resolver, metadata(). MetadataServiceLike keeps only get and register. One sentence of the resolver's docblock said object reads went through it. It now says objects come from the registry.

#21887's unit file moved with it (9489265ae0). At the merge commit, its 4 object-listing cases went red (expected [] to deeply equal [ 'wh_customer', 'wh_order' ]), because its harness served objects only from the metadata fake. The harness now also serves them from an objectql registry fake. The re-ask case tells the two services apart by the datasource definition: a managed replacement compares nothing. The no-metadata case also runs with no registry. Every other case is unchanged.

The door pin now asserts the verdict. Under the harness the metadata service is read when it is used (#21887), so the runtime-saved object's row is a real comparison. It answers ok: false with one diff, missing_column loyalty_tier. The imported row answers ok with no diffs.

objectstack start (showcase): this branch against origin/main 607463d736 as the control, with the same steps.

step origin/main this branch
fresh boot, boot gate all federated objects match their remote schema {"objects":2} same
PUT /meta/object/dg21842_saved (declares loyalty_tier, which the remote customers table lacks), then validate 2 rows; the saved object is not listed 3 rows; dg21842_saved is ok: false with missing_column loyalty_tier
import orders as dg21842_imported, then validate 2 rows 4 rows; dg21842_imported is ok: true with no diffs
restart on the same home, boot gate one external schema drift warn: dg21842_saved, missing_column loyalty_tier same

The question in this PR's Decision needed section is closed: on start, validate judges a runtime-saved object, and no row answers ok without a comparison. The seat decided option C (5995103062), and #21876 landed first as PR #21887. The boot gate output matches #21887's on both the fresh boot and the restart.

Measured at 9489265ae0:


Generated by Claude Code

claude added 3 commits October 5, 2026 11:08
…stry

The federation service's listObjects and getObject read the engine's
object registry (objectql service), resolved when validation runs,
instead of the metadata service's boot-time copy. An object saved at
runtime through PUT /meta/object or the import is listed and judged
with no restart.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-datasource, touching 11 documentable anchor(s).

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

  • content/docs/api/wire-format.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/kernel/contracts/metadata-service.mdx (via getObject (symbol, a field of interface MetadataServiceLike), listObjects (symbol, a field of interface MetadataServiceLike), getObject (literal, a string literal in ObjectRegistryReads))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin), /object/:name/state/:field (route, bridged from symbol getObject — its route source's handler names it))
  • content/docs/ui/forms.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via getLegalNextStates (sdk, the bare tail of client method meta.getLegalNextStates, bound to GET /api/v1/meta/object/:name/state/:field; the bare tail of client method meta.getLegalNextStates, bound to GET /meta/object/:name/state/:field), meta.getLegalNextStates (sdk, the route ledger binds it to GET /api/v1/meta/object/:name/state/:field, selected by route anchor /object/:name/state/:field; the route ledger binds it to GET /meta/object/:name/state/:field, selected by route anchor /object/:name/state/:field))
  • content/docs/releases/v17/17-1.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/releases/v17/17-2.mdx (via getLegalNextStates (sdk, the bare tail of client method meta.getLegalNextStates, bound to GET /api/v1/meta/object/:name/state/:field; the bare tail of client method meta.getLegalNextStates, bound to GET /meta/object/:name/state/:field), meta.getLegalNextStates (sdk, the route ledger binds it to GET /api/v1/meta/object/:name/state/:field, selected by route anchor /object/:name/state/:field; the route ledger binds it to GET /meta/object/:name/state/:field, selected by route anchor /object/:name/state/:field))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 607463d736046d8d692d64cf16ea6804d1609f38 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 092b71994006cd25b514f121c8ec7047f9a18690 — the merge of head 9489265ae06f63ef13ac63fb2dd177f6a2b727eb into base 607463d736046d8d692d64cf16ea6804d1609f38, 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 092b71994006cd25b514f121c8ec7047f9a18690 && git checkout 092b71994006cd25b514f121c8ec7047f9a18690
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 607463d736046d8d692d64cf16ea6804d1609f38 9489265ae06f63ef13ac63fb2dd177f6a2b727eb && git checkout -B drift-repro 607463d736046d8d692d64cf16ea6804d1609f38 && git merge --no-ff 9489265ae06f63ef13ac63fb2dd177f6a2b727eb

node scripts/docs-audit/affected-docs.mjs --json 607463d736046d8d692d64cf16ea6804d1609f38

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

claude added 2 commits October 5, 2026 16:28
…lidate-reads-live-registry

# Conflicts:
#	packages/services/service-datasource/src/plugin.ts
…ngine registry; door pin asserts the verdict

After merging main, the federation service reads objects from the engine
registry and datasource definitions from the metadata service resolved at
use. The start-ordering unit file's harness now serves its objects from an
objectql registry fake, its re-ask case is told apart by the datasource
definition, and its no-metadata case has no registry either. The door pin
now asserts the runtime-saved object's real verdict.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 17:18
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 17:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 25eb7de Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21842-validate-reads-live-registry branch October 5, 2026 17:59
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…vice when it is used, so validate and the boot gate compare every federated object (objectstack-ai#21887)

Fixes objectstack-ai#21876
Clause-②: no (narrowing)

## What this changes

`ExternalDatasourceServicePlugin.init()` read the `metadata` service
once and kept the answer. `objectstack start` composes no metadata
plugin. Its `metadata` service is the kernel's in-memory fallback, which
the kernel pre-injects after every plugin's `init()`, just before the
start phase. The `start` log shows the order: `Service
'external-datasource' registered`, then `Service 'metadata' registered`,
then `Phase 2: Start plugins`. So on `start`, every reader of the kept
value saw no service for the life of the process.

The plugin now looks up the `metadata` service when each reader runs. It
uses a resolver function called at each use, the same pattern
`metadataSaveDoor` already follows in this `init()` (AGENTS.md, "Startup
registry reads", cure 1). That covers `getDatasource`, `getObject`,
`listObjects`, `getNamespace` and the catalog write. The catalog write
was a conditional spread, so `init()` decided whether the
`persistCatalog` slot existed at all (H2). It is now a getter, as
`persistObject` already is. With no metadata service at all, each reader
returns the same fallback it always did.

Not changed: what validation judges, what each `onMismatch` value does,
and the boot gate's code (`packages/runtime`). The import's save call
(`persistObject`, landed with objectstack-ai#21874) is not changed either. Objects are
still read from the metadata service's copy. objectstack-ai#21842's PR objectstack-ai#21875 moves
them to the engine registry.

**The object reads, called out.** `getObject` and `listObjects` read the
same kept value, so replacing it with a resolver also changes how those
two readers are spelled: each calls the resolver instead of using the
constant. They read the same members with the same fallback as before.
Only the moment the service is looked up moves. Without this, the card's
"Done when" cannot hold: on `start`, validate would still list no
objects. These are the lines PR objectstack-ai#21875 replaces. When objectstack-ai#21875 merges
`main`, its `getObject` and `listObjects` should replace these, and it
should keep this PR's `getDatasource` line.

Files: `packages/services/service-datasource/src/plugin.ts`, one new
unit file beside it, one new dogfood file, and a `minor` changeset with
the `!` banner. No `packages/spec` edit, no new export, and no edit to
an existing `packages/qa` file.

## Measured on the real composition (showcase, `objectstack start` and
`objectstack dev`)

Remote fixture for the probe: `customers.lifetime_value` is TEXT on the
remote, while `showcase_ext_customer` declares a currency field. That is
a `type_mismatch` at severity `error`. A missing column would not
survive the boot, because the showcase's own `onEnable` adds missing
columns. A column type it leaves alone. The base build of
`service-datasource` is `plugin.ts` at `2e780467`. The branch build is
`plugin.ts` at `10dd86129b`, which is this head's `plugin.ts` minus
objectstack-ai#21874's one-line `writeFace` change from the merge.

| `objectstack start` | base | branch |
| --- | --- | --- |
| boot gate, drift, showcase's `onMismatch: 'warn'` | `all federated
objects match their remote schema {"objects":0}` | `external schema
drift` (warn) on `showcase_ext_customer`: `type_mismatch`
`lifetime_value`, expected `currency`, actual `text` |
| boot gate, no drift | (`objects: 0` whatever the remote holds) | `all
federated objects match their remote schema {"objects":2}` |
| boot gate, drift, datasource set to `onMismatch: 'fail'` for the probe
(restored after; blob equals HEAD) | boots, `objects: 0` | **refuses to
boot**: "Object 'showcase_ext_customer' does not match its remote table
on datasource 'showcase_external': type_mismatch:
customers.lifetime_value (expected currency, actual text)" |
| `POST /datasources/showcase_external/external/validate` | `{ ok: true,
results: [] }` | 2 rows: `showcase_ext_customer` `ok: false` with the
`type_mismatch`, `showcase_ext_order` `ok: true` |
| `POST …/external/refresh-catalog`, then `GET
/meta/external_catalog/showcase_external_catalog` | snapshot answered;
the read answers `RESOURCE_NOT_FOUND` (never stored) | snapshot
answered; the read returns the stored record |
| `GET …/external/tables` | 2 tables | same (the showcase sets no
`allowedSchemas`) |
| `POST …/tables/customers/draft` | `customers`, with the
`TODO(namespace)` note | same (see the namespace note below) |
| import `orders` as `probe_ext_orders_21876`, then as
`showcase_probe_orders_21876` | 201, 201 | 201, 201 (same note) |
| validate after both imports | `results: []` | the 2 code-defined rows
only; the imported objects are not listed until restart (H5, objectstack-ai#21842's) |

**`objectstack dev`, the control (H4):** base and branch gave
byte-identical answers from validate, validate-after-import, tables, the
catalog read and both imports, after stripping `snapshotAt`. Both boot
gates log the same drift warning. `dev` answers exactly what the branch
now answers on `start`.

**The namespace note.** The showcase's code-defined datasource carries
no `_packageId` on either composition, `dev` included, so its namespace
never resolves there. The namespace half (the draft's prefix, the
import's name check) is pinned in the unit file, with a datasource that
carries package provenance. On `start` it was blind on every deployment,
and it now answers as on `dev`.

**Introspection (`data` service).** It is still read at `init()`. On
`start` it is already registered by then: base `start` answered tables,
draft and refresh. See Acceptance notes.

## Tests

- New
`packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts`:
10 cases, relative import, so it measures `src/`. The `metadata` service
is registered AFTER `init()`, as on `start`. Under that ordering:
validate compares each federated object and reports the drifted column,
and the boot gate's sweep (`validateAll`) lists every federated object.
The draft takes the namespace of the datasource's package. An import's
explicit name that breaks the prefix is refused, asserting `code:
'EXTERNAL_IMPORT_ERROR'` and `status: 400`, and the save door is never
called. The refreshed catalog is persisted. `allowedSchemas` is
honoured. The service is looked up again at each use, never remembered.
Control: a service registered BEFORE `init()` (the `dev` ordering) gives
the same answers. With no metadata service, every reader keeps its
fallback.
- New
`packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts`:
3 cases, booted showcase. The remote's `customers.email` is renamed away
after provisioning; the harness does not run `onEnable` at boot, so the
drift survives. A precondition asserts that the harness's `metadata`
service is the kernel's in-memory fallback, the `start` shape. Validate
compares both federated objects and reports `missing_column email`.
`validateAll()` lists both.
- **Red at base** (`ce28b73809`: the two test files on the base
`plugin.ts`): unit 7 failed, 3 passed, for example `expected [] to
deeply equal [ 'wh_customer', 'wh_order' ]` and `expected "vi.fn()" to
be called 1 times, but got 0 times`. Dogfood 2 failed, 1 passed (the
precondition), `expected [] to deeply equal [ 'showcase_ext_customer', …
]`.
- **Ablation** at `10dd86129b` (fix committed first), through
`scripts/ablation-replace.mjs` in WRAP mode. The resolver line was
replaced by a capture taken at `init()` and a resolver returning that
capture (anchor hits 1, blob `3f9f1968` to `2dbc3ade`, marker
`ABLATION-21876` count 1 on disk, anchor count 0). Unit: 7 of 10 failed,
the same 7 as at base. Dogfood: 2 of 3 failed. Restore: blob after
restore `3f9f1968` equals HEAD, `git diff HEAD` is empty and `git
status` is clean. No `dist/` is on either path: the unit file imports
`src/` relatively, and the dogfood config aliases
`@objectstack/service-datasource` to `src/`.
- **At this head `c6f237478c`** (`origin/main` `e864db56df` merged,
which carries objectstack-ai#21874): closure build (`@objectstack/dogfood`
dependencies plus `service-datasource`) 63 of 63 tasks.
`@objectstack/service-datasource`: 38 files, 728 tests passed. `tsc
--noEmit` passes, and `--listFiles` includes the new unit file (count
1). Dogfood: the new file plus `external-import-saves-like-meta` and
`external-import-destructive-remedy` (the other two files that mount
this plugin), 3 files, 10 tests passed. Dogfood `tsc --noEmit` passes,
with the new file in `--listFiles` (count 1).

**The gap, named.** The dogfood file boots the verify harness, not the
`objectstack start` CLI. The harness composes no metadata plugin, so its
`metadata` service is the same kernel fallback, injected at the same
moment as on `start`. The precondition case holds that. The harness does
not mount the boot gate. Mounting it from this file would need
`@objectstack/runtime` as a value import, which the dogfood package does
not alias. That would mean a new pair in `check:test-source-alias`'s
shrink-only ledger or an edit to the dogfood vitest config, and both are
outside this dispatch. So the file pins the gate's input
(`validateAll()`), and the gate's own verdict on `start` is the probe
table above.

## Gates

At `c6f237478c`: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 67 commands: the dispatch's 59 plus
8 that the changeset brings. All 67 were run and all exit 0. `--ran`
reconciles 67 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads`
first answered PREREQUISITE NOT MET (exit 3), because 8 unrelated
packages had no `dist/`. `check:type-check-debt`'s re-measure later in
the same battery built them, and the gate was re-run on the same head to
exit 0. Also run: `pnpm check:startup-registry-verdict` exit 0 ("43
startup/open-registry seam(s) … none recording a verdict the boot can
contradict").

Narrowed eslint (`--no-inline-config`, `--format json`) on the 3 touched
`.ts` files: 3 files, 0 errors, 0 warnings. The changeset `.md` is
outside eslint's population: eslint answers "File ignored because no
matching configuration was supplied". `eslint.config.mjs` enables no
type-aware linting (no `parserOptions.project`), so this diff cannot
move the verdict on any untouched file.

## Docs

No `content/docs` sentence is made false.
`data-modeling/external-datasources.mdx` promises a federated datasource
is "validated at boot" and that a mismatch "fails boot". That was false
on `start` and is now true.

## Acceptance notes

- **`engine` (the `data` service) is still read at `init()`.** It is not
part of this card. On both `start` and `dev`, `ObjectQLPlugin` registers
it in its own `init()`, which runs before this plugin. Base `start`
introspected (tables, draft, refresh answered), so nothing was measured
wrong. Carrier: none.
- **The showcase's code-defined datasources carry no package
provenance**, so the federation draft and import never resolve a
namespace for them, on `dev` as on `start`. It is reported to the seat
with its evidence, not filed from here.
- **A federated object saved at runtime** is still listed by the
validate door only after the next restart. That is objectstack-ai#21842's, fixed by PR
objectstack-ai#21875 after this lands.

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

---------

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 21) (objectstack-ai#21907)

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

Stage 21 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 name-ordered `ui/` group: the test files
directly under `packages/spec/src/ui/` from
`component-record-blocks.test.ts` to `dashboard.test.ts`. Those files
carried 103 messages and 109 tracker-shaped ids, citing 58 records. 105
of those ids now either state what their record decided, in words (form
D), or are dropped where the title already says it. Four stay: they are
CSS colour literals in two widget fixtures, not citations (below). Text
only: no assertion, identifier, test count or code comment changes, and
no file is renamed.

## Census at the base (`1e18a0735c`)

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 20 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 base is `1e18a0735c`, stage 20's landing and the claim's base. Both
instruments read **764 messages / 807 ids in 159 files**, the seat's
reading and stage 20's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `ui/` (this PR: 7 of the 53 files) | 53 | 300 / 318 | 284 / 302 | 16 /
16 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **159** | **764 / 807** | **710 / 752** | **54 / 55** |

The group reads **103 messages / 109 ids in 7 files**, the seat's
figures file for file:

| file (under `ui/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `component-record-blocks.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `component-reference-rail.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `component-type-vocabulary.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `component.test.ts` | 65 / 70 | 65 / 70 | 0 |
| `dashboard-chart-structure-refusal.test.ts` | 2 / 2 | 0 | 2 / 2 |
| `dashboard-compareto.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `dashboard.test.ts` | 20 / 21 | 17 / 18 | 3 / 3 |
| **7 files** | **103 / 109** | **98 / 104** | **5 / 5** |

`component-report-items-action-members-typed.pin.test.ts` sits in the
same name range and carries no id. The five "other" strings are the four
colour literals and `dashboard.test.ts:205`.

- **Controls.** Lit: `ui/view.test.ts`, outside the group, reads 43 ids
at the base and at the head. Dark: `component.test.ts` reads 0 at the
head while 200 of its comment lines still carry a number. Planted in
scratch copies of head files: an id put into a
`component-record-blocks.test.ts` title reads 1 / 1, and an id put into
a `dashboard-compareto.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in 6 of the 7 files at the base. `component.test.ts` reads one
more: `decision batch objectstack-ai#77`, a two-digit batch number the gate pattern
does not count. It leaves with the id beside it, as stages 9, 13 and 15
did with theirs.
- **At the head:** 665 messages / 702 ids in 154 files. The 7 files read
4 / 4 (the four colour literals), `ui/` reads 201 / 213, and no other
file moved.

## How the area was chosen

`ui/` has no subdirectory test file with an id, so it is taken in
name-ordered file groups near the ~100-id bound. Stage 20's re-cut named
this group at 109 ids, and this census reads 109, so no re-cut was
needed.

**Named for the next stages** (cut from the head census, 665 / 702):
- `ui/` 213 ids. One is stage 20's kept
`component-props-unknown-members.pin.test.ts:322`, four are this group's
colour literals, and 208 sit in the 45 files after `dashboard.test.ts`.
The next group nearest 100 runs from
`dataset-filter-nested-relation-list.test.ts` to
`view-inline-object-binding.test.ts`: 29 files, 96 messages / 102 ids, 7
of them "other". Cutting two files earlier gives 98. The last `ui/`
group is then the 16 files from `view-item-config-type.test.ts` to
`widget.test.ts`: 100 messages / 106 ids, `view.test.ts` alone 43.
- `api/` 201, two stages. `system/` 165, two. The files directly in
`src/`, 120, one.
- The three docblock needles plus the kept `:322`, one stage, with an
at-tier review.

## The five "other" strings: four kept, one rewritten

- **`'objectstack-ai#111'` / `'objectstack-ai#222'` at
`dashboard-chart-structure-refusal.test.ts:94` and
`dashboard.test.ts:124` are kept.** They are three-digit CSS hex
colours, not placeholders for a record: `colors: ['objectstack-ai#111', 'objectstack-ai#222']` in a
widget's `chartConfig`, and `palette: ['objectstack-ai#111', 'objectstack-ai#222']` inside the
free-form `options` bag. They cite nothing. But they are not input and
expected value at once, the case stage 12 dropped. Each is input that
the schema under test reads (`ChartConfigSchema.colors` is a
string-array or string-map union), and the assertions read other things:
the first parse must succeed, the second asserts `options.stacked`. So
by the claim's rule they stay, and are reported. They match the gate
pattern only because a short hex colour can be all digits. They are the
only four such literals in the whole census.
- **`dashboard.test.ts:205`** is the label argument of the file's
`orderPin` helper, which passes it to `it()`. So it is a test title one
call down, and no assertion reads it. It is rewritten and declared to
the text-only tool.

No string in the group is a needle (an id that is the expected value of
an assertion over a docblock or another file's text). The three known
needles are in `ai/` and `contracts/`.

## What each id became

- **21 literals (21 ids)** now state a decision in words.
- **15 literals (15 ids)** get their subject back in words, where the
number stood for a thing, such as "the objectstack-ai#11661 keys".
- **63 literals (69 ids)** drop a number the title already explains.

Every cited record was read with its comments through REST: 54 answer
200 and 4 answer 404. Four citations are cross-repo: `ui#6206` (also
spelled `ui#6206-B`), `ui#6207` and `objectui#8221` were read from
objectui and answer 200. `framework#2501` was read under the
repository's current name, as stage 18 read `framework#2536`. objectstack-ai#5042 is a
pull request, `objectstack-ai#4001` batch 14. The four 404s were read from what
landed, through the commits the stage-5 comment sweep (objectstack-ai#20576)
re-anchored them to:
- **objectstack-ai#6276:** `78f0be872`, which declares the record picker's flat `sort`
/ `limit` (maintainer ruling 2026-08-08, direction A).
- **objectstack-ai#9972:** `60e0f900a`, which records the live read point of
`page:tabs` `items[].icon` and its accept pin.
- **objectstack-ai#11507:** `88b9d749a`, which declares `sys_activity.type` an open,
author-extensible vocabulary (maintainer ruling 2026-08-24, direction
4).
- **objectstack-ai#11658:** `1a6a19c31`, which opens `RecordActivityProps.types` to
author-contributed kinds, executing that ruling.

Each of those titles already carried its decision, so the number is
dropped. `component.test.ts:3037` also gets its subject back: "the
ruling" becomes "the open-vocabulary ruling".

**Stated in words:**

| record | literal (under `ui/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#8744 | `component-record-blocks.test.ts:355` | "the
`record:discussion` / `record:chatter` pair — one shared row" |
`record:discussion` is wired to the chatter row on purpose: one
renderer, one accept face. |
| objectstack-ai#7702 | `component.test.ts:63` | "accepts a header without title — the
synthesized shape, headed from the record" | Ruling A/B: `title` becomes
optional, and an omitted title means the renderer derives the heading
from the record. |
| objectstack-ai#6776 | `component.test.ts:89`, `:262` | "PageHeaderProps recordChrome
/ showStar / showCopyId — declared because the header renderer reads
them"; "PageAccordionProps variant — declared because the accordion
renderer reads it" | Route A: declare the five author keys objectui's
renderer reads. |
| objectstack-ai#6776 | `component.test.ts:235` | "PageTabsProps tabStyle — renamed
from `type`, which collides with the component `type`" | Route A: rename
`type` to `tabStyle`, because in a flat node `type` is the component's
own dispatch key. |
| objectstack-ai#6946 | `component.test.ts:122` | "PageHeaderProps icon is retired —
no renderer reads it" | Maintainer ruling: retire three keys with zero
renderer read points, this one among them. |
| objectstack-ai#5775 | `component.test.ts:406`, `:2932` | "… items[].value /
items[].count — declared because the tabs renderer reads them";
"RecordPathProps stages[].terminal — declared because the path renderer
reads it" | Ruling A: retire the dead keys and declare the keys the
renderers honour. |
| objectstack-ai#5775 | `component.test.ts:649` | "PageContainerProps — page:section /
page:footer / page:sidebar compose through `children`" | Same ruling:
the three containers declare `children` as their one composition key,
and `body` is not declared. |
| objectstack-ai#11289 | `component.test.ts:768` | "preserves the section presentation
keys the renderer honours, verbatim" | Direction 1: declare `hideEmpty`
/ `collapsible` / `showBorder`; the renderer is unchanged. |
| objectstack-ai#11661 | `component.test.ts:889` | "still refuses `title`,
deliberately withheld as a second spelling of `label`" | Three more
renderer-honoured keys are declared; `title` stays out because `label`
already names the heading slot. |
| objectstack-ai#9220, objectstack-ai#9249 | `component.test.ts:2078`, `:2142`, `:2899`, `:2908` |
"Interactive Elements — element:filter (retired, no renderer)", the same
for `element:form`, and "… through the kept map row (retired, no
renderer)" twice | Both elements are retired at element grain: no
renderer or reader in any repository. |
| objectstack-ai#4001 | `component.test.ts:3325` | "batch A, unknown keys refused —
the prescriptions, each backed by a measured producer" | Every
authorable surface refuses unknown keys; spelled as stage 15 spelled
`objectstack-ai#4001 batch D`. |
| objectstack-ai#7751 | `component.test.ts:3419` | "object-* block props schemas —
declared, so the props gate has a schema to dispatch" | Ruling A: the
`object-*` block family enters `ComponentPropsMap`. |
| `ui#6207` | `component.test.ts:3468` | "object-grid `data` takes the
ViewDataSchema provider object — the two spec authorities converged" |
Option A: `object-grid.data` converges on `ViewDataSchema`, and the bare
array is refused. |
| objectstack-ai#19228 | `component.test.ts:4499` | "row caps on the object-bound
blocks — a bound view fills `limit` only when the authored one is not a
usable cap" | Ruling D: one row bound per view; the component face keeps
an undefaulted `limit`, which a bound view's page size fills whenever
the authored one is not a usable cap (the gate's
`!isUsableRowLimit(authored)`). |
| objectstack-ai#4614 | `dashboard.test.ts:419` | "date-range preset vocabulary — one
source, in the spec" | Ruling A: the preset names move into the spec as
their one source, and a date filter's default is checked against them. |
| objectstack-ai#16458 | `dashboard.test.ts:850` | "control — `columns` still declares
no default … (the renderer infers it from widget spans)" | Item 4 was
not landed: a `.default(12)` would retire the renderer's span inference
and switch every auto-flow dashboard to the positioned grid. |

**Subject back in words** (15 literals): "the objectstack-ai#5068 gate" becomes "the
props gate" (`component-reference-rail.test.ts:34`); "the three objectstack-ai#18305
blocks" / "object blocks" become "object-map / object-gantt /
object-tree" (`component-type-vocabulary.test.ts:88`,
`component.test.ts:4295`); the four "objectstack-ai#11661 keys" titles name
`defaultCollapsed` / `icon` / `description` (`component.test.ts:832`,
`:848`, `:860`, `:875`), because id-free sibling titles already say "the
section presentation keys"; "objectstack-ai#18639 scope fences" becomes "scope fences
of the `columns` widening"; "the twin of the objectstack-ai#14406 census pin" becomes
"the twin of the census pin that no door refuses the rule array"; "the
objectstack-ai#7750 specimen shape" becomes "the my-work specimen shape"; the four
`objectstack-ai#5011 —` prefixes in `dashboard-compareto.test.ts` become `compareTo —`
where the title needs a subject, and go where it already has one
(`:61`); "the words objectstack-ai#5042 measured" becomes "every word authors were
measured spelling … reaches …".

**Dropped where already stated** (63 literals, 69 ids). A number goes
only where the title already says its decision. Examples:
"ComponentPropsMap[\"record:alert\"] (objectstack-ai#8744)"; "user:profile is not
author-placeable (objectstack-ai#14159, ruling B)"; "ai:chat_window is retired,
refused by name (objectstack-ai#21504)"; the six "(objectstack-ai#6276)" picker titles, each already
naming what the declaration does; "the four `object-*` `sort` doors —
one sort orthography, the array (objectui#8221, decision batch objectstack-ai#77,
option B; objectstack-ai#18305)"; the `[objectstack-ai#4876]`, `[objectstack-ai#5010]`, `[objectstack-ai#17779]`, `[objectstack-ai#20958]` and
`[objectstack-ai#21293]` prefixes on `dashboard.test.ts`, each in front of the rule it
names; "drill branch (objectstack-ai#5022): …", whose sibling labels already read "…
branch: …". `批 17` stays as a batch label in stage 18's form, and
`ADR-0021` / `ADR-0049` style citations are untouched.

**No file is renamed.** None of the 7 file names carries a number.

## Readers

- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` (the hits are `mapfile -t`,
`docker build -t`, `type -t`, `lsof -t`, and a preflight's option
vocabulary).
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 7 files calls a snapshot matcher.
- **Projects:** all 7 files run in the `local` project;
`packages/spec/vitest.repo-tests.json` lists none of them.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (301 needles) was searched with `git grep` at the
base, across the tracked tree outside its own file. No gate, doc,
filter, snapshot or `scripts/check-*.mjs` self-test reads one. The 6
hits:
- a code comment in `lint/src/validate-component-props.test.ts:540`
("the objectstack-ai#7750 specimen shape");
- a QA checklist `source` entry,
`docs/qa/platform-checklist/areas/dashboards.json:406`, which anchors on
`dashboard-compareto.test.ts#dashboard` and describes it in its own
words ("objectstack-ai#5011 — compareTo converged on …"). The checklist gate looks up
the `dashboard` symbol, which this PR leaves in the file, and reads none
of the titles. `pnpm check:platform-checklist` exits 0 at the head;
- a sibling title in `service-analytics`
(`dataset-compare-dimension-resolution.test.ts:74`, "objectstack-ai#5011 — …");
- three hits on one same-id title in this card's later `ui/` stage,
`ui/view.test.ts:4236`, the visibility twin of the message-order
describe, citing objectstack-ai#6416 / objectstack-ai#6619.

## 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 one line,
`dashboard.test.ts:205`.

- **Result:** 7 of 7 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 99 changed string leaves in 99 literals: 98 titles and 1
declared. The diff's `+` and `-` lines are exactly the 99 planned lines
as multisets, and every file keeps its line count.
`dashboard-chart-structure-refusal.test.ts` is untouched.
- **Controls (13 of 13 as predicted, 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 `orderPin` label changed VIOLATION; a title
re-split into a `+` chain DIFF; the declared label reverted to base
SAME; the declared label given a new id VIOLATION; a kept colour literal
edited VIOLATION. The first run predicted VIOLATION for the
declared-label revert: putting `(objectstack-ai#5022)` back where it was reproduces
the base text exactly, so SAME is the right answer, and a control that
gives the label a new id was added. That run read 11 of 12.
- **Templates and tables:** no `.each` title, `%s` / `$name` placeholder
or table row changes.

**Test counts:** the 7 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 677 tests in 7 files, all passed, with the same count and
status sequence per file in 7 of 7. 469 full test names change, and each
changed name equals the base name with the planned replacements applied
(0 mismatches). One full name repeats 5 times on both sides: an
`it.each` row in `dashboard.test.ts` whose printed name is cut at the
same point for 5 rows. Only its describe prefix changed.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
6 touched files are in it, and no `*.test.ts` at all. The controls
`src/ui/component.zod.ts` and `dist/index.mjs` are in it.
- In the built `dist/`, a new phrase and an old literal each read in 0
files. The control `Unrecognized key` reads in 42.

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

## Verification (at `ec96327761`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (`VERDICT command-exit 0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 618 files, 18450 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 7 group
files, counted 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
set as stage 20, and all 79 exit 0. `--ran` reconciles: 79 derived, 79
run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded.
The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`. So does `check:platform-checklist`, for
the checklist entry above.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 7 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 7 configured, 0 ignored. No file sets `parserOptions.project` or
`projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 198 changed lines (+99 /
-99).
- A control-byte scan over the 6 changed files finds none.
- **Review round 1, at `69ea423302`:** two titles reworded, one line
each: the row-cap describe (`component.test.ts:4499`) now states the
gate's guard, and the offset-alias title
(`dashboard-compareto.test.ts:160`) reads straight. Text-only proof
against the base: 7 of 7 SAME, 99 changed leaves (98 title, 1 declared).
The 7 files at base and head: 677 / 677 passed, count and status
sequence identical in 7 of 7, 469 changed full names, 0 mismatches
against the plan. ESLint over the 7 files: 0 errors, 0 warnings. The
group census still reads 4 / 4. `typecheck` exit 0, and
`check:nul-bytes` OK. The derived gate set is the same 79 families; they
were not re-run for a two-literal change.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was four commits
past the base (`cab6396715`: objectstack-ai#21875, objectstack-ai#21896, objectstack-ai#21893, objectstack-ai#21900). None
touches any of the 7 files. objectstack-ai#21893 touches 8 `packages/spec` files under
`automation/` and `migrations/`, and the census over `packages/spec/src`
at `866683f96f` (after objectstack-ai#21893) still reads 764 / 807 with no file moved,
so the merged tree reads this PR's 665 / 702. objectstack-ai#21900 touches two more
`packages/spec` files, both non-test migration entries, which this
census does not count. So `main` was not merged. `git merge-tree` onto
`cab6396715` is clean.

## Acceptance notes

- **The four colour literals** stay, as above. They are the only
digit-only hex colours in the census, so the card's end state will read
them unless a later stage changes the fixture values, which would be a
fixture change rather than a text change.
- **Same-id test titles in this card's later stages** go with those
stages: 21 lines in `packages/spec/src`, for example
`system/i18n-resolver.test.ts:1982` ("(objectstack-ai#20940)"), `ui/page.test.ts:696`
("(ui#6206-B, objectstack-ai#15442)"), `ui/view-strictness-batch18.test.ts:91` ("objectstack-ai#4001
批 18 — …"), `ui/view.test.ts:4236` ("(objectstack-ai#6416 / objectstack-ai#6619)") and
`ui/view.test.ts:4793` ("(objectstack-ai#19228)").
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec/src` finds 40 lines citing ids this PR handled, in 10
packages: `lint` 25 (8 files), `service-analytics` 3 (1), `cli` 2 (1),
`platform-objects` 2 (1), `plugin-audit` 2 (2), `plugin-security` 2 (2),
and one each in `plugin-sharing`, `rest`, `service-automation` and
`spec/scripts`. Examples:
`lint/src/validate-component-props.test.ts:783` ("… are dispatched
(objectstack-ai#8744)"),
`service-analytics/src/__tests__/dataset-compare-dimension-resolution.test.ts:74`
("objectstack-ai#5011 — …"), `rest/src/meta-types-schema-titles.test.ts:131` ("objectstack-ai#16458
— …"). The two `[objectstack-ai#6206]` hits in `plugin-security` and `plugin-sharing`
cite objectstack#6206, a different record from `ui#6206`.
- **Code comments still carry ids** in these files and their sources,
for example the `objectstack-ai#9198 tombstone` comment in `component.test.ts`, the
`objectstack-ai#19228` header above `component.test.ts:4499`, and the `objectstack-ai#6416` /
`objectstack-ai#5955` docblock above `dashboard.test.ts:163`. Comments are not this
card's share, and none is touched here.

---
_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
…urce is held to its package's ADR-0028 namespace (objectstack-ai#21906)

Fixes objectstack-ai#21889
Clause-②: no

An import over a code-defined datasource is now held to the ADR-0028
namespace of the package that declares the datasource, and the draft
door answers the prefixed name. This follows triage's direction A
(`5999047022`, which amends `5998041661`). There are two halves.

## What changed

- **Writer (`packages/runtime/src/app-plugin.ts`).** `AppPlugin`
registers each code-defined datasource through `applyProtection`
(`@objectstack/spec/shared`), the stamping helper the other load paths
use. The datasource is stamped with the id and version of the package
body that declares it. The owner is read the way the metadata plugin's
artifact door reads it (ADR-0130 D4):
- `packages` absent: every datasource takes the manifest's id, the key
`registerApp` installs the package under. The list read is the same one
as before (D7).
- `packages` present: each body's datasources take that body's id
(`artifactPackageId`, in `resolveArtifactPackageOrder` order). The
artifact's top-level manifest id is never stamped onto every entry (the
objectstack-ai#14599 misattribution class), and no name-to-package index is built over
the flattened list.
- A top-level datasource that no body declares keeps its registration
under the artifact's own id, as the door's residual sweep does, and a
warning names it.
- **Reader (`packages/services/service-datasource/src/plugin.ts`,
`getNamespace` and its docblock only).** The package record is read from
the engine registry (`registry.getPackage` on the `objectql` service),
the store the publish gate reads for the same check
(`publishPackageDrafts`, `metadata-protocol`). It used to be asked of
the `metadata` service, which holds no package records in any
composition. The id comes only from the stamped `_packageId`: no guess,
no fallback store, no second resolution path. The engine is resolved
when it is used, like `metadata()` (the read-at-use posture).

Outside `getNamespace`, `plugin.ts` changes two docblock words, so that
neither docblock names package reads: the `metadata()` docblock ("every
datasource read below") and the `MetadataServiceLike` docblock
("datasource definitions"). Nothing under `packages/spec`, no
metadata-door change, and no new error code.

## The ruling's pins, at the real doors

Measured at `8e5ff7aa` on the showcase's `showcase_external`, under
`objectstack dev` (`pnpm dev -- --fresh -p 38931`) and `objectstack
start` (`--compile -p 38933`, fresh database). Both gave the same
readings:

| door | answer |
|---|---|
| `POST …/external/tables/orders/import` `{"name":"probe_orders_21889"}`
| `400 EXTERNAL_IMPORT_ERROR` "Object 'probe_orders_21889' is missing
the package namespace prefix. Rename it to 'showcase_probe_orders_21889'
(namespace = 'showcase')." `GET /meta/object/probe_orders_21889` answers
`404`. |
| `POST …/external/tables/orders/draft` | `200`, `name:
'showcase_orders'`, no `TODO(namespace)` in the source |
| import `{"name":"showcase_probe_orders_21889"}` (control) | `201`, and
`GET /data/showcase_probe_orders_21889` answers `200` with 4 rows |
| import with no `name` override (the carry) | `201`, saved as
`showcase_customers` |
| `PATCH` / `DELETE` / `PUT /api/v1/datasources/showcase_external`
(control) | `400 DATASOURCE_ADMIN_ERROR` / `400 DATASOURCE_ADMIN_ERROR`
/ `405 METHOD_NOT_ALLOWED`, unchanged |
| `POST …/external/validate` (control) | `200`, `ok: true`, over
`showcase_ext_customer` and `showcase_ext_order` |
| `GET /data/showcase_ext_order` (control) | `200`, 4 rows |

## Clause-② readings

- `GET /api/v1/meta/datasource`, `showcase_external`, after the change
(dev and start alike): `_diagnostics, _packageId, _packageVersion,
_provenance, active, autoConnect, config, driver, external, label, name,
origin, schemaMode`. The values are `com.example.showcase`, `0.1.0` and
`package`. Before the change the item carried none of the three (the
writer ablation below, on the harness). All three are declared on
`DatasourceSchema` (`...MetadataProtectionFields`). On an item with no
`protection` block, `applyProtection` writes exactly those three
(`shared/protection.zod.ts`), so no `_lock*` key and no further key is
added.
- The host `default` item is unchanged: `_diagnostics, config, driver,
label, name, origin`, with no `_packageId`.
- The admin list (`GET /api/v1/datasources`) is unchanged on dev and
start for both items: `active, driver, label, name, origin, schemaMode,
status`.
- No published entry gains an export. `codeDefinedDatasourceOwners` is a
private method, and `artifact-collections.ts` is not touched.

## Tests

- `packages/runtime/src/app-plugin.datasource-provenance.test.ts` (new,
5 cases): the single-package bundle in both datasource spellings, the
two-body ADDITIVE `packages[]` artifact and the option-B artifact (each
datasource carries its own body's id and is registered once), and the
residual top-level datasource (the artifact's own id, plus the warning).
No in-repo example declares a datasource in a multi-package artifact, so
the two-body cases are pinned here.
-
`packages/services/service-datasource/src/__tests__/external-namespace-reads-engine-registry.test.ts`
(new, 11 cases): the namespace pins now sit on the store the reader
reads.
  - The draft is prefixed and carries no TODO.
- An unprefixed import name is refused with `code` + `status` and the
shared validator's own ADR-0028 message, and nothing is saved.
- A prefixed import name is saved, and an import with no override saves
the derived prefixed name.
- The engine is read at each use, and the start and dev orderings give
the same answer.
- Nothing else resolves a namespace: a `package` item in the metadata
service (seeded with a different namespace) is never read, a datasource
with no `_packageId` or with `sys_metadata` resolves none, a package
with no namespace resolves none, and with no engine the documented
fallback holds.
- `external-metadata-read-at-use.test.ts`: the `package` map the
metadata fake seeded (a store no composition fills) is removed. Its two
namespace cases and the dev ordering's draft line moved to the file
above.
-
`packages/qa/dogfood/test/external-import-code-datasource-namespace.dogfood.test.ts`
(new, 7 cases, a real boot of the showcase):
  - Premise: the provenance on `GET /meta/datasource/showcase_external`.
  - The two ruling pins.
- Controls: the prefixed import with its rows, the `default` datasource
with no package, the admin service's refusals and its list (the harness
mounts no admin routes, so the door's status and code are the dev/start
readings above), and validate with the federated read.
- The carry, as triage accepted it:
- `external-import-saves-like-meta.dogfood.test.ts` moves to
`showcase_dogfood_ext_cust_21788`. Its control used to import `orders`
under the remote table's own name, a shape the namespace now refuses. It
now imports with no `name` override and asserts the saved name
`showcase_orders` across a cold boot.
- `external-import-destructive-remedy.dogfood.test.ts` moves to
`showcase_dogfood_ext_cust_21841` and `…_v2`.
- `external-validate-sees-runtime-save.dogfood.test.ts` (landed on
`main` by objectstack-ai#21875 after this PR branched) moves its imported object to
`showcase_dogfood_ext_ord_21842`. Nothing else in it changes: `SAVED`
goes through `PUT /meta/object`, which runs no namespace check, and the
later assertions pass on the renamed object.
- A repo-wide grep for other pins of an unprefixed import, or of
`TODO(namespace)`, on a datasource whose package resolves found none.
The remaining draft tests drive `ExternalDatasourceService` with an
injected `getNamespace`, and the REST tests mock the service.

Runs at `3ec0e9f6`, the current head (it carries merges of `origin/main`
at `d2ed5e04` and `374ca5cb`). Each ran through the shared verify lock,
`VERDICT command-exit 0`:
- `pnpm --filter @objectstack/runtime test`: 328 files passed, 4644
passed, 19 skipped.
- `pnpm --filter @objectstack/service-datasource test`: 40 files, 741
passed.
- `typecheck` for `runtime`, `service-datasource` and `dogfood`: exit 0.
- Dogfood, 6 files (the three import files,
`external-validate-sees-runtime-save`,
`external-validate-start-ordering` and `showcase-external-autoconnect`):
23 passed.

## Ablations (one-time; restored by blob hash and an empty `git diff
HEAD`, then rebuilt)

Every leg went through `scripts/ablation-replace.mjs` (anchor hit and
landed) and `scripts/ablation-dist-preflight.mjs` (the mutation reached
`dist/`, and the restore removed it).

- **W, writer stamp removed** (`applyProtection(…)` replaced by an
unstamped copy, runtime rebuilt):
  - The runtime unit file went red, 5 of 5.
- Dogfood went red, 3 of 7: the premise, the refusal (it answered `201`
and saved `dogfood_ext_order_21889`, the defect itself), and the draft
(`'orders'`). The 4 controls stayed green.
- The rebuild's DTS step exited 1 on TS6133 (the now-unused
`applyProtection` and `owner`). The JS was emitted, and the preflight
read the stamp absent from `dist/index.js`.
- The first attempt was a no-op that the tool refused (the replacement
text already occurred inside the anchor). It is void, and the reading
above is the second attempt.
- **R, reader reverted to asking the metadata service**
(service-datasource rebuilt, build exit 0):
  - The new service-datasource unit file went red, 8 of 11.
- Dogfood went red, 2 of 7: the refusal (`201`) and the draft
(`'orders'`). The premise and the controls stayed green.
- **A, every body stamped with the top-level manifest's coordinates**
(source-level suite, no build): the runtime unit file went red, 3 of 5
(additive, option-B and residual). The two single-package cases stayed
green.

## Gates

All at `3ec0e9f6`.
- `node scripts/pm/dispatch-gates.mjs --ran`: 69 derived, 69 run, 0
NOT-MEASURED, 0 UNRUN.
- 73 commands exit 0: those 69, the full `pnpm lint` (`eslint .
--no-inline-config`), and the three PR-context checks run against this
PR (`check-closing-target-claim`, `check-partof-closing-keyword`,
`check-single-claim-paths`).
- The dispatch's whole list (134 commands) last ran at `f7d3aac1`, and
every one exits 0. Two of them (`check:dual-build-cjs-loads`,
`check:published-readme-exports`) exit 0 only after a workspace build
cleared their prerequisite.

## Acceptance notes

- **Wider than the title, by construction.** Any datasource that carries
`_packageId` now resolves its package's namespace, including one the
metadata plugin's artifact door registers. That was the reader's
documented intent all along. It is measured here only on
`showcase_external`.
- **Serial:** objectstack-ai#21899 remains open and owns the metadata door's refusal
for `origin: 'code'` datasources. Until it lands, a meta-door save can
replace the stamped item and drop `_packageId`.
- **objectstack-ai#21875 landed first.** This PR carries both docblock words its
reader change made false (the `metadata()` docblock and
`MetadataServiceLike`), and moves objectstack-ai#21875's runtime-save validate pin to
the prefixed import name.

---

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

---------

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/m tests tooling

Projects

None yet

2 participants