Repository navigation
fix(service-datasource)!: on objectstack start, read the metadata service when it is used, so validate and the boot gate compare every federated object - #21887
Conversation
… on the start ordering Red at base: the plugin keeps the metadata service it read at init(), and on a composition with no metadata plugin that service registers later. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…ever at init() On objectstack start the metadata service is the kernel's in-memory fallback, registered after every plugin's init(). The federation service kept the absent answer for the life of the process. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…used (narrowing on start) Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…tasource-metadata-read-at-use
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) 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 f8fc103aa30985061cce2bbb8d975c136bd17ff1 && git checkout f8fc103aa30985061cce2bbb8d975c136bd17ff1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e864db56dffc2dec3290e5f0700f9d1606a1c830 c6f237478c0554db62139694cb3867b9fb845bdd && git checkout -B drift-repro e864db56dffc2dec3290e5f0700f9d1606a1c830 && git merge --no-ff c6f237478c0554db62139694cb3867b9fb845bdd
node scripts/docs-audit/affected-docs.mjs --json e864db56dffc2dec3290e5f0700f9d1606a1c830
|
…ecision in words instead of a tracker number (stage 20) (objectstack-ai#21895) Part of objectstack-ai#20749 Clause-②: no Stage 20 of this card: the next area of class (e), the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the first name-ordered `ui/` group: the 32 test files directly under `packages/spec/src/ui/` from `action-confirm-params-guard.test.ts` to `component-record-block-field-security.test.ts`. Those files carried 97 messages and 102 tracker ids, citing 65 records. 101 of those ids now either state what their record decided, in words (form D), or are dropped where the title already says it. One id stays, because an assertion in the same file reads it (below). Text only: no assertion, identifier, test count or code comment changes, and no file is renamed. ## Census at the base (`e085a8c3be`) 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 19 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 `e085a8c3be`, stage 19's landing and the claim's base. Both instruments read **860 messages / 908 ids in 190 files**, the seat's reading and stage 19's head reading. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `ui/` (this PR: 32 of the 84 files) | 84 | 396 / 419 | 378 / 401 | 18 / 18 | | `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** | **190** | **860 / 908** | **804 / 851** | **56 / 57** | The group reads **97 messages / 102 ids in 32 files**, the seat's figures file for file: | file (under `ui/`) | messages / ids | titles | other | |:--|--:|--:|--:| | `action-confirm-params-guard.test.ts` | 4 / 4 | 4 / 4 | 0 | | `action-description.test.ts` | 3 / 3 | 3 / 3 | 0 | | `action-dispatch-contract.test.ts` | 4 / 4 | 4 / 4 | 0 | | `action-doubled-redirect.test.ts` | 1 / 1 | 1 / 1 | 0 | | `action-newtaburl-pair.test.ts` | 1 / 1 | 1 / 1 | 0 | | `action-on-success.test.ts` | 3 / 4 | 3 / 4 | 0 | | `action-param-carryover.test.ts` | 1 / 2 | 1 / 2 | 0 | | `action-param-default-value.test.ts` | 3 / 3 | 3 / 3 | 0 | | `action-params.test.ts` | 9 / 9 | 9 / 9 | 0 | | `action-requires-confirmation-docblock.pin.test.ts` | 5 / 6 | 3 / 4 | 2 / 2 | | `action-row-update.test.ts` | 5 / 5 | 5 / 5 | 0 | | `action.test.ts` | 12 / 12 | 12 / 12 | 0 | | `app-nav-expanded-alias.test.ts` | 1 / 1 | 1 / 1 | 0 | | `app-nav-target-exclusivity-export.test.ts` | 1 / 1 | 1 / 1 | 0 | | `app-strictness-batch19.test.ts` | 1 / 1 | 1 / 1 | 0 | | `app.test.ts` | 9 / 9 | 9 / 9 | 0 | | `aria-carrier-tombstones.test.ts` | 1 / 1 | 1 / 1 | 0 | | `assembled-view-artifact-type.test.ts` | 1 / 1 | 1 / 1 | 0 | | `assembled-views.test.ts` | 1 / 1 | 1 / 1 | 0 | | `bulk-action.test.ts` | 4 / 4 | 4 / 4 | 0 | | `calendar-config-allday-prescription-17054.test.ts` | 2 / 2 | 2 / 2 | 0 | | `chart-aggregate.test.ts` | 1 / 1 | 1 / 1 | 0 | | `chart.test.ts` | 7 / 9 | 7 / 9 | 0 | | `component-action-element-rows-20371.test.ts` | 1 / 1 | 1 / 1 | 0 | | `component-action-row-endpoint-21005.test.ts` | 1 / 1 | 1 / 1 | 0 | | `component-element-navigation-17987.test.ts` | 4 / 4 | 4 / 4 | 0 | | `component-form-custom-fields-sections-typed.pin.test.ts` | 1 / 1 | 1 / 1 | 0 | | `component-object-grid-default-filters.pin.test.ts` | 1 / 1 | 1 / 1 | 0 | | `component-object-grid-export-options-members.pin.test.ts` | 1 / 1 | 1 / 1 | 0 | | `component-object-grid-pagination-accept-set.pin.test.ts` | 1 / 1 | 1 / 1 | 0 | | `component-props-unknown-members.pin.test.ts` | 2 / 2 | 1 / 1 | 1 / 1 | | `component-record-block-field-security.test.ts` | 5 / 5 | 5 / 5 | 0 | | **32 files** | **97 / 102** | **94 / 99** | **3 / 3** | The three "other" strings are `action-requires-confirmation-docblock.pin.test.ts:168` and `:175` (two `expect` messages) and `component-props-unknown-members.pin.test.ts:322`. The claim calls the third one an `expect` message too; it is the `ruling` value of a ledger entry. 12 more test files in the same name range carry no id and are not touched. - **Controls.** Lit: `ui/component.test.ts`, outside the group, reads 70 ids at the base and at the head. Dark: `action-requires-confirmation-docblock.pin.test.ts` reads 0 at the head while 4 of its comment lines still carry a number. Planted in scratch copies of head files: an id put into an `app-nav-expanded-alias.test.ts` title reads 1 / 1, and an id put into a `bulk-action.test.ts` comment reads 0. - **A wider pattern** (any `#` plus digits) reads the same as the gate pattern in 29 of the 32 files at the base. The other three differ only by 16 hex colour literals (`'#0070F3'` in `app.test.ts`, `'#8B5CF6'` in `bulk-action.test.ts`, the `colors` arrays in `chart.test.ts`), which this PR does not touch. - **At the head:** 764 messages / 807 ids in 159 files. The 32 files read 1 / 1 (the kept `:322`), `ui/` reads 300 / 318, and no other file moved. ## How the area was chosen `ui/` has no subdirectory, so it is taken like `data/`, in name-ordered file groups near the ~100-id bound. Stage 19's re-cut named this group at 102 ids, and this census reads 102, so no re-cut was needed. **Named for the next stages** (cut from the head census, 764 / 807): - `ui/` 318 ids in 53 files, about three stages. The next group nearest 100 runs from `component-record-blocks.test.ts` to `dashboard.test.ts`: 7 files, 103 messages / 109 ids (98 titles / 103 ids, 5 other). `component.test.ts` alone carries 70. Cutting one file earlier gives 88. That group holds five "other" strings: `dashboard-chart-structure-refusal.test.ts:94` (two ids) and `dashboard.test.ts:124` (two ids), which read like placeholders (`objectstack-ai#111`, `objectstack-ai#222`), and `dashboard.test.ts:205` (objectstack-ai#5022). - `api/` 201, two stages. `system/` 165, two. The files directly in `src/`, 120, one. - The three docblock needles (`ai/build-progress.test.ts` ×2, `contracts/approval-service.test.ts` ×1), one stage, with an at-tier review. The id this PR keeps (`component-props-unknown-members.pin.test.ts:322`) fits there too. ## The three "other" strings: two rewritten, one kept - **`action-requires-confirmation-docblock.pin.test.ts:168` and `:175` are not needles.** Each one is the failure message (the second argument of `expect(value, message)`) of an assertion whose expected value carries no id: `.toEqual([])` over the docblock's positive `confirmText` claims, and `.not.toMatch(/confirmText/)` over the classifier's body. Nothing compares the message text. Both are rewritten and declared to the text-only tool. - **`component-props-unknown-members.pin.test.ts:322` is kept.** Its `ruling: 'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'` is the expected value of the assertion at `:417` in the same file, `expect(reason.kind === 'opaque' && reason.ruling, key).toMatch(/objectstack-ai#21704/)`, and the `ruling` slot is typed as naming the record that holds the member (`:188`). Removing the id turns `:417` red, and moving that regex would change assertion text, which this stage does not do. It is not a docblock needle, since it reads a value in its own file, but it is held the same way. It is reported for the needle stage. ## What each id became - **36 literals (41 ids)** now state a decision in words. - **13 literals (13 ids)** get their subject back in words, where the number stood for a thing, such as "the objectstack-ai#7428 pair rule". - **47 literals (47 ids)** drop a number the title already explains. Every cited record was read with its comments through REST: 62 answer 200 and 3 answer 404. Three citations are cross-repo (`objectui#3139`, `objectui#3382`, `objectstack-ai/objectui#11610`); all three were read from objectui and answer 200. Where a record answers 404, or answers 200 without the decision, the decision was read from what landed: - **objectstack-ai#11753** (404): its ruling, recommendation A, is quoted in its spec-half card objectstack-ai#11992. - **objectstack-ai#17987** (404): the landing commit `e233db9dbb` ("declare element-level `navigation` on object-kanban / object-calendar and give object-timeline its ComponentPropsMap row"), executing objectui#8652's ruling B. - **objectstack-ai#18177** (404): the landing commit `adabccf5fb` ("BulkActionParamSchema is strict and declares dependsOn"), decision batch objectstack-ai#146 item 4, letter A. - **objectstack-ai#3896** (200): the record is a sharing-rule REST defect, closed with no comment. The "close-out" the title names is what landed under that citation: the `action-inert-keys-removed` conversion in `conversions/registry.ts` ("capability claims nothing enforced") and the `shortcut` / `bulkEnabled` tombstones in `ui/action.zod.ts`. - **objectstack-ai#3701** (200, closed with no comment): the convention as `ui/chart-aggregate.ts` writes it down. **Stated in words:** | record(s) | literal (under `ui/`) | now reads | the decision | |:--|:--|:--|:--| | objectstack-ai#7278 | `action-confirm-params-guard.test.ts:148` | "… — the one-dialog shape the confirm question migrated TO" | Maintainer option 1: drop `confirmText` and carry the question as the param dialog's description, one decision in one dialog. | | objectstack-ai#7367 | `action-description.test.ts:35` | "ActionSchema.description — the line the param dialog shows" | `description` joins the action contract, shaped like `label`, as the param dialog's description line. | | objectstack-ai#17319 | `action-dispatch-contract.test.ts:43`, `:77` | "… (and still true: the declared dispatch contract is an authoring key)"; "an action's dispatch contract speaks `bulkActionDefs`' own vocabulary" | Ruling A: an action declares its bulk dispatch contract in the bulk def's own `execution` vocabulary, and a view that wires it the other way is refused at validate. | | objectstack-ai#11519 | `action-doubled-redirect.test.ts:24` | "ActionSchema — doubled post-success navigation is refused, with no precedence field" | Refuse the doubled channel; no `precedence` field. | | objectstack-ai#9566, objectstack-ai#9474 | `action-on-success.test.ts:13` | "ActionSchema.onSuccess — one closed post-success navigation key for api and script actions" | Ruled together: one closed `onSuccess` key (`navigate` + `openIn`) for both types. | | objectstack-ai#4352 | `action-on-success.test.ts:141`; `action.test.ts:1365` | "type scope — api and script only, refused on any other type, never silently ignored"; "… the publish gate resolves to it, so a `body` off `script` is refused at publish" | Outlet 1: the runtime follows the spec, and contradictory type-scoped metadata errors at publish instead of being ignored. | | objectstack-ai#11992, objectstack-ai#11753 | `action-param-carryover.test.ts:17` | "ActionParamSchema.carryOver — seeded from the row, shown read-only, submitted verbatim" | Recommendation A on objectstack-ai#11753, executed by objectstack-ai#11992. | | objectstack-ai#20740 | `action-param-default-value.test.ts:250` | "… refuses a zone-suffixed `time` value — a time of day is a zone-less wall clock" | `ClockTimeValueSchema` narrowed to the zone-less wall clock. | | objectstack-ai#5568 | `action-params.test.ts:139` | "names `_selectedIds` when the caller sent `selectedIds` — the declared channel for a selection" | Verified and closed: `params._selectedIds` is the working declared channel; the reported gap was not one. | | objectstack-ai#5613 | `action-params.test.ts:294` | "accepts the DUAL-EMIT shape the runtime emits through the rename window — …" | Contract first, then `roles` → `positions` with a deprecation window in which the runtime emits both keys. | | objectstack-ai#7828, objectstack-ai#13865 | `action-requires-confirmation-docblock.pin.test.ts:128`, `:150`, `:168`, `:172`, `:175` | "… docblock names only the declared signals the classifier reads"; "would flag the retired sentence …"; "… re-seeds the retired leg — only declared semantics classify" (message); "… still does not read `confirmText` — only declared semantics classify"; "… reopen the declared-semantics ruling before the docblock …" (message) | objectstack-ai#7828 option A: `actionLooksDestructive` classifies on declared semantics only (`mode`, `variant`), never on UI copy. objectstack-ai#13865 retired the docblock sentence that still named `confirmText`. | | objectstack-ai#3405 | `action.test.ts:85`, `:141` | "inline lookup reference target — `reference`, the FieldSchema spelling"; "… at `reference`, the one key an author writes" | `ActionParamSchema` gains `reference`, named as `FieldSchema.reference` so the spelling authors already write is the legal one. | | objectstack-ai#15811 | `action.test.ts:217` | "… at the SLOT, which needs a `source` to evaluate, not at the lowering" | Ruling A: every engine-evaluated expression slot requires a non-blank `source`. | | objectstack-ai#20323 | `action.test.ts:1116` | "Action ARIA Integration (retired — no action surface ever read it)" | Retire `action.aria`: no action surface reads it. | | objectstack-ai#6888 | `action.test.ts:1513` | "`global_nav` is retired — it rendered nowhere" | Direction 2: retire `global_nav`, which rendered nowhere in the running app. | | objectstack-ai#3896 | `action.test.ts:1612` | "audit close-out — retired shortcut/bulkEnabled, capability claims nothing enforced" | Read from what landed (above). | | objectstack-ai#5016 | `action.test.ts:1650` | "action param option vocabulary — declared only where a renderer delivers it" | Ruling B, on condition that it lands with the renderer and after a per-key liveness audit; `visibleWhen` is declared, `color` / `default` / `icon` / `disabled` stay refused. | | objectstack-ai#4001 | `app-strictness-batch19.test.ts:99` | "批 19, unknown keys refused — the `verify` check …" | Every authorable surface goes strict; spelled as stage 18 spelled the 批 20 titles. | | objectstack-ai#5320 | `assembled-views.test.ts:51` | "AssembledViewArtifactSchema — the declared home for non-container view artifacts" | Fork ruling B: a declared, portable home for non-container view artifacts. | | objectstack-ai#4457 | `bulk-action.test.ts:20` | "BulkActionDefSchema — the def shape is typed, not `z.any()`" | Type the def that was `z.record(z.any())`. | | objectstack-ai#17054 | `calendar-config-allday-prescription-17054.test.ts:106` | "what declaring `allDayField` did NOT open" | Ruling A: `CalendarConfigSchema` declares `allDayField`. | | objectstack-ai#3701 | `chart-aggregate.test.ts:45` | "result-column naming convention — rows keyed by the raw field names" | Read from what landed (above). | | objectstack-ai#17751 | `chart.test.ts:271` | "Chart ARIA Integration — retired, no renderer ever applied it" | Retire `ChartConfigSchema.aria`. | | objectstack-ai#4001, objectstack-ai#5583 | `chart.test.ts:476` | "批 15 — the two chart sites left open on a measurement, since CLOSED as strict objects" | objectstack-ai#5583: both schemas became strict objects. | | objectstack-ai#5022 | `chart.test.ts:631` | "ChartDrillDownSchema — the honest subset, every key one ObjectChart reads" | Declare `drillDown` with only the keys `ObjectChart` reads. | | `objectui#3382`, objectstack-ai#5435 | `chart.test.ts:721` | "target: 'navigate' is ACCEPTED — the chart renderer delivers it now" | `ObjectChart` gained `'navigate'`, so the union gained the member. | | `objectui#11610` | `component-form-custom-fields-sections-typed.pin.test.ts:496` | "§5 the grid widget's eight field-level keys, camelCase since objectui renamed them" | objectui renamed the eight keys to camelCase with no dual read. | | objectstack-ai#19046 | `component-object-grid-pagination-accept-set.pin.test.ts:172` | "both arms refuse a … pageSize — the view and component arms no longer disagree" | The grid component arm refuses the page sizes the view arm refuses. | | objectstack-ai#18159 | `component-record-block-field-security.test.ts:174` | "`requiredPermissions` is declared on the three blocks as a capability set (instruments A and B)" | Ruling A: the key follows the objectstack-ai#19186 ruling, an ADR-0066 capability set. | **Subject back in words** (13 literals): "objectstack-ai#7428 —" becomes "the `confirmText` + `params` guard" or "the pair guard" (`action-confirm-params-guard.test.ts:130`, `:171`), and "the objectstack-ai#7428 pair rule" becomes "the `confirmText` + `params` pair rule" (`action-row-update.test.ts:280`); "the surfaces objectstack-ai#7367 …" becomes "the surfaces the action `description` key …"; two `objectstack-ai#17319 —` prefixes become "`execution` on an action" and "dispatch-contract default"; "the pre-existing probes (objectstack-ai#9474)" becomes "the spellings tried before `onSuccess` existed"; "the bare filter objectstack-ai#14175 declared" becomes "the bare filter the facade was first typed with"; "objectstack-ai#14092 — boundaries" becomes "the declarative row update — boundaries"; "objectstack-ai#17631's shape" becomes "the never-parsing gate it once built"; "§6 objectstack-ai#21464 is closed out" becomes "§6 the `z.unknown()` member sweep is closed out"; and two `objectstack-ai#18159 —` prefixes become "the three record blocks" and "the field-security pair". **Dropped where already stated** (47 literals, 47 ids). A number goes only where the title already says its decision. Examples: "ActionSchema — newTabUrl requires opensInNewTab: true (objectstack-ai#11842)"; "ActionSchema — the `execute` alias is REMOVED (objectstack-ai#3855)"; the `objectstack-ai#15124 —`, `objectstack-ai#15117 —` and `objectstack-ai#5779 —` prefixes, each in front of the rule it names; the four `[objectstack-ai#17987]` prefixes on `component-element-navigation-17987.test.ts`; "— the shape is closed, so its accept means something (objectstack-ai#18177)"; "retired fail-open area gates (objectstack-ai#4651)". `(ADR-0049)`, `(ADR-0066 D4)` and `(ADR-0078)` stay: they cite decision records by number, not tracker ids. **No file is renamed.** Four file names carry a number (`calendar-config-allday-prescription-17054`, `component-action-element-rows-20371`, `component-action-row-endpoint-21005`, `component-element-navigation-17987`); they are not this card's. ## Readers - **Test-name filters:** none. No tracked script, workflow or package config passes `-t` / `--testNamePattern`. - **Snapshots:** none. No `__snapshots__` directory is tracked under `packages/spec`, and none of the 32 files calls a snapshot matcher. - **Projects:** one of the 32 files runs in the `repo` project: `action-requires-confirmation-docblock.pin.test.ts` is listed in `packages/spec/vitest.repo-tests.json`, because it reads `packages/runtime/src/action-execution.ts`. The other 31 run in `local`. - **By substring:** every old literal, plus a window around each id (290 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 11 hits: - 7 code comments in `ui/component.zod.ts` citing `objectstack-ai/objectui#11610`, and one in `ai/agent.test.ts:193` ("objectstack-ai#3896 close-out"); - one release-owned line, `content/docs/releases/v17/17-0.mdx:326`; - one sibling title in `runtime` (`action-engine-facade-find-envelope.test.ts:80`, "objectstack-ai#15124 — …"). None reads a spec test title. - **The files by name:** a few gates and ledgers name group files by path, never by title: `scripts/check-parse-guard.mjs` reads a code line of `app.test.ts`, `packages/spec/test-typecheck-debt.json` keys `app.test.ts` and `chart.test.ts` on error signatures, and `vitest.repo-tests.json` lists the docblock pin. ## 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 two lines, `action-requires-confirmation-docblock.pin.test.ts:168` and `:175`. - **Result:** 32 of 32 files SAME on all three legs, with the per-file counts predicted in writing before the run. - **Totals:** 96 changed string leaves in 96 literals: 94 titles and 2 declared. The diff's `+` and `-` lines are exactly the 96 planned lines, and every file keeps its line count. - **Controls (12 of 12 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 `expect` message changed VIOLATION; a title re-split into a `+` chain DIFF; a declared message given an id back VIOLATION; the kept `:322` ruling value edited VIOLATION. - **Templates and tables:** the one template title (`component-object-grid-pagination-accept-set.pin.test.ts:172`) changes only after its `${label}` span. No `.each` title, `%s` / `$name` placeholder or table row changes. **Test counts:** the 32 files were run at the base, in a separate base worktree, and at the head, with `--project local --project repo`. Both sides read 959 tests in 32 files, all passed, with the same count and status sequence per file in 32 of 32. 513 full test names change, and no full name repeats on either side. Each changed name equals the base name with the planned replacements applied. The comparison script flags one name: its plan entry spells `’` as the source does, and the printed name carries the decoded character. With the escape decoded, that name matches too. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 32 touched files are in it, and no `*.test.ts` at all. The controls `src/ui/action.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 `fa39bcf62e`) - `pnpm turbo run build` over all packages: 71 / 71, through the shared verify lock (`VERDICT command-exit 0`). - `@objectstack/spec`: - `vitest run --project local`: 616 files, 18426 passed, 1 todo. - `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246 errors / 135 pinned signatures held). Its program holds all 32 touched 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 19, 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`. - **ESLint, a proven narrowing:** `--no-inline-config` over the 32 files reads 0 errors and 0 warnings. The population comes from ESLint's own config: 32 configured, 0 ignored. No file sets `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 192 changed lines (+96 / -96). - A control-byte scan over the 32 files finds none. ## `main` since the base Re-fetched just before this PR opened, `origin/main` was two commits past the base (`67c544ccca`: objectstack-ai#21887, objectstack-ai#21891). Neither touches any of the 32 files, and neither touches `packages/spec`: their 9 files are in `service-datasource`, `qa/dogfood`, the QA checklist and one changeset. So `main` was not merged. `git merge-tree` onto `67c544ccca` is clean. None of the 13 open PRs touches the 32 files. ## Acceptance notes - **The kept id** (`component-props-unknown-members.pin.test.ts:322`, read by `:417`) is held for the needle stage, as above. - **Same-id test titles in this card's later stages** go with those stages. 30 lines in `packages/spec/src` cite ids this PR handled, for example `api/api-error-code-type.test.ts:71` ("[objectstack-ai#19920] …"), `system/job.test.ts:836` ("retired job.id (objectstack-ai#4667)"), `ui/view-strictness-batch18.test.ts:91` ("objectstack-ai#4001 批 18 — …"), `ui/view.test.ts:3144` ("(objectstack-ai#3896 close-out)") and `ui/component.test.ts:3518` ("(objectstack-ai#19514)"). `ui/view.test.ts:3386` cites `objectui#5435`, a different record. - **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 44 lines citing ids this PR handled, in 11 packages: `lint` 16 (6 files), `runtime` 9 (6), `cli` 5 (3), `plugin-approvals` 3 (2), `spec/scripts` 3 (2), `objectql` 2 (2), `plugin-security` 2 (2), and one each in `plugin-sharing`, `qa/dogfood`, `rest` and `service-automation`. Examples: `runtime/src/action-execution-destructive.test.ts:86` ("(objectstack-ai#7828 Option A)"), `lint/src/validate-react-page-props.test.ts:1008` ("since objectui#3382 (objectstack-ai#5435)"), `cli/test/i18n-extract-action-description.test.ts:26` ("(objectstack-ai#7367)"). Some of the `objectstack-ai#3896` hits there (`plugin-sharing`, `qa/dogfood`) cite the sharing-rule record itself. - **Code comments still carry ids** in these files and their sources, for example the `[objectstack-ai#13865]` header of the docblock pin, the `objectstack-ai#20323` comment above `action.test.ts:1116` and the `objectstack-ai/objectui#11610` comments in `ui/component.zod.ts`. 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>
…ved at runtime, with no restart (objectstack-ai#21875) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 - The boot gate's completeness probe (`announceAllClear`, `packages/runtime`) still asks `metadata.listDiagnosed('object')`, a list the sweep no longer reads. It only shapes the all-clear sentence, never a verdict. Carrier: none. - `persistCatalog` and `getNamespace` read the same init-time `metadata` capture as the datasource read above. This was already noted on objectstack-ai#21837. - `origin/main` was merged at `b44c1c87bb`. objectstack-ai#21841 has not landed, and nothing on `main` since the base touches `service-datasource`. ## 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 objectstack-ai#21887, and `objectstack start` measured again **The merge.** `origin/main` `607463d736` was merged as `80dfcc30b7`. It carries objectstack-ai#21887 (merge `bc7747cb`) and objectstack-ai#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 objectstack-ai#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. **objectstack-ai#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 (objectstack-ai#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 objectstack-ai#21876 landed first as PR objectstack-ai#21887. The boot gate output matches objectstack-ai#21887's on both the fresh boot and the restart. **Measured at `9489265ae0`:** - `@objectstack/service-datasource`: 39 files, 732 tests pass. Typecheck passes, and `--listFiles` includes both unit files. - Dogfood typecheck passes. The three door pins (this one, objectstack-ai#21887's `external-validate-start-ordering` and objectstack-ai#21788's `external-import-saves-like-meta`): 3 files, 9 tests pass. - Ablation, both object readers put back to `metadata()` through `scripts/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. - Gates: 67 derived, all exit 0. `--ran`: 67 run, 0 NOT-MEASURED, 0 UNRUN. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21876
Clause-②: no (narrowing)
What this changes
ExternalDatasourceServicePlugin.init()read themetadataservice once and kept the answer.objectstack startcomposes no metadata plugin. Itsmetadataservice is the kernel's in-memory fallback, which the kernel pre-injects after every plugin'sinit(), just before the start phase. Thestartlog shows the order:Service 'external-datasource' registered, thenService 'metadata' registered, thenPhase 2: Start plugins. So onstart, every reader of the kept value saw no service for the life of the process.The plugin now looks up the
metadataservice when each reader runs. It uses a resolver function called at each use, the same patternmetadataSaveDooralready follows in thisinit()(AGENTS.md, "Startup registry reads", cure 1). That coversgetDatasource,getObject,listObjects,getNamespaceand the catalog write. The catalog write was a conditional spread, soinit()decided whether thepersistCatalogslot existed at all (H2). It is now a getter, aspersistObjectalready is. With no metadata service at all, each reader returns the same fallback it always did.Not changed: what validation judges, what each
onMismatchvalue does, and the boot gate's code (packages/runtime). The import's save call (persistObject, landed with #21874) is not changed either. Objects are still read from the metadata service's copy. #21842's PR #21875 moves them to the engine registry.The object reads, called out.
getObjectandlistObjectsread 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: onstart, validate would still list no objects. These are the lines PR #21875 replaces. When #21875 mergesmain, itsgetObjectandlistObjectsshould replace these, and it should keep this PR'sgetDatasourceline.Files:
packages/services/service-datasource/src/plugin.ts, one new unit file beside it, one new dogfood file, and aminorchangeset with the!banner. Nopackages/specedit, no new export, and no edit to an existingpackages/qafile.Measured on the real composition (showcase,
objectstack startandobjectstack dev)Remote fixture for the probe:
customers.lifetime_valueis TEXT on the remote, whileshowcase_ext_customerdeclares a currency field. That is atype_mismatchat severityerror. A missing column would not survive the boot, because the showcase's ownonEnableadds missing columns. A column type it leaves alone. The base build ofservice-datasourceisplugin.tsat2e780467. The branch build isplugin.tsat10dd86129b, which is this head'splugin.tsminus #21874's one-linewriteFacechange from the merge.objectstack startonMismatch: 'warn'all federated objects match their remote schema {"objects":0}external schema drift(warn) onshowcase_ext_customer:type_mismatchlifetime_value, expectedcurrency, actualtextobjects: 0whatever the remote holds)all federated objects match their remote schema {"objects":2}onMismatch: 'fail'for the probe (restored after; blob equals HEAD)objects: 0POST /datasources/showcase_external/external/validate{ ok: true, results: [] }showcase_ext_customerok: falsewith thetype_mismatch,showcase_ext_orderok: truePOST …/external/refresh-catalog, thenGET /meta/external_catalog/showcase_external_catalogRESOURCE_NOT_FOUND(never stored)GET …/external/tablesallowedSchemas)POST …/tables/customers/draftcustomers, with theTODO(namespace)noteordersasprobe_ext_orders_21876, then asshowcase_probe_orders_21876results: []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 strippingsnapshotAt. Both boot gates log the same drift warning.devanswers exactly what the branch now answers onstart.The namespace note. The showcase's code-defined datasource carries no
_packageIdon either composition,devincluded, 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. Onstartit was blind on every deployment, and it now answers as ondev.Introspection (
dataservice). It is still read atinit(). Onstartit is already registered by then: basestartanswered tables, draft and refresh. See Acceptance notes.Tests
packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts: 10 cases, relative import, so it measuressrc/. Themetadataservice is registered AFTERinit(), as onstart. 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, assertingcode: 'EXTERNAL_IMPORT_ERROR'andstatus: 400, and the save door is never called. The refreshed catalog is persisted.allowedSchemasis honoured. The service is looked up again at each use, never remembered. Control: a service registered BEFOREinit()(thedevordering) gives the same answers. With no metadata service, every reader keeps its fallback.packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts: 3 cases, booted showcase. The remote'scustomers.emailis renamed away after provisioning; the harness does not runonEnableat boot, so the drift survives. A precondition asserts that the harness'smetadataservice is the kernel's in-memory fallback, thestartshape. Validate compares both federated objects and reportsmissing_column email.validateAll()lists both.ce28b73809: the two test files on the baseplugin.ts): unit 7 failed, 3 passed, for exampleexpected [] to deeply equal [ 'wh_customer', 'wh_order' ]andexpected "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', … ].10dd86129b(fix committed first), throughscripts/ablation-replace.mjsin WRAP mode. The resolver line was replaced by a capture taken atinit()and a resolver returning that capture (anchor hits 1, blob3f9f1968to2dbc3ade, markerABLATION-21876count 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 restore3f9f1968equals HEAD,git diff HEADis empty andgit statusis clean. Nodist/is on either path: the unit file importssrc/relatively, and the dogfood config aliases@objectstack/service-datasourcetosrc/.c6f237478c(origin/maine864db56dfmerged, which carries fix(service-datasource): a destructive re-import's refusal names the remedies that work from the import route #21874): closure build (@objectstack/dogfooddependencies plusservice-datasource) 63 of 63 tasks.@objectstack/service-datasource: 38 files, 728 tests passed.tsc --noEmitpasses, and--listFilesincludes the new unit file (count 1). Dogfood: the new file plusexternal-import-saves-like-metaandexternal-import-destructive-remedy(the other two files that mount this plugin), 3 files, 10 tests passed. Dogfoodtsc --noEmitpasses, with the new file in--listFiles(count 1).The gap, named. The dogfood file boots the verify harness, not the
objectstack startCLI. The harness composes no metadata plugin, so itsmetadataservice is the same kernel fallback, injected at the same moment as onstart. The precondition case holds that. The harness does not mount the boot gate. Mounting it from this file would need@objectstack/runtimeas a value import, which the dogfood package does not alias. That would mean a new pair incheck: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 onstartis the probe table above.Gates
At
c6f237478c:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 67 commands: the dispatch's 59 plus 8 that the changeset brings. All 67 were run and all exit 0.--ranreconciles 67 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (exit 3), because 8 unrelated packages had nodist/.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-verdictexit 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.tsfiles: 3 files, 0 errors, 0 warnings. The changeset.mdis outside eslint's population: eslint answers "File ignored because no matching configuration was supplied".eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file.Docs
No
content/docssentence is made false.data-modeling/external-datasources.mdxpromises a federated datasource is "validated at boot" and that a mismatch "fails boot". That was false onstartand is now true.Acceptance notes
engine(thedataservice) is still read atinit(). It is not part of this card. On bothstartanddev,ObjectQLPluginregisters it in its owninit(), which runs before this plugin. Basestartintrospected (tables, draft, refresh answered), so nothing was measured wrong. Carrier: none.devas onstart. It is reported to the seat with its evidence, not filed from here.POST /external/validatedoes not see a federated object saved at runtime (throughPUT /meta/objector the import) until the next restart #21842's, fixed by PR fix(service-datasource): external validate sees a federated object saved at runtime, with no restart #21875 after this lands.Generated by Claude Code