Uh oh!
There was an error while loading. Please reload this page.
fix(service-datasource): refuse an importObject name override that violates the namespace prefix - #11241
Conversation
…olates the namespace prefix The opts.name override was taken verbatim and persisted through metadata.register — the one runtime write path no namespace gate looks at. It now answers to the same ADR-0028 rule the derived name already obeys: loud refusal (the publish pre-flight's treatment of the identical violation) with validateObjectNamespacePrefix's own actionable message, thrown in the #8016 declaration shape (status 400, code EXTERNAL_IMPORT_ERROR). No namespace resolvable = rule skipped, mirroring defineStack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 0 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 5863fd119c8f10958088328af50745bfe4d2cb17 && git checkout 5863fd119c8f10958088328af50745bfe4d2cb17
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 38cf397ea9b1b5aea338805e7559f70576b88fb1 4a86656751cf7c9942210d50daa33a0162edb358 && git checkout -B drift-repro 38cf397ea9b1b5aea338805e7559f70576b88fb1 && git merge --no-ff 4a86656751cf7c9942210d50daa33a0162edb358
node scripts/docs-audit/affected-docs.mjs --json 38cf397ea9b1b5aea338805e7559f70576b88fb1 |
合同审核已通过(PASS),详见卡 #11061 上的判定记录。 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11061
What
ExternalDatasourceService.importObjecttookopts.nameverbatim (opts.name ?? draft.name) and persisted it throughmetadata.register('object', ...)— the one runtime write path no namespace gate looks at (registerapplies the register contract and the writability gate, nothing else; the prefix rule runs only atdefineStack()and the publish pre-flight). APOST /api/v1/datasources/:name/external/tables/:remote/importbody of{"name": "customers"}therefore minted an unprefixed federated object that the platform's own authoring and publish paths would both have refused. Reachability re-verified against this head: the route forwards the request body as opts unfiltered — now atpackages/rest/src/external-datasource-routes.ts:465(the card's:462had drifted by three lines; same code).Triage ruled (comment
5383472206, binding): invariant restore — the prefix rule is the declared behavior; loud refusal or forced prefixing, implementer argues which in the PR. That argument follows.The choice: B (loud refusal), argued against A's cost
A (force the prefix) keeps every request succeeding, which is its whole appeal — no caller breaks today. But it succeeds by silently rewriting a name the caller explicitly asked for: the request says
customers, the 201 sayswh_customers, and nothing failed loudly. Every caller that recorded its requested name — an AI-authored script, a saved config, a follow-up read of/api/v1/data/customers— now holds a reference to an object that does not exist, and discovers it later, far from the cause. That is the tolerant-consumer accommodation Prime Directive #12 forbids: off-contract input accepted and adjusted downstream, fossilizing "any name works, the platform fixes it" into a de-facto second contract. It would also make this seam the only place in the tree where the prefix rule edits input rather than refusing it —defineStack()refuses at compile time, the publish pre-flight refuses at runtime — a third behavior for one rule. (The derived path'sapplyNamespacePrefixis not a precedent for A: it adjusts a name the service itself invented; no caller intent is overridden there.)B's cost, engaged honestly: a request that succeeds today returns 400 tomorrow. It is bounded three ways, all measured rather than assumed: the refusal takes an explicit
namein the body (in-tree callers pass none — objectui's ImportObjectDialog posts no name,os datasource introspectdoes not import; the exposure is the raw route body), and a datasource whose owning package resolves a namespace, and a name that violates the rule — i.e. exactly the requests that were minting objects the platform's authoring path refuses. The 400 carries the rule's own prescription ("Rename it to 'wh_customers' (namespace = 'wh')"), so a broken caller — human or AI — self-corrects in one round trip. A silent rewrite hides the mistake where AI-generated metadata errors propagate; a loud refusal with the exact fix is the structurally safe failure. B also matches what the publish gate already does with the identical violation, keeping one rule at one behavior with one message everywhere it fires.Shape
importObjectbefore the draft is generated: it resolves the namespace through the same injected read and the samenormaliseNamespacethe derived path uses, and judgesopts.namewithvalidateObjectNamespacePrefix— the tree's single source of the rule. Fail-fast placement is deliberate: the verdict needs only the injected namespace read, so a doomed request does not cost a live remote introspection round trip (pinned: the refusal case asserts zero introspect calls).metadataIncompleteError. Stated in prose because the body sanitizer eats short angle-bracket fragments: the helper returns a plain Error object whosecodeproperty is the stringEXTERNAL_IMPORT_ERROR(the ADR-0112 ledger's registered code for a refused federated import) and whosestatusproperty is the number 400. The REST import route answers 400 +EXTERNAL_IMPORT_ERRORwith the thrown message for everyimportObjectthrow (already pinned inpackages/rest/src/external-datasource-envelope.conformance.test.ts, "an import the service refuses"), so the declared envelope and the served envelope agree by construction. Consequentlypackages/rest/src/external-datasource-routes.ts— permitted only if the shape required it — was not touched.defineStack()behave. Nothing invents a prefix on the caller's behalf.packages/specuntouched (zero ownership in this lane; the fix did not need it — the validator was already exported). TheImportObjectOpts.namedoc there still reads "Override the auto-derived object name (snake_case)", which remains true; the refusal semantics are documented at the service seam.Tests (7 new cases, both directions)
Refusal pins (ADR-0112: code + status, never a bare toThrow):
EXTERNAL_IMPORT_ERROR, message byte-equal to the validator's own return (computed via the imported validator, not a copied string), nothing persisted, nothing introspected;wh__orders): same envelope, the validator's legacy-form message.Preservation pins:
wh_orders) accepted verbatim — no double prefixing;sys_*override accepted (the platform-reserved carve-out every gate on this rule shares);wh_fact_orders).Reverse verification, from the committed state: stood the base (
dd84ddd796) service file up in the fixed tree (mutation confirmed on disk by anchored grep: zero hits of the new helper), re-ran the suite — exactly the two refusal pins went red (import succeeded instead of refusing), all 568 others stayed green; restored from the branch commit (disk-confirmed), 570/570 green. Predicted direction, observed direction: match.Gates — derived union, measured at head
4a86656751, clean treenode scripts/pm/dispatch-gates.mjs(no hand-supplied paths; derivation header names this repo at4a86656751). All to real verdicts, exit codes captured before any pipe:pnpm --filter @objectstack/service-datasource test— "Test Files 26 passed (26) · Tests 570 passed (570)"pnpm --filter @objectstack/service-datasource typecheck— command-exit 0check:changeset-gate-self-tests,check:objectui-changeset,check:published-files,check:slot-lookup,check:test-source-alias("72 packages with tests scanned... OK"),check:type-source-resolution,check-adr-0087-registration("adds no declared-breaking changeset"),check-changeset-no-major,check-ci-filter-parity,check-empty-changeset("1 declaring changeset(s) added"),check-plugin-teardown-shape,docs-audit/check-affected-docs— all exit 0check:query-options-erasure,check:type-check-coverage("OK — 65/78 workspace packages type-checked"),check:type-check-debtafter building the workspace closure ("--re-measure: OK — 33 ledger entr(ies) re-measured, none above its recorded number"),check:engine-double-contract("OK — 384 pinned"),check:cross-package-test-inputs("OK: 13 package(s) read outside themselves, all declared"),check:where-matcher— all exit 0check:nul-bytes("OK — scanned 6511 text file(s)... no raw ASCII control bytes")The full repo farm runs in CI; local scope is the derived union above (declared narrowing: repo-wide scans belong to CI per lane policy).
Contract review (clause-②)
This PR is draft and stays draft: it changes contract accept/reject behavior on a request the API accepts today, so it waits on a contract-review seat at tier. No ready-flip, no auto-merge, and
needs:contract-reviewstays on the card — this lane cannot clear any of those.Out of scope, recorded
metadata.register('object', ...)applies no namespace check at all — the wider question the card flagged; deliberately not widened into this PR. metadata.register('object', ...) applies no namespace-prefix check — the ADR-0028 invariant is enforced caller-by-caller, not at the runtime write path #11237 remains open for its own grading.Generated by Claude Code