Skip to content

fix(service-datasource)!: Sync refuses a managed datasource with the existing 400 (ADR-0015 §4.2) - #25167

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24837-managed-datasource-sync
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24837-managed-datasource-sync

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24837

Clause-②: no (narrowing)
Metadata-protocol: none

What changes

ADR-0015 §1 puts a federated object on a datasource whose schemaMode is not managed, and §4.2 allows object.external only there; managed is the spec's default. The Sync doors (list remote tables, draft an object, import one, refresh the catalog) refused only the platform's primary datasource (default, the route-D ruling on #24772). Every other managed datasource answered, including the runtime datasources Setup creates, which are saved with no schemaMode and so are managed (the HotCRM tester's qa_mem listed its tables 200).

Now one guard in the federation service, ExternalDatasourceService.assertFederationSource, runs first in all four Sync methods and refuses, before anything is introspected:

  • default, by name, with its existing message (unchanged);
  • any other datasource that is not a federation source: schemaMode: 'managed' declared, no schemaMode (the default), or no datasource definition at all.

Each refusal is the existing 400 with the code that door already serves: EXTERNAL_DATASOURCE_ERROR, and EXTERNAL_IMPORT_ERROR on the import. The message names ADR-0015's rule and the remedy (sync from a datasource declared external or validate-only). No status code or error code is minted. The governing text is ADR-0015 §1 and §4.2; retiring the rule instead would be an ADR amendment, which this does not take.

Patch round 1 (658d9d0b5) adds one test-fixture line in packages/rest, declared cross-lane on #22648 (6332535245): remote-tables-twin.equivalence.test.ts now declares its fixture datasource schemaMode: 'external', because a datasource with no schemaMode is managed and both Sync twins now refuse it; no packages/rest source line changes.

Where the refusal lives (measured)

The one producer behind every door is ExternalDatasourceService in packages/services/service-datasource/src/external-datasource-service.ts:

door handler producer method
GET /api/v1/datasources/:name/remote-tables service-datasource admin-routes.ts listRemoteTables
POST /api/v1/datasources/:name/object-draft service-datasource admin-routes.ts generateObjectDraft
GET /api/v1/datasources/:name/external/tables packages/rest external-datasource-routes.ts listRemoteTables
POST /api/v1/datasources/:name/external/tables/:remote/draft packages/rest external-datasource-routes.ts generateObjectDraft
POST /api/v1/datasources/:name/external/tables/:remote/import packages/rest external-datasource-routes.ts importObject
POST /api/v1/datasources/:name/external/refresh-catalog packages/rest external-datasource-routes.ts refreshCatalog

os datasource list-tables, os datasource introspect and @objectstack/client reach those HTTP doors. So no packages/rest source line changes. importObject now drafts through a private draftObject (the old generateObjectDraft body, unchanged), so an import is judged once, under its own code. validateObject asks the same predicate (isFederationSource) for its existing "not federated, nothing to validate" skip: no behaviour change there, and what Sync refuses is now exactly what validation skips.

Measured answers on head b4fb8b8fe (demo_ext declares no schemaMode; lake_ext declares external). The four packages/rest rows came from an uncommitted scratch harness built from remote-tables-twin.equivalence.test.ts's fixture, deleted after the run. The final head, 145e52859, only replaces the guard's code parameter with a door kind, so that check:dispatcher-error-vocabulary reads both codes as literals; the codes per door are pinned in service-datasource.

door managed (demo_ext) external (lake_ext)
GET …/external/tables 400 EXTERNAL_DATASOURCE_ERROR 200
POST …/external/tables/customers/draft 400 EXTERNAL_DATASOURCE_ERROR 200
POST …/external/tables/customers/import 400 EXTERNAL_IMPORT_ERROR 201
POST …/external/refresh-catalog 400 EXTERNAL_DATASOURCE_ERROR 200
GET …/remote-tables (admin) 400 EXTERNAL_DATASOURCE_ERROR 200
POST …/object-draft (admin) 400 EXTERNAL_DATASOURCE_ERROR 200

Readings taken within the ruling (each stated, none new)

  1. The import door keeps EXTERNAL_IMPORT_ERROR. The card says "the existing 400 EXTERNAL_DATASOURCE_ERROR" for Sync's routes. The import route's catch serves 400 EXTERNAL_IMPORT_ERROR for every refusal, whatever the thrown code, and default's refusal already declares that code there. So the import answers its existing code; nothing is minted or moved.
  2. refresh-catalog is refused too. It is the fourth method behind the same producer, and the default refusal already covered it. One guard, one fact, so no door stays open.
  3. A name with no datasource definition is refused. Read with the spec default, an undefined definition has no declared schemaMode, so it is managed. It is also how validation already read it ("not federated"), so an import can no longer bind an object the sweep never checks. Its message says "has no datasource definition".

Pins reversed (not deleted)

  • primary-datasource-test-and-sync.test.ts › "a managed runtime datasource tests and syncs exactly as before" (remote-tables 200 on a managed runtime SQLite datasource) is now "a managed runtime datasource tests exactly as before, and its Sync refuses". The Test half is unchanged.
  • primary-datasource-test-and-sync.test.ts › "Sync on a driver that cannot introspect still refuses with the engine reason": the managed memory datasource now gets the managed refusal (asserted), and the engine reason is asserted on a federated memory datasource (stream). The harness now has the 'metadata' service the plugin reads, serving the same records the admin list reads.
  • external-metadata-read-at-use.test.ts › "validate and the sweep answer an empty report, the draft a bare name, the catalog an unpersisted snapshot" (the draft, refresh and listing answering with no datasource definition readable) is now "validate and the sweep answer an empty report, and the Sync doors refuse an undeclared datasource". The validate half is unchanged.

New: managed-datasource-sync-refusal.test.ts, over the real composition (real ObjectQL, connection service, driver factory, ExternalDatasourceServicePlugin wiring, admin routes on Hono). It covers each door refusing warehouse (no schemaMode), ledger (managed) and qa_mem (runtime memory) with code + status and nothing saved or introspected; a name with no definition; lake (external) and mirror (validate-only) still listing, drafting, importing and refreshing; default unchanged; and Setup's list (schemaMode: 'managed' for the tester's runtime datasources) agreeing with the refusal row by row.

Known gap: one packages/rest fixture line needs the seat's declaration first (closed)

Closed in patch round 1 (658d9d0b5): the seat declared the edit on #22648 (6332535245), the line below is committed, and Test Core is green on that head. The paragraph below is the record of the gap as it stood on 145e52859.

packages/rest/src/remote-tables-twin.equivalence.test.ts builds its datasource as { name }. That is managed by default, so 5 of its cases now go red (Test Core): both twins answer the refusal where the cases expect 200. It is an under-declared fixture, not a managed-Sync pin: the twin-equivalence cases need a federation source to compare listings. The fix is one line, getDatasource: async (name: string) => ({ name, schemaMode: 'external' as const }). Probed uncommitted on 145e52859 through scripts/ablation-replace.mjs: with that line the file passes 10 of 10, and the tool restored the file (blob equal to HEAD, git diff HEAD empty). This PR does not make the edit: packages/rest is domain:cli's, and the dispatch requires a seat declaration before an edit there.

Tests

All on the final head 145e52859, under the shared verify lock:

  • pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2: Test Files 54 passed (54), Tests 945 passed (945).
  • pnpm --filter @objectstack/service-datasource typecheck: exit 0. tsc --listFiles includes all three touched test files.
  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/remote-tables-twin.equivalence.test.ts src/external-datasource-validate-scope.test.ts src/external-datasource-envelope.conformance.test.ts: 5 failed, 27 passed. All 5 failures are the twin-fixture cases named above.
  • The federation dogfood files (external-import-saves-like-meta, external-import-code-datasource-namespace, external-import-destructive-remedy, external-validate-sees-runtime-save, external-validate-start-ordering) on the booted showcase, whose showcase_external declares schemaMode: 'external': 5 files, 20 tests passed.
  • Full dogfood battery (the title carries !): the dependency closure was built, then pnpm --filter @objectstack/dogfood test ran under the lock with no shard and no path. VERDICT command-exit 0 · Test Files 287 passed | 3 skipped (290) · Tests 2395 passed | 18 skipped (2413).

Ablation (one-off, not kept). The fix was committed first. Then, through scripts/ablation-replace.mjs, the guard's managed line on 145e52859 was replaced with a no-op (anchor hit 1 time, blob ad6c9f16563f became a2e8fe2b78ac) and the three files were run: 13 failed, 19 passed. Every managed cell, the no-definition cells, the row-by-row list agreement and the three reversed pins turned red. default's refusal and the federated cells stayed green. The tool restored the file (blob back to ad6c9f16563f, equal to HEAD; git diff HEAD empty). The subject is imported by relative path from src/, so no dist/ leg applies. A first ablation on b4fb8b8fe gave the same 13 and 19.

