Repository navigation
fix(service-datasource): external validate sees a federated object saved at runtime, with no restart - #21875
Conversation
…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>
…lidate-reads-live-registry
…ck dev Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
…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>
…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>
…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>
Fixes #21842
Clause-②: no
What this changes
ExternalDatasourceServicePluginwired the federation service'slistObjectsandgetObjectto themetadataservice. That service holds a copy of the engine's object registry, taken once at boot byObjectQLPlugin's startup bridge.PUT /api/v1/meta/object/:name, and the import that saves through it since #21837, writesys_metadataand the engine registry (applyRegistryWriteThrough), never that copy. SoPOST /api/v1/datasources/:name/external/validatedid not see a runtime-saved object until the next restart.Both object reads now come from the engine's object registry on the
objectqlservice (IObjectQLEngine.registry, itsgetAllObjectsandgetObject). The registry is looked up when validation runs, never atinit()(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 themetadataservice 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 apatchchangeset. Nopackages/specedit, no new export, nocontent/docssentence changed (none describes where the validate door reads its objects).Measured on the real composition (
objectstack dev, showcase)2df3d13dshowcase_external, before any saveshowcase_ext_customerandshowcase_ext_order, both okPUT /meta/object/dg21842_saved(bound to remotecustomers, fieldsname,email,ghost_col), then validatedg21842_savedisok: falsewith one diff,missing_column ghost_colordersasdg21842_imported, then validatedg21842_importedokdg21842_savedwithoutghost_col, then validatedg21842_savedok, no diffsThe 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
getObjectalready see the save?): falsified. With onlylistObjectsmoved (an intermediate build), the saved and imported rows answeredunreachablewithObject 'dg21842_saved' not found.andObject 'dg21842_imported' not found.. The metadata service'sgetObjectreads 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" withobjects: 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 startMeasured on
objectstack start(production mode), showcase:objects: 0, and validate answers{ ok: true, results: [] }. The code-defined federated objects are not listed at all. The plugin reads themetadataservice atinit(), and onstartthat service is the kernel's in-memory fallback, registered just before the start phase and after this plugin'sinit()(the log showsService 'external-datasource' registered, thenService 'metadata' registered, thenPhase 2: Start plugins). So the plugin holds no metadata service for its whole life.init(), which is absent, sovalidateObjecttakes its "not federated" branch. Every row answersok: truewith no diffs, and nothing is compared. The boot gate logsobjects: 2. A saved object withghost_colansweredok: true.The boot gate's pass or abort does not move on either composition, but on
startthis branch turns "no rows" into rows that claimokwithout a comparison. That is close to this dispatch's stop line, so the landing is the seat's call:metadatacapture becomes its own card. Cost: until that card lands,startanswers per-objectok: truethat it never compared.metadataservice (datasource, namespace, catalog write) when it is used. Cost: onstartthe boot gate starts judging, so a deployment with drift under the defaultonMismatch: 'fail'refuses to boot where it started before. That is a boot-behaviour change outside this card.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
packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts(4 cases, relative import, measuressrc/). The fakes model the measured mechanism:metadataholds the boot copy, theobjectqlregistry is live, and a save writes only the registry. A runtime-saved object is listed and judged byvalidateDatasourceand byvalidateAll, 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.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 throughPUT /meta/object/:name, then also an imported one. Under this harness the federation service finds nometadataservice atinit()(the same cause asstart), so this file pins the listing only. The verdict half is the unit file's, and was measured onobjectstack devabove.b44c1c87bb(after mergingorigin/main):@objectstack/service-datasource38 files, 721 tests pass.tsc --noEmitpasses, and--listFilesincludes the new test file. Dogfood: the new file andexternal-import-saves-like-meta.dogfood.test.ts, 2 files, 6 tests pass.94e3056d62), throughscripts/ablation-replace.mjs: both readers were put back to the boot-copy reads (anchor hit 1, blob5fd02852tocad2f3a6). Unit: 4 of 4 failed, for exampleexpected [ '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 atinit()there; the unit file is what separates "boot copy" from "live registry". Restore: blob equals HEAD (5fd02852), andgit diff HEADis empty. Nodist/is on either path: the unit file importssrc/relatively, and the dogfood config aliases@objectstack/service-datasourcetosrc/.Gates
At
c68487ae6c(this head; it differs fromb44c1c87bbby the changeset text only):node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 67 commands. All 67 were run and all exit 0, and--ranreconciles 67 run, 0 NOT-MEASURED, 0 UNRUN. That is the dispatch's 59 plus 8 the changeset brings (check-adr-0087-registrationandcheck-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-loadsfirst answered PREREQUISITE NOT MET (8 unrelated packages had nodist/). Those packages were built, and it was re-run to exit 0.Narrowed eslint (
--no-inline-config,--format json) on the 3 touched.tsfiles: 3 files, 0 errors, 0 warnings. The config enables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file.Acceptance notes
announceAllClear,packages/runtime) still asksmetadata.listDiagnosed('object'), a list the sweep no longer reads. It only shapes the all-clear sentence, never a verdict. Carrier: none.persistCatalogandgetNamespaceread the same init-timemetadatacapture as the datasource read above. This was already noted on fix(service-datasource)!: Import as Object saves through the metadata door's save #21837.origin/mainwas merged atb44c1c87bb. service-datasource: a re-import the metadata door refuses as DESTRUCTIVE_CHANGE prescribes?force=true, which the import route never reads — a third face of #11095's class (reachable once #21788 lands) #21841 has not landed, and nothing onmainsince the base touchesservice-datasource.Seat's append: patch round 1 (written by
domain:servicesseat 1 from the dev's report5999337391; the dev does not edit this body)Patch round 1 (head
9489265ae0):mainmerged after #21887, andobjectstack startmeasured againThe merge.
origin/main607463d736was merged as80dfcc30b7. It carries #21887 (mergebc7747cb) and #21874. There was one conflict, inplugin.ts's object readers. Resolution:getObjectandlistObjectsread the engine registry (objectRegistry()).getDatasource,getNamespaceand thepersistCataloggetter keep #21887's resolver,metadata().MetadataServiceLikekeeps onlygetandregister. 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 anobjectqlregistry fake. The re-ask case tells the two services apart by the datasource definition: amanagedreplacement 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: falsewith one diff,missing_column loyalty_tier. The imported row answersokwith no diffs.objectstack start(showcase): this branch againstorigin/main607463d736as the control, with the same steps.origin/mainall federated objects match their remote schema {"objects":2}PUT /meta/object/dg21842_saved(declaresloyalty_tier, which the remotecustomerstable lacks), then validatedg21842_savedisok: falsewithmissing_column loyalty_tierordersasdg21842_imported, then validatedg21842_importedisok: truewith no diffsexternal schema driftwarn:dg21842_saved,missing_column loyalty_tierThe question in this PR's Decision needed section is closed: on
start, validate judges a runtime-saved object, and no row answersokwithout 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:@objectstack/service-datasource: 39 files, 732 tests pass. Typecheck passes, and--listFilesincludes both unit files.external-validate-start-orderingand service-datasource: importing an external table under a name that differs from its remoteName creates an object that answers 500 "no such table" — and the import does not survive a restart #21788'sexternal-import-saves-like-meta): 3 files, 9 tests pass.metadata()throughscripts/ablation-replace.mjs: the unit file went 4 of 4 red. The door pin went 2 of 3 red: the saved and imported cases failed, and the code-defined listing stayed green because the metadata service now answers it. The restore proved the blob equal to HEAD.--ran: 67 run, 0 NOT-MEASURED, 0 UNRUN.Generated by Claude Code