Repository navigation
fix(service-datasource)!: the datasource admin API refuses to make a datasource managed while objects bind it with external - #25431
Conversation
…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>
…nding refusal Claude-Session: https://claude.ai/code/session_01AkGUa1szqpcew6SVTijZsv Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
Full dogfood battery on the final head
Generated by Claude Code |
Contract reviewServed-tier: Inputs read: card #25277 (body, claim 6365222748, os-dev-report), PR #25431 (body, 13-file list, net diff ① Derived judgmentsThe narrowing is declared of the whole PR; each accept-set or public-surface change the diff implies, judged in the cited code:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT · 2026-10-23T13:49Z
Generated by Claude Code |
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 withexternal.createDatasourceandupdateDatasourcenow judge the record they will persist against the objects bound to it, through the spec predicateexternalBindingDatasourceRefusal(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
f0fdd45bbmanaged(or with noschemaMode, the spec default) while objects bind it withexternal(externalBindingSaveRefusal,packages/metadata-protocol/src/protocol.ts:22675, datasource half:22692).createDatasource(packages/services/service-datasource/src/datasource-admin-service.ts:608) andupdateDatasource(:654) write throughputDatasourceRecord(datasource-admin-plugin.ts:418:metadata.registerplus a directsys_metadatainsert/update). Neither enterssaveMetaItem, 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 aPATCHthat setsschemaMode: 'managed'.What changed
DatasourceAdminService(datasource-admin-service.ts): a privateboundExternalRefusal(record)asks the predicate once per object that names the datasource, and counts only itsmanagedverdict, exactly as the save door's datasource half does. One line per bound object, eachschemaMode:plus the predicate's own message, under the door'sInvalid datasource 'NAME'.heading. It is thrown as the door's other record refusals are, so the route answers400 DATASOURCE_ADMIN_ERROR(the code this service already uses; no new error code).createDatasource: judged right after the existing record judgement, beforewriteSecretandputDatasourceRecord.updateDatasource: the merged record, under the condition the record judgement already uses (a supplied secret or a patcheddriver/schemaMode/config/external/pool). Alabel/active-only write is not judged, so a row that already pairs badly can still be renamed or taken out of service.DatasourceAdminServiceConfiggains one required member,listObjectsBoundTo(datasource): the objects that name the datasource, with theirexternalbinding. 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 (theobjectqlservice'sregistry), the registry the save door writes through to, so an object saved throughPUT /api/v1/meta/object/NAMEor imported from a remote table counts at once. Not the metadata service'slistObjects(), which holds a boot-time copy that a runtime save never reaches (the readingplugin.tsrecords for the federation service).DatasourceAdminServiceConfigfakes in this package's tests gainlistObjectsBoundTo: async () => [](8 files, 10 fakes, no assertion moved).Contract
Every input whose answer changes, old to new:
POST /datasources,schemaMode: 'managed'or none, and an object in the engine registry names that datasource withexternal. Old:201, the record stored and registered, a supplied secret minted, the pool registered. New:400 DATASOURCE_ADMIN_ERRORnaming each such object; nothing stored, registered or minted.PATCH /datasources/NAMEthat setsschemaMode: 'managed'on a federated datasource such objects bind. Old:200, the row flipped, the pool rebuilt. New: the same400; the row, its secret and its pool untouched.PATCHof a judged key (or with a secret) on a datasource that is already managed and already boundexternal. Old:200. New: the same400. The fix it names: declare the datasourceexternal/validate-only, or removeexternalfrom the objects.Unchanged: a federated datasource (
schemaMode: 'external'or'validate-only') and a datasource no object binds withexternalkeep their answers (pinned on both doors); objects on the datasource with noexternalbinding, and objects boundexternalto another datasource, are never in the verdict (pinned); alabel/active-onlyPATCHis applied (pinned);testConnection,removeDatasourceandmigrateCredentialare not touched (none of them changesschemaMode).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 fromexternalBindingDatasourceRefusal, never restated) and the store after (no record, no secret, noput/registerPool/reregisterPoolcall). Controls: noexternalbinding, federatedexternalandvalidate-onlyon create,validate-onlyon update, managed with no external binding, label/active-only.src/__tests__/datasource-admin-plugin.test.ts(3 cases added): the plugin's seam reads theobjectqlregistry while the metadata service'slistObjects()is empty, so a runtime-saved object blocks create and the flip to managed; an object with noexternalbinding does not.Ablation (
scripts/ablation-replace.mjs, anchorif (findings.length === 0) return undefined;replaced byreturn undefined;, landed 1 to 0, restored to the HEAD blob1c8772e150d7withgit diff HEADempty): the two pin files went7 failed | 35 passed. The 7 red are exactly the refusal pins (5 door, 2 plugin); every control stayed green.Local verification
On
ab44e1bd, overorigin/mainf0fdd45bb. Heavy runs went through the verify lock; each line cites itsVERDICT.pnpm exec turbo run build --filter='@objectstack/service-datasource^...':VERDICT command-exit 0.@objectstack/service-datasourcesuite (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-configon the changed.tsfiles: exit 0.check-nul-bytes: exit 0.node scripts/pm/dispatch-gates.mjs --commands: 68 families derived, each run, every oneexit 0(check:type-check-debtunder the lock; the build-reading families after the dogfood closure build).--ranreconciliation: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'snot-required (already-registered object-external-binding-managed-datasource-refused)disposition.!and the(narrowing)arm); its counts follow as a PR comment.Acceptance notes
removeDatasourcereads the boot-time copy.countBoundObjects/countBoundObjectsDiagnosed(datasource-admin-plugin.ts:461,:486) count frommetadata.listObjects(), the copyplugin.tsrecords 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 read0for 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.claude/project-thread-29nfa9, the name the session was given, notclaude/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