Gates. The derived list on 145e52859 was node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 68 commands. All 68 exited 0, each code captured before any pipe. --ran reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. The no-major level axis was also run against this body as the event payload: LEVEL AXIS: … clause-② no (narrowing) and metadata-protocol none … a non-protocol change at patch, exit 0. Every other family is CI's, on this PR's check runs.

Patch round 1, on 658d9d0b5 (under the verify lock, dependency closures rebuilt): remote-tables-twin.equivalence.test.ts 10 passed (10), the 5 cases red on 145e52859 now green; the three service-datasource test files 32 passed (32); the 68 derived gates re-run and reconciled (68 derived, 68 run, 0 NOT-MEASURED). The full dogfood battery above stands for this head: the round changes one test-fixture line in packages/rest and no runtime code, and CI's Dogfood Regression Gate is green on 658d9d0b5.

Acceptance notes

  • objectui still offers Sync on managed rows. At objectui origin/main 56cf4d8, DatasourceResourcePage.tsx renders the "Sync objects" button on every row, whatever its schemaMode (which is already in the list payload). After this PR, the button on a managed row opens the dialog with the refusal message (objectui#13207 draws refusals there). Not changed here; reported to the seat for an objectui card. Carrier: an objectui card the seat files when this PR lands.
  • ADR-0015 §4.2's "enforced at metadata-load time" has no enforcing site. defineStack() on the built spec, at b4fb8b8fe, accepted an object with external: { remoteName: 'orders' } bound to a schemaMode: 'managed' datasource. This PR closes the Sync producer of such objects only. Reported to the seat; not fixed here. Carrier: ADR-0015 §4.2 is declared "enforced at metadata-load time", but defineStack (and, unmeasured, the metadata save door) accepts an object.external binding on a datasource whose schemaMode is managed #25168, filed for triage.
  • No ADR anchor file is added for the guard. scripts/adr-anchors/ is outside the claim's declared file surface; the ADR id is in the code and in the refusal text. The contract review (6333095709) found it not blocking (check:adr-anchors is green and no ADR-0015 anchor names this service) and named it a lane follow-up. Carrier: none (no user-visible defect).
  • Pins for the packages/rest doors (draft, import, refresh) belong in packages/rest. They were measured once here (table above) and not committed. The contract review (6333095709) found it not blocking (each REST catch maps every service throw to that door's fixed code) and named a packages/rest pin of the four doors as a domain:cli follow-up. Carrier: none (no user-visible defect).

Generated by Claude Code

…wo managed-Sync pins

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…tch, Metadata-protocol: none)

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
… (door kind, not a code parameter)

check:dispatcher-error-vocabulary reported both refusal helpers as
unresolved once the shared guard forwarded a code variable to them.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-datasource, touching 19 documentable anchor(s).

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

  • content/docs/releases/v17/17-2.mdx (via datasources.external.draft (sdk, the route ledger binds it to POST /api/v1/datasources/:name/external/tables/:remote/draft, selected by route anchor /tables/:remote/draft), datasources.external.import (sdk, the route ledger binds it to POST /api/v1/datasources/:name/external/tables/:remote/import, selected by route anchor /tables/:remote/import), /tables/:remote/draft (route, bridged from symbol generateObjectDraft — its route source's handler names it), /tables/:remote/import (route, bridged from symbol importObject — its route source's handler names it))
  • content/docs/releases/v17/17-7.mdx (via EXTERNAL_IMPORT_ERROR (literal, a string literal in importObject; a string literal in notAFederationSourceError; a string literal in primaryDatasourceNotFederatedError))

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 — 2 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 425c743fd9b80005d83e528dfdc8a5f8c68c8b30 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 425c743fd9b80005d83e528dfdc8a5f8c68c8b30

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

…e 'external'

The fixture datasource declared no schemaMode, so it is managed by the
spec default and both Sync twins now refuse it (ADR-0015 §4.2). The
twin-equivalence cases compare listings of a federation source.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 658d9d0b54fb68201710b21904e6adf9e8b03159
Local-runs: none
Scope: packages/services/service-datasource/src/external-datasource-service.ts, packages/services/service-datasource/src/tests/managed-datasource-sync-refusal.test.ts, packages/services/service-datasource/src/tests/primary-datasource-test-and-sync.test.ts, packages/services/service-datasource/src/tests/external-metadata-read-at-use.test.ts, packages/rest/src/remote-tables-twin.equivalence.test.ts + packages/rest/src/external-datasource-routes.ts, packages/services/service-datasource/src/admin-routes.ts, packages/services/service-datasource/src/plugin.ts, packages/spec/src/data/datasource.zod.ts, packages/spec/src/data/object.zod.ts, docs/adr/0015-external-datasource-federation.md

Inputs: card #24837 (body and comments 6331033735, 6332488694, 6332519276, 6332527785), PR #25167 (body, file list, the net diff of this head against main: 6 files, +550/-53), and the check-runs on this head. The five required contexts are success on this head (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard); Check Changeset and Check PR Size are success; the PR-run Dogfood Regression Gate is success (fast set; its shards skipped by path). Nothing was built, run or re-run locally.

① Derived judgments

(a) The accept set of the six Sync doors, each read to its producer and its catch — right. The one producer is ExternalDatasourceService; the new private assertFederationSource(datasource, door) is the first statement of listRemoteTables, generateObjectDraft, importObject and refreshCatalog. It refuses, in this order: default by name (primaryDatasourceNotFederatedError, unchanged text), then anything isFederationSource(ds) rejects, where isFederationSource is Boolean(ds?.schemaMode) && ds?.schemaMode !== 'managed' over config.getDatasource(name). That rejects exactly three shapes: a declared schemaMode: 'managed', a definition with no schemaMode, and no definition at all (undefined); with SchemaModeSchema closed to managed | external | validate-only, nothing else is refused. Codes and statuses per door, read in the cited code:

  • admin GET /datasources/:name/remote-tables and POST /datasources/:name/object-draft (admin-routes.ts): the handler calls the service inside try, and badRequest answers 400 with SERVICE_ERROR_CODE['external-datasource'] = EXTERNAL_DATASOURCE_ERROR for every throw except a 503 SERVICE_UNAVAILABLE relay (the guard throws status: 400, so the relay arm is not taken). Right.
  • REST GET …/external/tables, POST …/external/tables/:remote/draft, POST …/external/refresh-catalog (external-datasource-routes.ts): the catch is refused, i.e. answerThrown(res, err, 'EXTERNAL_DATASOURCE_ERROR'), which answers 400 with that literal code for any non-sandbox throw. Right.
  • REST POST …/external/tables/:remote/import: the catch is answerThrown(res, err, 'EXTERNAL_IMPORT_ERROR'), 400 with that code for every refusal — the code that door already served for default's refusal and for every other refusal it relays. The guard's door: 'import' spells the same code at the throw, so the declaration and the served envelope agree. Right, and the card's literal "EXTERNAL_DATASOURCE_ERROR for Sync's three routes" is correctly read as "the code each door already serves": no code is minted or moved, and check:dispatcher-error-vocabulary reads both literals (green in Lint & Repo Gates).
  • A name with no definition answers 400, not 404: on these doors an unknown name already answered 400 EXTERNAL_DATASOURCE_ERROR (the introspector's "no driver introspection available" throw, through the same catch), so 400 is the existing answer and a 404 would have been a new code on the door. Right under the card's constraint.

(b) external and validate-only keep every answer — right. isFederationSource admits both. listRemoteTables reads the same definition it read before (now from the guard's return instead of Promise.all, a serialisation with no answer change) and keeps the allowedSchemas and unattributed-schema logic byte for byte; generateObjectDraft delegates to the private draftObject, which is the old body unchanged; importObject keeps its persistObject, name and prefix checks and now calls draftObject instead of re-entering generateObjectDraft (one judgment per request, under the import's own code); refreshCatalog is the old body after the guard. testConnection does not call the guard (Test is unchanged). The pins hold lake (external) and mirror (validate-only) at 200 list, 200 draft, an import that saves one object, and a refresh, over the real ObjectQL, connection service, driver factory, plugin wiring and Hono admin routes; the twin fixture now declares schemaMode: 'external' and its 10 cases compare both spellings on a federation source, which is what twin equivalence needs.

(c) default's existing refusal — unchanged. The guard's first line is the DEFAULT_DATASOURCE_NAME check with the same helper and the same message; it still precedes any definition read. The door-kind parameter yields EXTERNAL_IMPORT_ERROR on the import and EXTERNAL_DATASOURCE_ERROR elsewhere, the two literals main passed in by hand. Pinned ("remote-tables on default answers as #24772 left it").

(d) Decided before any introspection — right. All four methods await the guard before config.introspect; main's listRemoteTables ran introspect and getDatasource concurrently, so a refused datasource WAS introspected there, and is not now. The REST and admin handlers call nothing but the admission gate before the service. Pinned twice: introspectSchema spies on every managed driver and the primary one stay uncalled across all four doors, and introspectDatasource is uncalled for an undeclared name.

(e) The seat's answer A (refuse a name with no definition) — consistent, a narrowing within ADR-0015, not a widening beyond it. The spec default is schemaMode: SchemaModeSchema.default('managed') (datasource.zod.ts), so a definition without the key is managed; ADR-0015 §1 makes remote-ness a property the datasource DECLARES (schemaMode !== 'managed'), and §4.2 allows object.external only there — a name nothing declares has declared no such thing. validateObject on main already read !ds as "not federated, nothing to validate", so before this PR an import on such a name produced an object the sweep never checked and the §5.3 write gate (which exempts managed datasources) never gated; A closes that with the one predicate both now ask. The composition cost is the hypothetical one the dev named: a host whose live drivers have no datasource record in the 'metadata' service. plugin.ts wires getDatasource to metadata().get('datasource', n) resolved per call, and the dev's federation dogfood (the booted showcase's code-declared showcase_external, 20 tests) and the full battery passed on 145e52859, which is the evidence that the shipped composition does read code-declared datasources through that door. The reversed pin in external-metadata-read-at-use is the honest record of the change and keeps the validate half.

Public-surface changes implied by the diff, each named: the four REST doors' and two admin doors' accept set narrows on the three shapes above (right, the card's route B); validateObject / validateDatasource / validateAll semantics unchanged (!ds || !ds.schemaMode || ds.schemaMode === 'managed' is exactly !isFederationSource(ds)); no export, type, route, query parameter, status or code is added or removed; packages/rest source is untouched; packages/spec, packages/objectql, objectui and ADR-0015 are untouched, as the claim required.

② Semver level

Right. The changeset is '@objectstack/service-datasource': patch with Clause-②: no (narrowing) and Metadata-protocol: none on their own line-initial lines, a **BREAKING** banner, FROM / TO / Unchanged / fix-for-an-integrator lines, and the adr-0087 marker not-required (no-migration-prescription); the PR body carries the same two declaration lines and the title carries !. Metadata-protocol: none is the correct kind: no key, accepted value, default or meaning that an author writes moves (the datasource schema and the object schema are untouched; the authoring door for object.external on a managed datasource is deliberately not narrowed here, see ③). A non-protocol breaking change ships patch with its banner and disposition (AGENTS.md Post-Task Checklist step 3, the closed card #25105's ruling A as the brief states it). Check Changeset is success on this head, and the dev reports the level axis and check-adr-0087-registration exit 0 on the body.

The (narrowing) arm's full dogfood battery: run by the dev on 145e52859 (VERDICT command-exit 0, Test Files 287 passed, 3 skipped; Tests 2395 passed, 18 skipped). This head is 145e52859 plus exactly one hunk: one line in packages/rest/src/remote-tables-twin.equivalence.test.ts (the fixture's schemaMode: 'external' as const). That file is a vitest file, not a build input (packages/rest publishes dist only; nothing outside it imports it), and the dogfood battery is @objectstack/dogfood's own suite over a booted app, so none of the battery's inputs moved between 145e52859 and this head. The battery stands for this head. The PR-run Dogfood Regression Gate on this head is success (the fast set, as PR runs carry); the full battery runs on main after landing.

③ Boundary flags

  1. Console still offers "Sync objects" on managed rows (PR Acceptance notes; dev finding 1). Routed by the seat: an objectui card is filed when this PR lands (6332519276, item 1). Until then the button on a managed row opens the dialog with this refusal's message, which names the rule and the remedy, so the console is not silently broken. Not this PR's.
  2. ADR-0015 §4.2's "enforced at metadata-load time" has no enforcing site: defineStack and the metadata save door accept object.external on a managed datasource (dev finding 2). Routed by the seat as ADR-0015 §4.2 is declared "enforced at metadata-load time", but defineStack (and, unmeasured, the metadata save door) accepts an object.external binding on a datasource whose schemaMode is managed #25168 for triage (spec or objectql lane). This PR closes the Sync producer of such objects only, and says so; retiring or amending the rule would be an ADR change this PR correctly does not take.
  3. No ADR anchor JSON for the guard (scripts/adr-anchors/). Answered, not blocking: the ADR id is in the code and the refusal text, check:adr-anchors is green, and no anchor for ADR-0015 names this service today (the only anchor citing 0015 is packages__objectql__src__search-companion.ts.json), so the omission adds no drift. Escalated to the seat as a lane follow-up: anchor assertFederationSource under Prime Directive 13's corollary when the file surface is declared.
  4. REST-door refusal pins (draft, import, refresh on a managed datasource) measured once, not committed (they belong in packages/rest, domain:cli). Answered, not blocking: the REST catches map every service throw to a fixed 400 code per door by construction (read above), and the service-level pins hold each method's code and status; the twin test pins the external list path at 200 on both spellings. Escalated to the seat: a packages/rest pin of the four doors refusing a managed datasource with their codes, alongside the twin file, when the lane declares it.
  5. The import door answers EXTERNAL_IMPORT_ERROR, and refresh-catalog is refused too (dev deviations, accepted by the seat in 6332519276). Both right: see ①(a); the fourth method sits behind the same producer and default's refusal already covered it.
  6. origin/main not merged before opening; first commit titled wip(…). main has moved by docs and spec-prose commits with no overlap with the touched packages; the PR is mergeable clean, and the queue rebuilds the merge. The squash landing takes the PR title. No action.

Implemented-by: claude/issue-24837-managed-datasource-sync
Reviewed-by: session_01UnHcTADjeg3VwxF21QHj7W

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T06:00Z · ACCEPT at head 658d9d0b54fb68201710b21904e6adf9e8b03159 · dev reports on #24837 (round 1 6332488694, patch round 6333805584)

  1. Shape: a draft PR to main whose first line is Fixes #24837, assigned os-bill, title marked !.
  2. Premises measured (round 1, re-read on the patch head):
    • the Sync doors refused only default, and a runtime datasource saved in Setup with no schemaMode (managed by the spec's default) listed, drafted and imported;
    • the seat's answers 6332519276 hold in the code: a name with no definition is refused (answer A), the import keeps EXTERNAL_IMPORT_ERROR, and the catalog refresh sits behind the same guard.
  3. Gates (check-runs pinned to this head, the newest run of each name):
    • 32 success, including Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate and Test Core 1–6.
    • 9 skipped, each on the check-expected-skips.mjs roster. Auto Label and Check PR Size skip on the run the seat's body edit started and were success on the push run, as the roster records for an edited body.
    • mergeable_state: clean. check-governed-merges.mjs --pr 25167: NOT governed, +550 / −53.
    • Contract review at CONTRACT_REVIEW_TIER (isolated reviewer): PASS, 6333095709, on this head. It confirmed:
      • the six doors refuse exactly a declared managed datasource, one with no schemaMode and a name with no definition, each with the 400 code that door already served, and no code is minted;
      • external and validate-only keep every answer, and default's refusal is unchanged;
      • the refusal is decided before any introspection (main introspected a refused datasource concurrently);
      • answer A is a narrowing within ADR-0015, not beyond it;
      • the full dogfood battery run on 145e52859 stands for this head, whose one added line is a test fixture no battery input reads.
  4. Seat read of the diff (beyond the review): the changeset's remedy for a runtime datasource holds in the code. PATCH /datasources/:name accepts schemaMode and external (RECORD_JUDGED_PATCH_KEYS), the Setup door stores the record with metadata.register('datasource', …), and the guard reads metadata.get('datasource', …), which is the same store. So a datasource re-declared external is admitted on its next Sync.
  5. Accepted deviations: origin/main was not merged before opening, and the first commit is titled wip(…). The PR is clean, and the queue landing takes the PR title as the commit subject. The net diff is unaffected.
  6. Out of scope (all in the PR's Acceptance notes, with carriers):

Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #24837 closes by Fixes; the seat removes pm:dispatched, files the objectui card and records the landing.

Changeset sentences checked against the diff:

  • FROM: the six requests on a managed datasource answered with tables, drafts, a saved import and a stored catalog, and an undeclared name was introspected;
  • TO: each answers the door's existing 400 code with the rule and the remedy, nothing is introspected, drafted, saved or stored, and an undeclared name gets the same refusal;
  • Unchanged: default's refusal, the two federation modes, Test and validation (validation now asks the same predicate), and no code added;
  • the fix for an integrator: declare external or validate-only, by PATCH for a runtime datasource.

All hold.


Generated by Claude Code

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/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants