Skip to content

fix(service-datasource)!: the datasource admin API refuses to make a datasource managed while objects bind it with external - #25431

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-29nfa9
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-29nfa9

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Fixes #25277

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

The datasource admin API (POST /api/v1/datasources, PATCH /api/v1/datasources/NAME) no longer stores a datasource that is managed while objects bind it with external. createDatasource and updateDatasource now judge the record they will persist against the objects bound to it, through the spec predicate externalBindingDatasourceRefusal (the one copy of the ADR-0015 §4.2 rule the metadata save door asks), before any secret or record write.

The defect, measured on base f0fdd45bb

  • The metadata save door refuses a datasource saved as managed (or with no schemaMode, the spec default) while objects bind it with external (externalBindingSaveRefusal, packages/metadata-protocol/src/protocol.ts:22675, datasource half :22692).
  • createDatasource (packages/services/service-datasource/src/datasource-admin-service.ts:608) and updateDatasource (:654) write through putDatasourceRecord (datasource-admin-plugin.ts:418: metadata.register plus a direct sys_metadata insert/update). Neither enters saveMetaItem, so the pair the save door refuses was stored: on create (objects can name a datasource before it exists, and the save door gives no verdict on a binding whose datasource it cannot see) and on a PATCH that sets schemaMode: 'managed'.

What changed

  • DatasourceAdminService (datasource-admin-service.ts): a private boundExternalRefusal(record) asks the predicate once per object that names the datasource, and counts only its managed verdict, exactly as the save door's datasource half does. One line per bound object, each schemaMode: plus the predicate's own message, under the door's Invalid datasource 'NAME'. heading. It is thrown as the door's other record refusals are, so the route answers 400 DATASOURCE_ADMIN_ERROR (the code this service already uses; no new error code).
    • createDatasource: judged right after the existing record judgement, before writeSecret and putDatasourceRecord.
    • updateDatasource: the merged record, under the condition the record judgement already uses (a supplied secret or a patched driver / schemaMode / config / external / pool). A label / active-only write is not judged, so a row that already pairs badly can still be renamed or taken out of service.
  • DatasourceAdminServiceConfig gains one required member, listObjectsBoundTo(datasource): the objects that name the datasource, with their external binding. Required because it is the verdict's input; an optional seam would let a host store the very pair this closes.
  • DatasourceAdminServicePlugin (datasource-admin-plugin.ts) answers it from the engine's object registry (the objectql service's registry), the registry the save door writes through to, so an object saved through PUT /api/v1/meta/object/NAME or imported from a remote table counts at once. Not the metadata service's listObjects(), which holds a boot-time copy that a runtime save never reaches (the reading plugin.ts records for the federation service).
  • The existing DatasourceAdminServiceConfig fakes in this package's tests gain listObjectsBoundTo: async () => [] (8 files, 10 fakes, no assertion moved).

Contract

Every input whose answer changes, old to new:

  1. POST /datasources, schemaMode: 'managed' or none, and an object in the engine registry names that datasource with external. Old: 201, the record stored and registered, a supplied secret minted, the pool registered. New: 400 DATASOURCE_ADMIN_ERROR naming each such object; nothing stored, registered or minted.
  2. PATCH /datasources/NAME that sets schemaMode: 'managed' on a federated datasource such objects bind. Old: 200, the row flipped, the pool rebuilt. New: the same 400; the row, its secret and its pool untouched.
  3. PATCH of a judged key (or with a secret) on a datasource that is already managed and already bound external. Old: 200. New: the same 400. The fix it names: declare the datasource external / validate-only, or remove external from the objects.

Unchanged: a federated datasource (schemaMode: 'external' or 'validate-only') and a datasource no object binds with external keep their answers (pinned on both doors); objects on the datasource with no external binding, and objects bound external to another datasource, are never in the verdict (pinned); a label / active-only PATCH is applied (pinned); testConnection, removeDatasource and migrateCredential are not touched (none of them changes schemaMode).

