Skip to content

fix(service-datasource): a destructive re-import's refusal names the remedies that work from the import route - #21874

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21841-import-refusal-working-remedy
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21841-import-refusal-working-remedy

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 fix(service-datasource)!: Import as Object saves through the metadata door's save #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..9f9510f2), 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

claude added 4 commits October 5, 2026 10:38
The door pin follows each remedy a destructive re-import's refusal names.
On this commit the refusal still prescribes `?force=true` on the import
route, which reads none, so the pin is red by design.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…remedies that exist

The external-table import saves through `saveMetaItem`, whose
destructive-change refusal ended "re-submit with ?force=true". The
import route reads no `force`, so following it repeated the refusal.

The import now states `writeFace: 'external-import'` (a new member of
the server-stated `writeFace` enum), and `destructiveChangeRemedy`
renders that face in the existing faces' grammar: name the door, deny
the mechanism, then prescribe. The remedies are importing the table
under a new `name`, or saving the changed definition through
`PUT /api/v1/meta/object/:name?force=true`. The refusal itself stays,
and the 422 clause keeps its full-prose default on this face because
the import route relays the message alone.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
… is the shrunk definition

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 3 package(s): @objectstack/metadata-protocol, @objectstack/service-datasource, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy))
  • content/docs/api/wire-format.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy), /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin; a path literal in a comment on a changed line))
  • content/docs/protocol/diagram.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy), /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin; a path literal in a comment on a changed line))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy), /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin; a path literal in a comment on a changed line))
  • content/docs/ui/forms.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy), /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin; a path literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-1.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy), /api/v1/meta/object/:name (route, a path literal in a comment in ExternalDatasourceServicePlugin; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-5.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/meta/object (route, a path literal in destructiveChangeRemedy))

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

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

Coarse fallback — 139 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 aead296874176b1bcf2f2b8a916223b11cf30c4b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 380422546466f83b7f3df84272f442bb6b38364d — the merge of head 5e5dedaa1356d89a40d25aa7618687b165e6f031 into base aead296874176b1bcf2f2b8a916223b11cf30c4b, 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 380422546466f83b7f3df84272f442bb6b38364d && git checkout 380422546466f83b7f3df84272f442bb6b38364d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aead296874176b1bcf2f2b8a916223b11cf30c4b 5e5dedaa1356d89a40d25aa7618687b165e6f031 && git checkout -B drift-repro aead296874176b1bcf2f2b8a916223b11cf30c4b && git merge --no-ff 5e5dedaa1356d89a40d25aa7618687b165e6f031

node scripts/docs-audit/affected-docs.mjs --json aead296874176b1bcf2f2b8a916223b11cf30c4b

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5e5dedaa1356d89a40d25aa7618687b165e6f031
Local-runs: none

① Derived judgments

  1. SaveMetaItemRequestSchema.writeFace (packages/spec/src/api/protocol.zod.ts) gains the member 'external-import': an additive widening of a published schema's accept-set, nothing accepted before is refused. Right. The spec pin loops all four members and still refuses 'rest'; no committed JSON artefact enumerates the faces (only the generated reference row, regenerated in this diff), and no hand-written page under content/docs/** or skills/** names writeFace or meta-dispatch, so no prose is made false.
  2. saveMetaItem's inline request type on the exported ObjectStackProtocolImplementation (packages/metadata-protocol/src/protocol.ts, the local MetadataWriteFace union) gains the same member: an additive widening of that package's published .d.ts. Right. The two unions are held equal by typecheck, since plugin.ts types its door as a Pick of the spec's MetadataProtocol, so neither member can move alone.
  3. destructiveChangeRemedy gains one case; the three existing arms and the default string are byte-unchanged in the diff, and the face-inventory switch case pins that the other faces did not move. Right, and inside the claim's constraint that the existing faces' text stays.
  4. specValidationFindings (the 422 clause) is not edited; the new member falls to its full-prose default, which is the right polarity because the import route relays sendError(res, 400, 'EXTERNAL_IMPORT_ERROR', message) with no issues channel (packages/rest/src/external-datasource-routes.ts, read at the head). Pinned by the COUPLING case. Right; the validation path service-datasource: POST /external/validate does not see a federated object saved at runtime (through PUT /meta/object or the import) until the next restart #21842 owns is untouched.
  5. The 409 envelope itself does not move: still DESTRUCTIVE_CHANGE at 409 with issues[] at the producer, relayed as 400 EXTERNAL_IMPORT_ERROR by the import route, stored definition unchanged. Pinned at unit level and at the door. Right; the card's refusal-stays constraint holds and nothing overwrites silently.
  6. persistObject in packages/services/service-datasource/src/plugin.ts adds writeFace: 'external-import' to its save, server-stated. The REST import route hands req.body to importObject as opts, and importObject builds item from the draft definition plus name, label and external only, so neither a force nor a writeFace in a wire body reaches the request's top level; the no-smuggle unit case pins the exact key set. Right. The tenantAuthoredWriteRefusal branch keyed on writeFace !== 'package-duplicate' evaluates the same as before for this save, so no other accept-set moves.
  7. No new accepted parameter on the import route; ImportObjectOpts and IExternalDatasourceService.importObject in spec are untouched. Right, and consistent with the earlier ruling recorded above destructiveChangeRemedy (a force threaded only where a twin door already read it; this route has no twin, and the door that does read ?force exists for the same item and is the one the clause names).
  8. Generated artefacts: the one reference row in content/docs/references/api/protocol.mdx is regenerated; Lint & Repo Gates and Check Changeset are green on the head. Right.
  9. The dogfood pin is a new file in packages/qa/dogfood/test/, selected by the isolated project's glob include and served from service-datasource source through the alias that already exists in the dogfood config; it follows both prescribed remedies to 201 and 200, and the old prescription (?force=true on the import) to the byte-identical 400. Dogfood Regression Gate is green on the head. Right.
  10. Check-runs on the head, latest run per name: every check is success; Console Pin Gate and Packed-tarball smoke (opt-in) skipped by design; Auto Label and Check PR Size succeeded on their first run and skipped only on the label-event re-run; Build Docs ran and passed. No path in the file list is a governed surface. origin/main has moved 10 commits past the merge-base 2df3d13d, and the diff over the nine paths between the two is empty.

② Semver level

  • Changeset .changeset/21841-import-refusal-working-remedy.md: @objectstack/spec minor, @objectstack/metadata-protocol minor, @objectstack/service-datasource patch; @objectstack/dogfood is private and owes none. This matches what the diff publishes: the two packages whose published surfaces widen (the schema enum and the exported method's parameter union) take at least minor; service-datasource's exported surface does not move (one argument added inside a closure), so its bug fix takes patch. No skip-changeset label, correctly.
  • The line-start Clause-②: yes (widening) is carried by the PR body and by the changeset body, and the arm matches the diff: an additive member on a published schema, nothing narrowed. The level axis holds (the moved packages graded minor are exactly the widened ones). Not breaking, so no BREAKING banner, migration mapping or ADR-0087 marker is owed, and none is present.

③ Boundary flags

open_questions is empty. Each dev flag, answered:

  • The import route lives in packages/rest/src/external-datasource-routes.ts, not in the service file the claim named: accepted; it relays the message verbatim and needed no edit.
  • Changeset grades: judged right in ②.
  • Three existing unit-test files edited outside packages/qa: inside the claim's tests surface; no packages/qa file was edited, the door pin is a new file as the claim required.
  • Branch behind main: re-measured at this review as 10 commits, with an empty diff over the nine paths; the stale engine-double-contract.pinned.json gate input concerns fake engines, and this diff adds none.
  • Lock queue-timeouts, trailer spelling, worktree removal: operational; the trailer pair is the one AGENTS.md prescribes, and neither the diff, the changeset nor the PR body carries a model identifier.
  • Out-of-scope findings, noted and not filed: (a) the face-inventory docblock still lists as row 2 the compound-name PUT that commit 7986d97 retired, pre-existing drift shared with the docblock above destructiveChangeRemedy; a stale comment, not a reproducible defect, so the acceptance note is the right carrier; (b) @objectstack/rest bundling a devDependency copy of the protocol: observation only, the running protocol service is metadata-protocol's own plugin and rest never states this face; (c) the console's import dialog saving its draft through the meta PUT without offering force: objectui-side and not reproduced, and the envelope prescription on that door is true, so a note is right until it is reproduced. None escalated.
  • No governed surface is touched; the review is owed on the published-schema face (packages/spec/src/**) and on the Clause-② declaration, both judged above.

Implemented-by: claude/issue-21841-import-refusal-working-remedy
Reviewed-by: session_011K3zqE8Pv1Evw5hc8tZCnN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 13:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 13:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit e864db5 Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21841-import-refusal-working-remedy branch October 5, 2026 14:01
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…vice when it is used, so validate and the boot gate compare every federated object (objectstack-ai#21887)

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

## What this changes

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

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

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

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

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

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

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

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

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

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

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

## Tests

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

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

## Gates

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

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

## Docs

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

## Acceptance notes

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants