Uh oh!
There was an error while loading. Please reload this page.
feat(objectql): retain and expose external.credentialsRef on datasource definitions - #12806
Conversation
…ce definitions `registerDatasourceDef`'s inline parameter type carried only `name`, `schemaMode` and `external.allowWrites`, so a caller passing a fresh object literal with `external.credentialsRef` was refused by excess-property checking (TS2353) — and the engine exposed no reader onto its datasource index at all, its only consumer being the private write gate. Measured before changing anything: nothing stripped the reference at runtime. The writer stores the caller's `external` object whole, by reference, and the manifest install path spreads the def straight through, so the value was already in the index — unreachable to every typed producer and to every consumer. The defect was type-level, and the fix is a widening plus the accessor that was missing. - name the shape as `DatasourceDef` rather than restating it at all three touch points, and widen it with `external.credentialsRef?: string` — the key `@objectstack/spec` already declares (`ExternalDatasourceSettingsSchema`), valid in every `schemaMode` per #8153, so retention rather than invention; - add `ObjectQL.listDatasourceDefs()`, deliberately unfiltered and returning copied `external` blocks, so a `sys_secret` reference sweep can see the handles a datasource declared IN CODE holds — those never reach `sys_metadata`, so today the host has to remember to pass them in; - pin the compile-time half in a `.pin.ts`, since the package's tsconfig excludes `**/*.test.ts` and a `@ts-expect-error` in a test file there would be a phantom check. The write gate is untouched: it reads `schemaMode` + `allowWrites` and the new key is inert to it, which the runtime tests pin in both directions. Part of #12758
A1 — type-level pin. Delete `credentialsRef?: string;` from `DatasourceDef`. PREDICTION: `pnpm --filter @objectstack/objectql typecheck` goes RED with TS2353 on the POSITIVE lines of `datasource-def-credentials-ref.pin.ts`, AND the vitest run stays GREEN. The second half is the point: a runtime test cannot see a compile-time widening, which is why the pin exists at all. A2 — the accessor's retention. Make `listDatasourceDefs` copy only `allowWrites` out of the stored `external` block. PREDICTION: vitest goes RED on the read-back cases (both entry routes, the managed-datasource case, the defensive-copy case) and STAYS GREEN on the two write-gate cases, which do not read the reference. A3 — the write gate is really the write gate. Force `dsAllows = true`. PREDICTION: vitest goes RED on "still refuses a write without the double opt-in" here AND in the pre-existing `external-write-gate.test.ts`, proving this file's gate assertion rides the real Gate 3 and not a local stub. Restore leg for each: `git checkout HEAD -- <absolute path>`, proven by a `git hash-object` match against the HEAD blob plus an empty `git diff`. No rebuild is needed on any leg: the pin and the test both import `./engine` RELATIVELY, so neither resolves through the package `exports` field into `dist/` — the mutation on disk is the code under test. Part of #12758
…ning Argues the bump rather than defaulting it: zero runtime change (the case for patch) against three additions to public API (the case for minor). Part of #12758
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 d7d08245c0a138f8e750f16092fb139359cb8f37 && git checkout d7d08245c0a138f8e750f16092fb139359cb8f37
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96dc446c9c19063edfae26ae30ff75143ef0c5b7 afda2d04d71dddfea6878447403b306d9c43a066 && git checkout -B drift-repro 96dc446c9c19063edfae26ae30ff75143ef0c5b7 && git merge --no-ff afda2d04d71dddfea6878447403b306d9c43a066
node scripts/docs-audit/affected-docs.mjs --json 96dc446c9c19063edfae26ae30ff75143ef0c5b7
|
os-zhuang
commented
Aug 27, 2026
Reviewer-of-record audit of the drift check's rows above, including the release-owned one — which the check says is read-only but still audited, so here is the audit. The guardrail holds. The diff touches five files, none under
⇒ nothing to file and nothing to fix on that page. One adjacent line worth naming rather than passing over, because it points at this card's own follow-up. The two hand-written rows were already covered.
Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12758
Contract-review tier (
Clause-②: yes). All evidence below was produced on this branch's final commitafda2d04d.The measurement that reframed the card, done before any code was written
The card's headline is that a datasource's credentials reference "is dropped on the way in". It is not, and never was — at runtime. A throwaway harness against
packages/objectql/src/index.tsregistered a def carryingexternal.credentialsRefthrough every entry route and read the private index back:external.credentialsRefregisterDatasourceDefcallsys_secret:sec_abc123(and the storedexternalis the caller's object by reference)datasourcesas an ARRAYsys_secret:sec_abc123datasourcesas a NAME-KEYED MAPsys_secret:sec_abc123undefinedThe writer stores
def.externalwhole rather than rebuilding it key by key, and the install path spreads the manifest def straight through, so nothing on either path is capable of dropping a nested key. The full reading is on the card.The defect is type-level, and it has two halves. A caller could not get the key in without defeating excess-property checking — the package's own
tscrefused a fresh literal witherror TS2353: Object literal may only specify known properties, and 'credentialsRef' does not exist in type '{ allowWrites?: boolean | undefined; }'— and having got it in by anas any, nothing could read it back: enumerating the class's prototype chain found twelve datasource-related members and no accessor onto the index at all, its only reader being the private write gate. So the value sat in the map, reachable by no typed producer and no consumer.⛔ Consequently no test here is phrased as "the reference is no longer dropped". That would pin something that was never true, and no runtime fix was manufactured to make the card's wording right.
The accept/reject boundary change
Refused before, accepted now — calls to
ObjectQL.registerDatasourceDefpassing a fresh object literal whoseexternalblock carriescredentialsRef:Unchanged in both directions. Every shape that compiled before still compiles (
{ name }alone;{ name, schemaMode, external: { allowWrites } }), and the widening admits no garbage: a def withoutname, a non-stringcredentialsRef, an inlinepassword, and the spec'svalidationblock are each still refused. All eight cases are pinned in one file, positive and negative together.Newly public:
ObjectQL.listDatasourceDefs()and the exported typeDatasourceDef. Nothing is removed, narrowed or renamed, so there is no breaking-change declaration and no ADR-0087 entry.⭐ Worth noting for review:
credentialsRefis not invention.@objectstack/spechas declared it onExternalDatasourceSettingsSchemaall along (datasource.zod.ts), valid in everyschemaModeper #8153, and/docs/data-modeling/external-datasourcesshows authors writing exactly that key on a code-declared datasource. The engine's public registration method was refusing a key its own docs prescribe. ⛔packages/specis untouched here.What landed
registerDatasourceDefnow takes the named, exportedDatasourceDef, whoseexternalblock carriescredentialsRef?: stringbesideallowWrites. Named rather than restated at all three touch points — three copies of one shape is a second de-facto contract that drifts, and the drift it produces is a handle missing from a credentials sweep.ObjectQL.listDatasourceDefs()answers every definition the engine holds, from both entry routes. Deliberately unfiltered:credentialsRefis valid on a managed datasource too, so filtering by schema mode would hide live handles, and under-reporting is the direction that deletes live credentials. Each entry carries a copiedexternalblock so a reader cannot reach through the accessor and mutate the write gate's own input.schemaMode+allowWrites; the new key is inert to it.The runtime change is
Array.fromover a Map. That is the honest size of it, and it is the point: the card is a widening plus the accessor that was missing.Tests, and how each was ablation-proven
Predictions were committed empty before any mutation (
2de7e6cc6). Every mutation was proven on disk with anchored greps in both directions (injected marker count = 1, deleted text count = 0) before its run was read; every restore was proven bygit hash-objectmatching the HEAD blob plus an emptygit diff HEAD; the script carriedtrap ... EXIT INT TERMwith absolute paths. No rebuild leg was needed and this was verified rather than assumed: the pin and the test both import./enginerelatively, so neither resolves through the packageexportsfield intodist/.A1 — the type-level assertion, ablated as compile-red rather than test-red. Deleting
credentialsRef?: string;fromDatasourceDef:⭐ The second line is the load-bearing half: with the type reverted the runtime suite stays entirely green. A runtime test cannot see a compile-time widening, which is why the pin exists — and why it is a
.pin.tsand not a.test.ts.packages/objectql/tsconfig.jsonexcludes**/*.test.ts, so a@ts-expect-errorwritten in a test file there is a phantom check. That this file really is inside the program was measured, not assumed:tsc --listFilescounts 1 for the pin, 0 for the test file, and 1 forregister-object-authored-shape.pin.ts, the existing file whose docblock establishes this convention and which serves as the positive control.A2 — the accessor's retention. Making
listDatasourceDefscopy onlyallowWritesout of the stored block: 5 failed / 15 passed — red on both entry routes, on the managed-datasource case and on the defensive-copy case; green on the two write-gate cases, which do not read the reference.A3 — the write-gate assertion rides the real Gate 3. Forcing
dsAllows = true: 2 failed / 18 passed — red on this file's refusal case and onblocks insert when only the object opts inin the pre-existingexternal-write-gate.test.ts, so the assertion is on the genuine gate and not a local stub. The refusal case asserts the ADR-0112 envelope (codeandstatus, plus the message clause), never a baretoThrow().The refusal test also pins the widening's inertness to the gate: the definition carries a
credentialsRefin every gate case, and both verdicts are unchanged.Verification, all on
afda2d04dpnpm --filter @objectstack/objectql testTest Files 246 passed (246)·Tests 4253 passed (4253)pnpm --filter @objectstack/objectql typecheckObjectQL implements IObjectQLEnginestill holds against the wider parameter)pnpm --filter '...@objectstack/objectql' run typecheckerror TS. Direction stated: the...PREFIX is consumers, i.e. downstream, which is where a contract change lands. 43 packages in the closure, 37 actually ran atypecheckscript (verified by counting the echoed script lines, since a zero-match filter exits 0 silently); the 6 without one arecloud-connection,hono,knowledge-ragflow,service-automation,service-knowledge,service-storagerestenvelope test,specdata-enginecontract test,service-datasource(27 files / 585 tests),clisecret-reference-union(20 tests)pnpm lint(repo-wideeslint . --no-inline-config)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, 24 path-matched families plus the convention-triggered onespnpm check:type-check-debt(the ratchet; full closure built first)OK — 31 ledger entr(ies) re-measured in 219.3s, 1570 raw tsc error(s) total, none above its recorded numbernode scripts/pm/check-half-states.mjsexits 3,PREREQUISITE NOT MET— the container'sGITHUB_TOKENis a 14-character proxy placeholder, not a GitHub credential, so nothing was swept and no predicate ran. Its self-test half (pnpm check:pm-half-states, 1515 cases) passes. CI runs it with a real token.Gate results above are read from each gate's own printed verdict line, with the exit code captured before any pipe.
Contract-docs hand-read (not delegated to the drift bot)
The docs-drift bot is blind here by construction and will very likely post "nothing to list": no page in
content/docs/ordocs/mentionsregisterDatasourceDefat all (0 hits), so a diff changing the emitter shares no token with the pages that state the rule by its inputs.Positive control first: grepping
allowWritesacrosscontent/anddocs/reachesdata-modeling/external-datasources.mdx,data-modeling/drivers.mdx,references/data/datasource.mdx,references/data/object.mdxand ADR-0015 — so the search does reach the pages that describe what a datasource declaration may carry.Shapes searched, not just the token: what a datasource declaration may carry · ADR-0015 and the external-datasource pages · credential-reference and
sys_secretpages · claims of the form "the engine keeps / stores / retains / drops ..." · claims about what the write gate reads.Found: nothing this change falsifies. The prose runs the other way —
external-datasources.mdx§4 already instructs authors to put a reference inexternal.credentialsRefon a code-declared datasource, andreferences/data/datasource.mdxdocuments the key asoptional,string, valid in every schema mode. Both are generated from or agree with the spec, which is unchanged. So no doc sentence was rewritten, and none needed to be; the widening closes a gap between the docs and the implementation rather than opening one.Reported rather than edited: neither
registerDatasourceDefnorlistDatasourceDefsis documented anywhere. Judged not a finding — the docs describe authoring datasources throughdefineDatasource, and these are engine-side registration APIs consumed byservice-datasource, not authoring surface.Changeset:
minor, argued rather than defaultedThe tension is real. Zero runtime behaviour changes, which is the honest case for
patch. But the bump describes the contract, not the bytes executed, and this adds public API three ways: a new public method, a newly exported type, and a widened accepted set on an existing public method. A consumer pinning~would receive new API under apatch, which misdescribes the release. Nothing is removed, narrowed or renamed, so this is the additive-surfaceminor, not the launch-window convention for shipping breaking changes asminor.Scope
packages/specuntouched, ⛔ no part of sys_secret: rotations that happened BEFORE #8030's fix left decryptable orphans on deployed instances, and nothing reaps them #8103's deletion half, ⛔ no ADR edits.cascadeDeleteRelationsin the same file: this diff stays in its own regions (the class-adjacent type, the private index, and theregisterDatasourceDefneighbourhood) and reformats nothing.The consumer half was NOT wired, and that is a decision with reasons — filed as #12804, blocked on this card.⚠️ Three prose sites in that module now state the old fact and were deliberately left alone: correcting the wording without the behaviour change would leave the module internally inconsistent, and one of the three is the operator-facing gap message. #12804 names all three with line context.
secret-reference-union.tscannot reach the new accessor (SecretReferenceEngineLikenames only two members) anddeclaredDatasources: undefinedis the module's deliberate loud "nobody answered". Making the engine answer instead is a contract decision on a shipped, exported CLI input — the honest shape is plausibly the union of both sources rather than a replacement, since a host can declare datasources the engine never saw. That is not small and local, so per this card's dispatch it becomes a follow-up.Also filed: #12805 —
IDataEngine.registerDatasourceDefinpackages/specstill declares the narrow parameter and has nolistDatasourceDefs, so a host typed against the published contract still cannot pass the reference. Fenced out of this card by dispatch; unassigned, for the spec seat.Both follow-ups are open, unassigned and
pm:queue. Neither is closed by this PR.Generated by Claude Code