Pins

  • src/__tests__/datasource-admin-external-binding.test.ts (new, 11 cases): through the real routes over the real service. Each refusal asserts status, error.code, the predicate's own message per bound object (derived from externalBindingDatasourceRefusal, never restated) and the store after (no record, no secret, no put / registerPool / reregisterPool call). Controls: no external binding, federated external and validate-only on create, validate-only on update, managed with no external binding, label/active-only.
  • src/__tests__/datasource-admin-plugin.test.ts (3 cases added): the plugin's seam reads the objectql registry while the metadata service's listObjects() is empty, so a runtime-saved object blocks create and the flip to managed; an object with no external binding does not.

Ablation (scripts/ablation-replace.mjs, anchor if (findings.length === 0) return undefined; replaced by return undefined;, landed 1 to 0, restored to the HEAD blob 1c8772e150d7 with git diff HEAD empty): the two pin files went 7 failed | 35 passed. The 7 red are exactly the refusal pins (5 door, 2 plugin); every control stayed green.

Local verification

On ab44e1bd, over origin/main f0fdd45bb. Heavy runs went through the verify lock; each line cites its VERDICT.

  • pnpm exec turbo run build --filter='@objectstack/service-datasource^...': VERDICT command-exit 0.
  • @objectstack/service-datasource suite (vitest run): VERDICT command-exit 0; Test Files 56 passed (56); Tests 969 passed (969).
  • pnpm --filter @objectstack/service-datasource typecheck: VERDICT command-exit 0.
  • eslint --no-inline-config on the changed .ts files: exit 0. check-nul-bytes: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands: 68 families derived, each run, every one exit 0 (check:type-check-debt under the lock; the build-reading families after the dogfood closure build).
  • --ran reconciliation: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).
  • check-adr-0087-registration --base origin/main (in the derived set, on the committed head): exit 0 with the changeset's not-required (already-registered object-external-binding-managed-datasource-refused) disposition.
  • The full dogfood battery is owed once on the final head (the ! and the (narrowing) arm); its counts follow as a PR comment.

Acceptance notes

  • The bound-object count behind removeDatasource reads the boot-time copy. countBoundObjects / countBoundObjectsDiagnosed (datasource-admin-plugin.ts:461, :486) count from metadata.listObjects(), the copy plugin.ts records as taken once at boot and never reached by a runtime save. So an object imported or saved at runtime is not counted, and the removal guard can read 0 for a datasource such an object binds until the next restart. Read from code and from that recorded reading; not reproduced at the public door here, so not filed. This PR leaves the count alone (its own card's scope); the new seam reads the registry. Carrier: none.
  • Branch name. This PR's branch is claude/project-thread-29nfa9, the name the session was given, not claude/issue-25277-…; the Duplicate Fix Guard may say so. The claim (comment 6365222748) names it.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AkGUa1szqpcew6SVTijZsv

…under objects bound external

createDatasource and updateDatasource judge the record they will persist
against the objects that bind it with external, through the spec predicate
externalBindingDatasourceRefusal, before any secret or record write.

Claude-Session: https://claude.ai/code/session_01AkGUa1szqpcew6SVTijZsv
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot requested a review from os-sales October 23, 2026 13:18
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

5 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 DatasourceAdminServicePlugin))
  • content/docs/data-modeling/external-datasources.mdx (via DatasourceAdminServicePlugin (symbol, a top-level class), /api/v1/meta/datasource (route, a path literal in a comment in DatasourceAdminService))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in DatasourceAdminServicePlugin))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in DatasourceAdminServicePlugin))
  • content/docs/ui/forms.mdx (via /api/v1/meta/object/:name (route, a path literal in a comment in DatasourceAdminServicePlugin))

⛔ 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/:name (route, a path literal in a comment in DatasourceAdminServicePlugin))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/meta/datasource (route, a path literal in a comment in DatasourceAdminService))
  • content/docs/releases/v17/17-7.mdx (via /api/v1/meta/datasource (route, a path literal in a comment in DatasourceAdminService), /api/v1/meta/object/:name (route, a path literal in a comment in DatasourceAdminServicePlugin))

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 77392e901cce58316e803144971f2741ef049fc4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 77392e901cce58316e803144971f2741ef049fc4

⚠️ 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 77392e901cce58316e803144971f2741ef049fc4 → 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

Full dogfood battery on the final head ab44e1bd (owed by the ! and the (narrowing) arm), run as CI runs it: the closure built first (pnpm exec turbo run build --filter='@objectstack/dogfood^...', VERDICT command-exit 0), then pnpm --filter @objectstack/dogfood test under the verify lock, no shard, no path.

  • os-verify-lock: VERDICT command-exit 0 (held the lock 1231s)
  • Test Files 290 passed | 3 skipped (293)
  • Tests 2494 passed | 18 skipped (2512)
  • Failures: none.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ab44e1bdb35bf4bbff5ae29bacf3c8a5f8e4ff7d
Local-runs: none
Scope: packages/services/service-datasource/src/datasource-admin-service.ts, packages/services/service-datasource/src/datasource-admin-plugin.ts, packages/services/service-datasource/src/tests/datasource-admin-external-binding.test.ts, packages/services/service-datasource/src/tests/datasource-admin-plugin.test.ts, packages/services/service-datasource/src/tests/datasource-admin-record-judgement.test.ts, packages/services/service-datasource/src/tests/datasource-admin-service.test.ts, packages/services/service-datasource/src/tests/datasource-cluster-convergence.test.ts, packages/services/service-datasource/src/tests/datasource-config-redaction.test.ts, packages/services/service-datasource/src/tests/datasource-pool-support.test.ts, packages/services/service-datasource/src/tests/datasource-removal-bound-count-outage.test.ts, packages/services/service-datasource/src/tests/datasource-update-rebuild.test.ts, packages/services/service-datasource/src/tests/turso-bound-secret-authoring.test.ts + packages/spec/src/data/external-binding-datasource.ts, packages/metadata-protocol/src/protocol.ts (externalBindingSaveRefusal :22675, objectsBoundExternallyTo :22733), packages/services/service-datasource/src/admin-routes.ts (badRequest :635, SERVICE_ERROR_CODE :73), packages/services/service-datasource/src/index.ts, packages/services/service-datasource/src/plugin.ts (:119–:141), packages/spec/src/contracts/objectql-engine.ts (getAllObjects :94), packages/objectql/src/plugin.ts (subscribeMetadataRebind :590, followRuntimeObjectCopy :2705), packages/metadata-core/src/code-artifact-provenance.ts (isTenantAuthored :43), .changeset/25278-restore-doors-save-refusal-family.md, .changeset/25168-external-binding-managed-datasource-refused.md, content/docs/data-modeling/external-datasources.mdx (:92–:101)

Inputs read: card #25277 (body, claim 6365222748, os-dev-report), PR #25431 (body, 13-file list, net diff 77392e901...ab44e1bd), the 40 check-runs on ab44e1bd — every one success or skipped (the two Test Core shards that were in_progress at the first read concluded success before this record was written). No worktree, checkout, build or test; the ADR-0087 gate was invoked once with --help to read its usage and refused before any verdict for lack of tsx, so no gate ran.

① Derived judgments

The narrowing is declared of the whole PR; each accept-set or public-surface change the diff implies, judged in the cited code:

  1. POST /api/v1/datasources with schemaMode: 'managed' or none, while an object in the engine registry names the datasource with external — was 201 (stored, registered, secret minted, pool registered); now 400 DATASOURCE_ADMIN_ERROR, nothing stored, registered or minted. Right. createDatasource judges record right after assertValidRecord and before writeSecret / putDatasourceRecord (datasource-admin-service.ts:651–:662); toRecord carries schemaMode through (:1158), so an omitted schemaMode reaches the predicate as the spec default. Pinned through the real route (datasource-admin-external-binding.test.ts: status, error.code, the predicate's own message per bound object, records.size === 0, secrets.size === 0, ops === []).
  2. PATCH /api/v1/datasources/NAME that sets schemaMode: 'managed' on a federated datasource such objects bind — was 200; now the same 400, row, secret and pool untouched. Right. Judged on merged inside the existing RECORD_JUDGED_PATCH_KEYS / secret condition (:733–:739), before writeSecret (:744) and putDatasourceRecord (:776). Pinned (record equal to before, ops === [], with a supplied secret too).
  3. PATCH of a judged key (driver / schemaMode / config / external / pool) or with a secret, on a datasource already managed and already bound external — was 200; now 400. Right, and a third narrowed set the card did not spell out: it follows from judging the MERGED record under the condition the record judgement already uses, which is what the card asked for, and the PR body (contract item 3) and the changeset (FROM line, "or edited another bound field of a datasource that is already managed") both name it. A label / active-only PATCH is not judged, so a row that already pairs badly can still be renamed or taken out of service; pinned both ways.
  4. Public surface: DatasourceAdminServiceConfig.listObjectsBoundTo(datasource) added as a REQUIRED member (:238), exported from the package (index.ts:73). A host that builds DatasourceAdminService itself must add it. Right, and required is the right strength: it is the verdict's input, and an optional seam would let a host store the very pair this closes. Named in the changeset's host bullet. All ten in-package fakes gain a listObjectsBoundTo that resolves to an empty array, and no assertion moved (diff read).
  5. One copy of the rule. The service imports externalBindingDatasourceRefusal from @objectstack/spec/data (:38) and asks it once per object that names the datasource, (object.name, object.external, record.name, record), keeping only refusal.shape === 'managed' (:1116–:1123). That is exactly the save door's datasource half (protocol.ts:22693–:22695): same arguments, same managed-only filter, same schemaMode path. The service adds only the schemaMode: path prefix and the Invalid datasource 'NAME'. heading the door's own record refusals already use (:1097). No restatement of the rule or its message anywhere in the diff; the pins derive the expected text from the predicate and throw if it makes no managed refusal. Right.
  6. Envelope. The refusal is thrown as the door's other record refusals are; the route's badRequest maps it to sendError(res, 400, SERVICE_ERROR_CODE['datasource-admin'], message) = 400 DATASOURCE_ADMIN_ERROR (admin-routes.ts:635–:641, :73), the ADR-0112 code registered for this service. No new error code. The card asked for "the ADR-0112 envelope this service already uses", not the save door's 422 INVALID_METADATA. Right.
  7. Unchanged answers. A federated datasource (schemaMode: 'external' or 'validate-only') gets no refusal because the predicate returns undefined when schemaMode !== 'managed' (external-binding-datasource.ts:117); a datasource no object binds external has no findings; objects bound external to another datasource are filtered out by o.datasource === datasource (plugin :519); objects on the datasource with no external get undefined from the predicate (:96). All pinned on both doors (create: external, validate-only, no-binding, LOCAL + ELSEWHERE; update: validate-only, no-binding, label/active-only). testConnection, removeDatasource, migrateCredential untouched (diff). Right.
  8. The plugin's source for the bound objects. listObjectsBoundTo reads the objectql service's registry.getAllObjects() (datasource-admin-plugin.ts:515–:521), resolved per call through safeGetService, never at init() (AGENTS.md "Startup registry reads" holds: no verdict is recorded). That is the registry the save door writes through to (applyRegistryWriteThrough) and the one the save door's own objectsBoundExternallyTo reads beside the stored rows (protocol.ts:22748–:22750); stored rows are hydrated into it at boot and persistObject (the federation import) goes through saveMetaItem (plugin.ts:225), so an object saved or imported at runtime is seen at once. A host with no engine registry serves [], so nothing is bound and nothing is refused — the fail-open direction, which is right for a seam whose absence means no objects exist. Pinned (datasource-admin-plugin.test.ts: registry object blocks create and the flip to managed while metadata.listObjects() is empty; a non-external object does not). Right.
  9. Prose, not contract (note for the seat). The new comment at datasource-admin-plugin.ts:508–:510, and the PR body's reason for it, say the metadata service's listObjects() is "a copy of this registry taken once at boot" that "never sees a runtime save". On main that reading is older than fix(objectql): keep the metadata service's copy of a runtime-authored object current after boot (MCP stdio exposure gate) #23687: ObjectQLPlugin.followRuntimeObjectCopy (packages/objectql/src/plugin.ts:2705, subscribed at :590) mirrors a tenant-authored object (_provenance === 'org') into the metadata service on every non-draft object mutation. The copy is still not the source (it covers tenant-authored objects only, is best-effort and logs rather than retries on failure), so reading the engine registry remains the right choice and the one plugin.ts:119–:131 already made for the federation service; the changeset's wording ("the one the save door writes through to, so an object saved or imported at runtime counts at once") is accurate. Only the "never" in the code comment overstates; the same stale sentence sits at plugin.ts:127–:129 on main. Not a FAIL: it changes no answer. A one-line softening in a follow-up touch is enough.

② Semver level

  • Changeset .changeset/25277-datasource-admin-external-binding.md: '@objectstack/service-datasource': minor. Matches. AGENTS.md §Post-Task step 3: a Metadata-protocol kind other than none takes at least minor on the package the diff moves; the package is at 18.0.0 and the sibling door's changeset for the same pair (.changeset/25278-restore-doors-save-refusal-family.md) carries the identical level and phrasing.
  • Clause-②: no (narrowing) on the PR body and the changeset. Matches what is published: no accept set or public surface widens; the admin door's accept set narrows (items 1–3 above). (narrowing) is BREAKING per AGENTS.md :1087–:1088, and the changeset carries the **BREAKING** banner, the FROM → TO mapping and the one-line fix for an author.
  • Metadata-protocol: narrowed. Matches: the admin door writes a sys_metadata datasource row (putDatasourceRecord → persistDatasourceRow), so a value an author could write through it is now refused; narrowed is the kind AGENTS.md names for a value refused, and the sibling door declared the same kind for the same pair.
  • ADR-0087: exactly one marker, not-required (already-registered object-external-binding-managed-datasource-refused) …. Matches: no rule is added or changed (the exported predicate is reused as-is); the named id was registered by 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's changeset and is referenced by the spec's refused-shape tests. The gate ran in CI on this head (Check Changeset job, step "Require an ADR-0087 disposition on a declared-breaking changeset"): success.
  • The dev's claim comment declared the same two lines before the diff was written, with the basis stated; nothing moved between claim and head.

③ Boundary flags

open_questions: [] — none to answer. The three flags the dev left:

  1. removeDatasource's bound count reads the boot-time copy (countBoundObjects / countBoundObjectsDiagnosed, datasource-admin-plugin.ts:461–:466, :486–:491, both from metadata.listObjects(); carrier: none · noted, not filed). Read in the cited code: the count does read the metadata service's copy, and the PR leaves it alone, as its claim scoped. The premise that the copy is "never reached by a runtime save" is the stale reading judged at ①.9: since fix(objectql): keep the metadata service's copy of a runtime-authored object current after boot (MCP stdio exposure gate) #23687 a tenant-authored object saved at runtime IS mirrored into that copy. What may still be missed is narrower (an object whose owner row is not tenant-authored, or a kernel whose metadata service refuses the in-memory mirror) and was not reproduced at any public door. Under the three filing classes (.claude/agents/os-dev.md §3: a reproduced defect, a breached declared contract with a Seam: line, or a metadata trap — each owing a reach:), this is a read-only inference with no reach:, so Acceptance notes only is the right disposition; the dev placed it right. The seat may want to carry one sentence to whichever card next touches the removal guard: the count should read the engine registry as this PR's seam and plugin.ts:136 already do, so the two bound-object readings on one plugin stop coming from two sources.
  2. content/docs/data-modeling/external-datasources.mdx names only the /meta door (:98–:101: "The same save door refuses PUT /api/v1/meta/datasource/…"; carrier: none · noted, not filed; flagged by the Docs Drift Check). Read on main: the page is incomplete, not falsified — every sentence on it stays true, and the admin API is now a second door that refuses the same pair. The tree is hand-written and editable in a code PR (AGENTS.md "Documentation Guardrails"), but the dev's claim excluded the docs pages and the drift check is advisory. Not in a filing class (no wrong answer, no breached contract, no trap). Note only; a one-sentence addition ("and POST / PATCH /api/v1/datasources refuse it too") belongs to the docs lane or a follow-up touch, not to this PR.
  3. Branch name claude/project-thread-29nfa9 rather than claude/issue-25277-…. Resolved on the head: The card this PR closes must claim this branch and No other open PR may claim the same issue both success; the claim comment names the branch. No action.

Implemented-by: claude/project-thread-29nfa9
Reviewed-by: session_016g7nYP9wdNx1GDa5XDr7Uy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · 2026-10-23T13:49Z

domain:services seat 1 (seat post #6021) · os-sales · session_016g7nYP9wdNx1GDa5XDr7Uy · card #25277 (card thread; claim 6365222748; os-dev-report 6366203774) · reviewed head ab44e1bdb35bf4bbff5ae29bacf3c8a5f8e4ff7d

  • Contract review record 6366423384 (CONTRACT_REVIEW_TIER, narrowing face): PASS. The seat adopts it.
    • Narrowed accept sets. All three are named and pinned:
      • the admin POST that makes a datasource managed or leaves schemaMode unset, while objects bind it with external;
      • a PATCH that sets schemaMode: 'managed' on such a datasource;
      • a PATCH of a judged key, or a secret, on a row that is already managed and already bound. label- or active-only writes stay exempt.
    • Judgement order. The judgement runs before writeSecret and putDatasourceRecord, so a refused call stores, mints and registers nothing.
    • One copy of the rule: externalBindingDatasourceRefusal from @objectstack/spec/data, with the same arguments and managed-only filter as the save door. The refusal is 400 DATASOURCE_ADMIN_ERROR, the service's existing ADR-0112 code. Federated and unbound datasources keep their answers, and both doors are pinned.
    • Declarations match what is published: changeset minor, Clause-②: no (narrowing), Metadata-protocol: narrowed, **BREAKING** with FROM→TO, and the ADR-0087 not-required (already-registered …) marker. The ADR-0087 gate is green in CI.
    • Host-facing break, named: the new required DatasourceAdminServiceConfig.listObjectsBoundTo.
  • Dev readings (report):
    • package tests 969 / 969, and typecheck green;
    • ablation: the 7 refusal pins go red and the controls stay green;
    • derived gates 68 / 68 exit 0, with 0 NOT MEASURED;
    • the full dogfood battery on this head passes: 290 files / 2494 tests (PR comment).
  • CI on this head: every check run is success or skipped, none failed. Mergeable clean, and git merge-tree onto today's main is clean.
  • Body: the attribution header, both footers and the os-sales reviewer are present.
  • Acceptance notes. Neither note is filed. The review agrees with both, and the seat concurs.
    • (a) removeDatasource's bound count reads metadata.listObjects(). The PR body's premise that this copy "never sees a runtime save" is stale: since fix(objectql): keep the metadata service's copy of a runtime-authored object current after boot (MCP stdio exposure gate) #23687, followRuntimeObjectCopy mirrors tenant-authored objects into it. What remains is a read-only inference with no reach:.
    • (b) external-datasources.mdx documents only the /meta door. It is incomplete, not wrong.
    • The overstated comment at datasource-admin-plugin.ts:508–510 is prose only, and the source it chose is correct. The next PR that touches that file softens it.
  • Landing: not governed (check-governed-merges --pr 25431: 428 lines). Ready + auto-merge through the relay. Fixes #25277 closes the card on merge, and the card thread follows the PR to MERGED.

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/m tests tooling

Projects

None yet

2 participants