Repository navigation
fix(service-datasource)!: Import as Object saves through the metadata door's save - #21837
Conversation
…door's save
persistObject called metadata.register('object', ...), which held the
definition in memory only: no sys_metadata row, no engine schema sync, no
external-object registration. An object imported under a name that differs
from its remote table answered 500 'no such table', and every import was
gone after a restart. It now calls saveMetaItem on the 'protocol' service,
the save PUT /meta/object/:name makes, resolved when the import runs.
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>
…ternal-import-saves-like-meta Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 3 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 0e25d1ccaa9da4d060854507515d3eed26bfdd0d && git checkout 0e25d1ccaa9da4d060854507515d3eed26bfdd0d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8832655af282e88046dc699747c4930fa94bd361 8d640fa2829a734c2e58ad1a4702c1ec73b7ce49 && git checkout -B drift-repro 8832655af282e88046dc699747c4930fa94bd361 && git merge --no-ff 8d640fa2829a734c2e58ad1a4702c1ec73b7ce49
node scripts/docs-audit/affected-docs.mjs --json 8832655af282e88046dc699747c4930fa94bd361
|
…ternal-import-saves-like-meta Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…ss a cold boot Declares @objectstack/service-datasource (lockfile importer regenerated by pnpm install) and aliases it to source, so the pin mounts ExternalDatasourceServicePlugin from this checkout. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…provisioned injected anchors A stored federated object is read with the anchors the platform injects (organization_id, created_by, updated_by, owner_id, owning_business_unit_id). Gate 2 compared them with the remote and reported each as missing_column at error severity, so a datasource with the default onMismatch 'fail' refused to boot. validateObjectUsing now skips the columns unprovisionedInjectedColumns names. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…idation skips injected anchors 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>
… drift stays an error Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…remedies that work from the import route (objectstack-ai#21874) Fixes objectstack-ai#21841 Clause-②: yes (widening) ## What this changes "Import as Object" (`POST /api/v1/datasources/:name/external/tables/:remote/import`) saves through the metadata door's `saveMetaItem`. A re-import that would drop a field the stored object still carries is refused by that save's destructive-change gate, and the import route relays the refusal as `400 EXTERNAL_IMPORT_ERROR`. The refusal ended `re-submit with ?force=true to proceed.` The import route reads no `force`, so following that sentence returned the identical refusal. - `persistObject` (`packages/services/service-datasource/src/plugin.ts`) now sends `writeFace: 'external-import'` on its save. The server states the face. The import's options cannot carry a face or a `force` into the save (pinned). - `destructiveChangeRemedy` (`packages/metadata-protocol/src/protocol.ts`) gains a case for that face, in the existing faces' grammar: name the door, deny the mechanism, then prescribe. The served sentence is now: `this import cannot be forced: the external-table import route accepts no force. Import the table under a new name, or save the changed definition of 'NAME' through PUT /api/v1/meta/object/NAME?force=true, which accepts the destructive change on purpose.` Those are the words of the import's earlier changeset. - `SaveMetaItemRequestSchema.writeFace` (`packages/spec/src/api/protocol.zod.ts`) and the local `MetadataWriteFace` gain the one member. The reference row is regenerated. - The refusal itself stays: still `400 EXTERNAL_IMPORT_ERROR`, and the stored definition does not move (pinned before and after). The other faces' text is unchanged. On this face the 422 clause keeps its full-prose default, because the import route's envelope carries no `issues`. That is pinned, and ablated below. ## Route taken: an import face, not a working `force` (four axes) All four axes point the same way, so there is no trade-off to hand up. - **Business need (measured).** The external-table import route has no first-party caller that re-imports. In objectui at the pinned `.objectui-sha` `0abd4f9f` (and at its main `f1a177c`), there are 0 calls of that route. The console's import dialog (`importObjectDraft`, `app-shell/src/views/metadata-admin/external/api.ts`) calls `POST …/draft` and then `PUT /api/v1/meta/object/:name`. Control: the draft route and the `PUT` both hit in that file. In this repo at `2df3d13d`, there are 0 callers of `datasources.external.import(` outside `packages/client`. That SDK method sends its options as a JSON body and has no query-string channel at all. The door the face names already serves this case, from raw HTTP and from the SDK (`meta.saveItem(…, { force: true })`). The door pin measures it at `200`. A `force` on the import route would have zero pull. - **Long-term soundness.** This follows the precedent the class's earlier ruling set. A `force` was threaded only where a twin door already read it. The import route has no such twin. Acknowledging a destructive change stays on one door, where the caller authors the whole definition. A working `force` here would also add a query parameter to a route with no closed query set, and a `force` through `IExternalDatasourceService.importObject`'s published contract. - **Preventing AI mistakes.** Both routes make the sentence true. But a `force` on the import would let a body that holds only table options overwrite an existing object's fields under a colliding `name`, including an object no import created. The face keeps the stricter contract: the import never overwrites destructively. - **Startup focus.** The face costs one enum member, one `case` and one call-site argument. A `force` would be a new accepted parameter on a REST route plus a service-contract change, with no caller. ## Measured - **H1 reproduced first** (red pin committed in `28bf4c43`, run on base `src` and base `metadata-protocol` `dist/`): `Tests 3 failed | 1 passed (4)`. Served message: `… Field 'region' removed — existing data in this column will become inaccessible. — re-submit with ?force=true to proceed.` The case that follows that prescription got past `expect(followed).toBe(message)`: the import with `?force=true` answered the byte-identical refusal, and the stored definition was unchanged. - **After** (HEAD `5e5dedaa`): the door pin `test/external-import-destructive-remedy.dogfood.test.ts` passes `Tests 4 passed (4)`. Run beside objectstack-ai#21837's pin, `Test Files 2 passed / Tests 7 passed`. Each remedy is followed: the import under a new `name` answers `201` and serves 3 rows, and `PUT /api/v1/meta/object/NAME?force=true` answers `200` and drops `region`. Control: the same `PUT` without `force` answers `409 DESTRUCTIVE_CHANGE` with the stored definition unchanged. - **Ablation 1** (`scripts/ablation-replace.mjs`, the import stops stating its face; `plugin.ts` blob `b0385ebd` to `2c89cd8f`, anchor x1 to x0). Direction predicted before running. Door pin `Tests 3 failed | 1 passed (4)`, with the served text back to `re-submit with ?force=true to proceed.`. Seam pin `Tests 2 failed | 3 passed (5)`. The dogfood alias resolves `@objectstack/service-datasource` to `src`, so no rebuild. Restore: blob `b0385ebd` equals HEAD, `git diff HEAD` empty. - **Ablation 2** (`case 'external-import':` unmatched in `protocol.ts`; blob `4e881baf` to `66cce7ad`). Face inventory `Tests 2 failed | 24 passed (26)`, exactly the "never re-submit" and "names the door" cases. Restore proven, blob equals HEAD. - **Ablation 3** (the import face added to the 422 trimming case; blob `4e881baf` to `d1f48892`). Face inventory `Tests 1 failed | 25 passed (26)`, exactly the `[COUPLING]` case. Restore proven, blob equals HEAD. - **Reverse type check** (`'external-importt'` pasted into `plugin.ts`): `service-datasource` typecheck `TS2820 … not assignable to type '"package-duplicate" | "meta-envelope" | "meta-dispatch" | "external-import" | undefined'`. That union is read from the rebuilt spec `.d.ts`. Restore proven, blob equals HEAD. ## Tests (all at HEAD `5e5dedaa`) - `@objectstack/metadata-protocol`: `Test Files 214 passed | 3 skipped (217)`, `Tests 27751 passed | 19 skipped`; typecheck exit 0. - `@objectstack/service-datasource`: `Test Files 37 passed (37)`, `Tests 718 passed (718)`; typecheck exit 0. - `@objectstack/spec`: `Test Files 668 passed (668)`, `Tests 19289 passed | 1 todo`; typecheck exit 0; `check:generated`: `All 15 generated artifacts are up to date` after `gen:docs`. - `@objectstack/dogfood`: typecheck exit 0. - Each edited test file is in its package's typecheck program (`tsc --listFiles` count 1 for each of the three). - Gates: `dispatch-gates --commands` derived 114 commands at this HEAD. All 114 ran, and all exit 0. Two of them first refused `PREREQUISITE NOT MET`: `check:skill-examples` needed `client-react`'s `dist/`, and `check:dual-build-cjs-loads` needed eight packages' `dist/`. Both were built and re-run to exit 0. `dispatch-gates --ran`: `114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED`. - Lint, narrowed: eslint over the 7 changed TypeScript files (`--no-inline-config --format json`) reported 7 files, 0 errors, 0 warnings. eslint's own config ignores the other two changed paths (`.md`, `.mdx`: "no matching configuration"). `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` is CI's. ## Acceptance notes - The import route lives in `packages/rest/src/external-datasource-routes.ts`, not in `external-datasource-service.ts` as the claim's file surface says. It is unchanged: it already relays the message verbatim. - `@objectstack/rest` lists `@objectstack/metadata-protocol` as a devDependency, so its `dist/` bundles a full copy of the protocol, `destructiveChangeRemedy` included. The running `protocol` service is registered by `metadata-protocol`'s own plugin, and `rest` never states this face, so the bundled copy's new case is unreachable from `rest`. Observation only. No carrier. - The face-inventory docblock in `protocol.destructive-409-face-inventory.test.ts` still lists the compound-name `PUT /meta/:type/:a/:b` as row 2. That route was retired by commit `7986d973f`. This is pre-existing drift in a test comment and is untouched here. No carrier. - Not measured: the console's import dialog saves its draft through `PUT /meta/object/:name` and sends no `force`, so a console re-import that shrinks an object would get the `meta-envelope` 409, whose `?force=true` the dialog does not offer. That is objectui-side, and it was not reproduced here. - The branch is not merged with `origin/main`. It is 4 commits behind (`2df3d13d..9f9510f`), and none of those commits touches any of this PR's 9 paths (`git diff --stat` over them is empty). `dispatch-gates` reported one stale gate input (`scripts/engine-double-contract.pinned.json`), which concerns fake engines, and this diff adds none. Changeset: `.changeset/21841-import-refusal-working-remedy.md`. `@objectstack/spec` and `@objectstack/metadata-protocol` are `minor` (the widened enum and parameter). `@objectstack/service-datasource` is `patch` (its published exports and types do not move; its import now states a face). --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- 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 #21788
Clause-②: no (narrowing)
What this changes
ExternalDatasourceServicePluginwired the import'spersistObjecttometadata.register('object', name, definition). That held the generated object in the metadata service's memory and did nothing else: nosys_metadatarow, no storage sync, and the SQL driver was never told the object'sexternal.remoteName.persistObjectnow callssaveMetaItemon theprotocolservice, the savePUT /api/v1/meta/object/:namemakes, with the request that door sends for anobject({ type: 'object', name, item }: no organization becauseobjectis not org-overridable, and no package, mode or force because the import route takes none). That save persists the row, writes it through to the engine registry and runssyncObjectSchema, which maps a federated object onto its remote table. There is no second registration path beside it.The save door is looked up when an import runs (a getter on the service config), not at
init(). Theprotocolservice registers in another plugin'sinit(), and a verdict drawn at this plugin'sinit()would be kept for the life of the process. With no save door registered, the service's existing "requires a writable metadata store" refusal still fires before any remote introspection.The import route's request and response shapes are unchanged. No
packages/specedit, and no new export.Measured on the real composition (
pnpm dev, showcase, at this branch)3237b4a2)2beb1441)POST …/tables/customers/import {"name":"ext_cust"}GET /data/ext_custno such table: ext_custPOST …/tables/orders/import {"name":"orders"}, thenGET /data/orderssys_metadatarows forext_cust/ordersactiveGET /data/ext_custand/data/ordersPUT /meta/object/ext_cust_meta(same binding)Tests
packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts(4 cases, relative import, measuressrc/): the import reachessaveMetaItemwith the door's request and never callsmetadata.register; aprotocolregistered afterinit()still receives the save; a refused save refuses the import with the door's own error (codeandstatusasserted); with no save door, the import is refused before introspection.@objectstack/service-datasource: 36 files, 713 tests pass;tsc --noEmitpasses, and its program includes the new test file (--listFiles).9f5279c8(this branch after mergingorigin/main088428fb): all 63 commandsdispatch-gates --commandsderives exit 0, and--ranreconciles 63 of 63 with 0 NOT-MEASURED. Narrowed eslint (--no-inline-config) on the two touched.tsfiles: 2 files, 0 findings. The config enables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file.scripts/ablation-replace.mjswithpersistObjectput back to register-only: the unit file went 3 failed and 1 passed (the no-save-door case is untouched by that mutation, as expected). The booted-stack pin described below went 3 failed and 1 passed, with404 OBJECT_NOT_FOUNDon both reads and after the restart. The restore leg proved the blob equal to HEAD, rebuiltdist/, andablation-dist-preflight --absentwas clean on the whole tree. Both files were 4 of 4 green again.Not in this PR, and why
ExternalDatasourceServicePlugin, so the pin has to import it. Committing that needs@objectstack/service-datasourcedeclared inpackages/qa/dogfood/package.json, plus a source alias inpackages/qa/dogfood/vitest.config.ts, becausecheck:test-source-aliasrefuses a new unaliased dogfood import and its ledger only shrinks. Both are existingpackages/qafiles outside this dispatch's file surface, so the pin waits for the seat. It was run locally through an untrackednode_moduleslink: green on the fix, and red under the ablation above.PUT /meta/object, whose code this PR does not touch. After a restart, Gate 2 (ExternalValidationPlugin) reads a stored federated object with the platform-injected anchors (organization_id,created_by,updated_by,owner_id,owning_business_unit_id) and reports each one as amissing_columnon the remote table. On a datasource with the defaultexternal.validation.onMismatch: 'fail', the boot then aborts with "Object 'NAME' does not match its remote table". This PR makes imports durable, so an import on such a datasource reaches that same abort at the next restart, where it used to vanish. A measured remedy, kept out of this PR's diff: havevalidateObjectskipunprovisionedInjectedColumns(obj)(@objectstack/spec/data). With that, the same database booted and both datasources validatedok: true. The landing order goes back to the seat.409 DESTRUCTIVE_CHANGE, answered as400 EXTERNAL_IMPORT_ERROR, says "re-submit with ?force=true to proceed", and the import route reads noforce. The text comes fromdestructiveChangeRemedyin@objectstack/metadata-protocol, which has no face for this door.Acceptance notes
recorded_by: null. The door records the caller's user id. The import route does not pass its caller toIExternalDatasourceService.importObject, and that contract has no actor parameter.POST …/external/validatedoes not list an object saved through the door, and that now includes an import. The federation service reads objects from the metadata service, which picks upsys_metadataobjects only at boot. Before this change, an import sat in that service's memory, so it was presumably listed until the restart and then disappeared. That half was not measured on the base.ExternalDatasourceServicePluginmounted as an extra plugin reads themetadataservice atinit()and finds none. SopersistCatalog(external_catalog) is never wired there. That decision is recorded atinit(), the same shape this PR removes frompersistObject.persistCatalogis untouched here.Patch round 1 (head 8d640fa)
Appended by the seat (
domain:services#1,session_011K3zqE8Pv1Evw5hc8tZCnN) from the dev's patch-round report5991725015, after seat verdict5990234116. Line 2 was corrected in the same act (the claim amendment is in that verdict).#21841(the import route's?forceprescription) and#21842(the validate door) are not addressed here.Q1: the card's dogfood pin is committed.
packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.tshas three cases:To make that possible:
@objectstack/service-datasourceis declared inpackages/qa/dogfood/package.json;pnpm install. The same run also wrotedeprecated:registry metadata onto twelve@yuku-analyzer/binding-*entries; none of it was hand-edited;packages/qa/dogfood/vitest.config.tsaliases the package to source, in the shapecheck:test-source-aliasreads.No other
packages/qafile is edited. This is declared on #6024 (5990241427).Q2: validation skips the platform's unprovisioned injected anchors.
validateObjectUsing(external-datasource-service.ts) skips the columnsunprovisionedInjectedColumns(obj)names. It reuses the spec's own provenance predicate and builds no second list.PUT /api/v1/meta/object/:name, whichmainhas today.missing_columnaterror. Onpnpm deva default-policy datasource still aborts the boot on that real drift (measured, not a committed booted pin).Q3: declaration.
Clause-②: no (narrowing), BREAKING: some re-imports that answered201now answer400.minor, with the!title, the banner and one measured handling line.not-required (no-migration-prescription), accepted by the gate. No authorable key, export, config field or stored shape moves, so there is nothing formigrate metato rewrite.Measured at
8d640fa2:service-datasource: 37 files, 717 tests pass;tscexit 0.@objectstack/dogfoodtypecheck exit 0.persistObjectback to register-only): the dogfood pin went 3 of 3 red (404 OBJECT_NOT_FOUND). The restore was proven.pnpm dev, a default-policy datasource with one imported and one door-saved object: the restart boots, and validate answersok: true.dispatch-gates: 78 derived, all exit 0.--ran: 78 of 78, 0 NOT-MEASURED.Acceptance notes (added by this round):
persistCatalog(theexternal_catalogsnapshot) still goes throughmetadata.registeronly, decided atinit(). Its durability onpnpm devis NOT MEASURED.recorded_bynull), measured. The import route does not pass its caller toIExternalDatasourceService.importObject, and that contract has no actor parameter.PUT /meta/objectwith the same body does. It was not measured on a compatible body; the same door semantics apply.Generated by Claude Code