Skip to content

fix(service-datasource)!: Import as Object saves through the metadata door's save - #21837

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21788-external-import-saves-like-meta
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21788-external-import-saves-like-meta

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21788
Clause-②: no (narrowing)

What this changes

ExternalDatasourceServicePlugin wired the import's persistObject to metadata.register('object', name, definition). That held the generated object in the metadata service's memory and did nothing else: no sys_metadata row, no storage sync, and the SQL driver was never told the object's external.remoteName. persistObject now calls saveMetaItem on the protocol service, the save PUT /api/v1/meta/object/:name makes, with the request that door sends for an object ({ type: 'object', name, item }: no organization because object is 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 runs syncObjectSchema, 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(). The protocol service registers in another plugin's init(), and a verdict drawn at this plugin's init() 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/spec edit, and no new export.

Measured on the real composition (pnpm dev, showcase, at this branch)

step before (base 3237b4a2) after (2beb1441)
POST …/tables/customers/import {"name":"ext_cust"} 201 201
GET /data/ext_cust 500 DATABASE_ERROR, log no such table: ext_cust 200, the 3 remote rows
POST …/tables/orders/import {"name":"orders"}, then GET /data/orders 201, 200 201, 200
sys_metadata rows for ext_cust / orders none both, env-wide, active
restart on the same DB, GET /data/ext_cust and /data/orders 404 OBJECT_NOT_FOUND both 200 both, with rows
control: PUT /meta/object/ext_cust_meta (same binding) 200, rows, survives restart unchanged

Tests

  • New packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts (4 cases, relative import, measures src/): the import reaches saveMetaItem with the door's request and never calls metadata.register; a protocol registered after init() still receives the save; a refused save refuses the import with the door's own error (code and status asserted); with no save door, the import is refused before introspection.
  • @objectstack/service-datasource: 36 files, 713 tests pass; tsc --noEmit passes, and its program includes the new test file (--listFiles).
  • Gates at 9f5279c8 (this branch after merging origin/main 088428fb): all 63 commands dispatch-gates --commands derives exit 0, and --ran reconciles 63 of 63 with 0 NOT-MEASURED. Narrowed eslint (--no-inline-config) on the two touched .ts files: 2 files, 0 findings. The config enables no type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any untouched file.
  • Ablation, committed fix first, through scripts/ablation-replace.mjs with persistObject put 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, with 404 OBJECT_NOT_FOUND on both reads and after the restart. The restore leg proved the blob equal to HEAD, rebuilt dist/, and ablation-dist-preflight --absent was clean on the whole tree. Both files were 4 of 4 green again.

Not in this PR, and why

  • The card's dogfood pin is written and measured, but not committed. It imports under a different name, reads the rows, restarts on the same DB file, and reads again, with a same-name import as the control. The verify harness does not mount ExternalDatasourceServicePlugin, so the pin has to import it. Committing that needs @objectstack/service-datasource declared in packages/qa/dogfood/package.json, plus a source alias in packages/qa/dogfood/vitest.config.ts, because check:test-source-alias refuses a new unaliased dogfood import and its ledger only shrinks. Both are existing packages/qa files outside this dispatch's file surface, so the pin waits for the seat. It was run locally through an untracked node_modules link: green on the fix, and red under the ablation above.
  • A durable federated object can abort the next boot. This is measured, and it already happens through 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 a missing_column on the remote table. On a datasource with the default external.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: have validateObject skip unprovisionedInjectedColumns(obj) (@objectstack/spec/data). With that, the same database booted and both datasources validated ok: true. The landing order goes back to the seat.
  • A re-import that drops a column is refused with a remedy this route cannot take. The door's destructive-change check now applies: 409 DESTRUCTIVE_CHANGE, answered as 400 EXTERNAL_IMPORT_ERROR, says "re-submit with ?force=true to proceed", and the import route reads no force. The text comes from destructiveChangeRemedy in @objectstack/metadata-protocol, which has no face for this door.

Acceptance notes

  • The history row of an imported object records recorded_by: null. The door records the caller's user id. The import route does not pass its caller to IExternalDatasourceService.importObject, and that contract has no actor parameter.
  • Until a restart, POST …/external/validate does 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 up sys_metadata objects 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.
  • Under the verify harness, ExternalDatasourceServicePlugin mounted as an extra plugin reads the metadata service at init() and finds none. So persistCatalog (external_catalog) is never wired there. That decision is recorded at init(), the same shape this PR removes from persistObject. persistCatalog is untouched here.

Patch round 1 (head 8d640fa)

Appended by the seat (domain:services#1, session_011K3zqE8Pv1Evw5hc8tZCnN) from the dev's patch-round report 5991725015, after seat verdict 5990234116. Line 2 was corrected in the same act (the claim amendment is in that verdict).

#21841 (the import route's ?force prescription) 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.ts has three cases:

  • an import under a name that differs from its remote table serves the remote rows;
  • the same-name control serves its rows;
  • after a cold boot on the same database file, both still serve their rows.

To make that possible:

  • @objectstack/service-datasource is declared in packages/qa/dogfood/package.json;
  • its lockfile importer entry was regenerated by pnpm install. The same run also wrote deprecated: registry metadata onto twelve @yuku-analyzer/binding-* entries; none of it was hand-edited;
  • packages/qa/dogfood/vitest.config.ts aliases the package to source, in the shape check:test-source-alias reads.

No other packages/qa file is edited. This is declared on #6024 (5990241427).

Q2: validation skips the platform's unprovisioned injected anchors.

  • validateObjectUsing (external-datasource-service.ts) skips the columns unprovisionedInjectedColumns(obj) names. It reuses the spec's own provenance predicate and builds no second list.
  • This also closes the boot abort for federated objects saved through PUT /api/v1/meta/object/:name, which main has today.
  • A field an author declares under an anchor's name is still compared.
  • Negative control: a declared column the remote lacks is still missing_column at error. On pnpm dev a 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 answered 201 now answer 400.
  • The changeset is minor, with the ! title, the banner and one measured handling line.
  • ADR-0087: not-required (no-migration-prescription), accepted by the gate. No authorable key, export, config field or stored shape moves, so there is nothing for migrate meta to rewrite.

Measured at 8d640fa2:

  • service-datasource: 37 files, 717 tests pass; tsc exit 0.
  • The new validation pins pass, 4 of 4, including the negative control. The dogfood pin passes, 3 of 3. @objectstack/dogfood typecheck exit 0.
  • Ablation 1 (persistObject back to register-only): the dogfood pin went 3 of 3 red (404 OBJECT_NOT_FOUND). The restore was proven.
  • Ablation 2 (the anchor skip removed): 3 of 4 validation pins red. The restore was proven.
  • pnpm dev, a default-policy datasource with one imported and one door-saved object: the restart boots, and validate answers ok: true.
  • dispatch-gates: 78 derived, all exit 0. --ran: 78 of 78, 0 NOT-MEASURED.

Acceptance notes (added by this round):

  • persistCatalog (the external_catalog snapshot) still goes through metadata.register only, decided at init(). Its durability on pnpm dev is NOT MEASURED.
  • An imported object's history row records no actor (recorded_by null), measured. The import route does not pass its caller to IExternalDatasourceService.importObject, and that contract has no actor parameter.
  • An import named after a packaged object whose fields do not conflict would save an env-wide overlay of it, exactly as PUT /meta/object with the same body does. It was not measured on a compatible body; the same door semantics apply.

Generated by Claude Code

claude added 3 commits October 5, 2026 06:36
…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>
…ternal-import-saves-like-meta

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 5, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/dogfood, @objectstack/service-datasource, touching 6 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/qa/dogfood/package.json, packages/qa/dogfood/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/wire-format.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin), /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/concepts/metadata-lifecycle.mdx (via saveMetaItem (literal, a string literal in MetadataSaveDoor), /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/deployment/production-readiness.mdx (via /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (literal, a string literal in MetadataSaveDoor))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (literal, a string literal in MetadataSaveDoor))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (literal, a string literal in MetadataSaveDoor))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (literal, a string literal in MetadataSaveDoor))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin), /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin), /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/ui/forms.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin), /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))

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

  • content/docs/releases/v17/17-0.mdx (via /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))
  • content/docs/releases/v17/17-1.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin), /meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin))

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/qa/dogfood/package.json, packages/qa/dogfood/vitest.config.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8832655af282e88046dc699747c4930fa94bd361 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0e25d1ccaa9da4d060854507515d3eed26bfdd0d — the merge of head 8d640fa2829a734c2e58ad1a4702c1ec73b7ce49 into base 8832655af282e88046dc699747c4930fa94bd361, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8832655af282e88046dc699747c4930fa94bd361 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 3 commits October 5, 2026 07:44
…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>
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Oct 5, 2026
claude added 3 commits October 5, 2026 07:48
…idation skips injected anchors

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>
@github-actions github-actions Bot added size/l and removed size/m labels Oct 5, 2026
@objectstack-fleet objectstack-fleet Bot changed the title fix(service-datasource): Import as Object saves through the metadata door's save fix(service-datasource)!: Import as Object saves through the metadata door's save Oct 5, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 09:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 09:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 07e933b Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21788-external-import-saves-like-meta branch October 5, 2026 10:07
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants