Skip to content

fix(service-datasource)!: the datasource admin doors refuse an empty secret instead of sealing it over the working credential - #29064

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-xqgh8b
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-xqgh8b

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28928

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** or content/docs/** teaches what an empty secret does at the datasource admin doors. The fail-closed paragraph in external-datasources.mdx (§4 Credentials) stays true: an empty stored credential still fails closed at connect.

BREAKING accept-set narrowing at the datasource admin doors (@objectstack/service-datasource): an empty secret is refused.

维护者速读

  • 改了什么:POST /api/v1/datasources 和 PATCH /api/v1/datasources/NAME 收到空字符串 secret("" 或 { "value": "" })时,现在回 400 DATASOURCE_ADMIN_ERROR,消息点名 secret,任何写入之前就拒绝:不新建 sys_secret,不改 credentialsRef,不删旧行。另外,旧数据里已经存成空值的凭据,连接时的状态原因现在说「凭据为空」,不再误说「sys_secret 行丢失或加密密钥变了」。
  • 为什么改:以前编辑时传空 secret 会被当成轮换:新建一个空值的 sys_secret,把引用指过去,再删掉能用的旧行。正在用的连接池照常跑,直到下次连接才坏,而旧凭据已经没了(Datasources: PATCH with an empty secret replaces the stored credential with an empty one, and the status reason then blames a missing secret row #28928,QA 记录 QA run · priority:P0 (23/23) · 7fa9873f · 2026-11-02 · 21 PASS / 2 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #28927)。
  • 风险与回滚:低。只多拒绝一种请求体;null、不传 secret、真实凭据、全空格值的行为都不变。控制台本来就只在字段有值时才发 secret,所以不受影响。回滚即 revert。
  • 先看:packages/services/service-datasource/src/datasource-admin-service.ts 的 assertSecretNotEmpty 及它在 createDatasource / updateDatasource 里的两处调用;datasource-connection-service.ts 里新拆出的 secret === '' 分支。
  • 两分钟验证:pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-empty-secret-refusal.test.ts,预期 Tests 8 passed (8)(main 上 3 条拒绝用例红,5 条对照两边都绿)。
  • 需要你做的:无,席位落地(这是收窄,按规矩先做合同审查)。

What changes

  • The refusal. assertSecretNotEmpty(heading, secret) (module function in datasource-admin-service.ts) throws when the normalized secret has value === ''. createDatasource calls it right after the name check, and updateDatasource right after the undeclared-key and inline-ssl.key checks, so it runs before any secret is read, written or removed and before the record is stored. The route answers it as it answers those two checks: 400 DATASOURCE_ADMIN_ERROR, message Invalid datasource 'NAME'. then "The secret is empty. Send the credential to store, or leave secret out (or null) to keep the stored one. Nothing was written." Only the empty string is judged, so the answer never echoes a credential.
  • Why there and in that shape. The doors have no VALIDATION_ERROR. Their existing refusals for a body (Datasources: POST /api/v1/datasources accepts and silently drops a top-level password or connectionString that os compile refuses, answering 201 #28549 / Datasources: the test door and every undeclared top-level key still bypass the refusal DatasourceSchema gives (the rest of #28549) #28612 undeclared keys, Datasources: the admin create and edit doors drop the schema-declared ssl, autoConnect and description, so a datasource created there loses its TLS settings #28633 inline ssl.key) are a plain Error thrown first thing in the service and answered 400 DATASOURCE_ADMIN_ERROR by badRequest; this reuses that exactly (ruling 3: no new error code). Only these two doors call createDatasource / updateDatasource.
  • What stays. null and an absent secret keep the stored credential (splitSecret already maps null to absent). A whitespace-only value is not refused: step 1 shows it is sealed and resolve() returns it unchanged, so it is a usable value as sent. POST /api/v1/datasources/test stores nothing and is untouched.
  • The status reason (ruling 4). A row sealed empty before this change is still reachable: resolve() returns '' for it. DatasourceConnectionService.connect now answers that case on its own: still failed-credentials, still no driver built, reason "credential 'REF' is empty (its sys_secret row holds an empty value); set the datasource's secret again". A missing row or a decrypt failure (undefined) keeps the old reason.
  • Changeset .changeset/28928-empty-datasource-secret-refused.md: patch on @objectstack/service-datasource (non-protocol), both declaration lines, BREAKING banner, the remedy, and the ADR-0087 marker not-required (no-migration-prescription).
  • No new error code, no packages/spec/**, no new gate.

Why Clause-②: no (narrowing). One body that answered 201 / 200 now answers 400; no answer member, code or exported type changes.

Step 1: measurement on main

Real routes over the real DatasourceAdminService on in-memory stores, base f7c545ad:

body secret create update
"" 201; seals an empty value and binds credentialsRef to it 200; writes a new sys_secret holding '', repoints credentialsRef, removes the working row
" " 201; seals the 3-space value 200; rotates to the 3-space value
null 201; no credentialsRef 200; no-op on the credential

resolve() returns the sealed string as-is, so "" yields '' and " " yields three spaces. Callers: none sends an empty secret on purpose. The console's DatasourceResourcePage.tsx (objectui ui/packages/app-shell) sets body.secret only when the field is truthy, on save and on test; the client SDK has no datasource create/update; hotcrm has no caller.

Tests

New packages/services/service-datasource/src/__tests__/datasource-empty-secret-refusal.test.ts, through the real routes over the real service (the harness of datasource-admin-declared-keys-kept.test.ts), recording every seam call:

  • PATCH with "secret": "" and with { "value": "" }: 400, DATASOURCE_ADMIN_ERROR, message names secret; the stored record (its credentialsRef included) equals the seed, no secret written, no seam call at all (no put, no removeSecret, no pool).
  • POST with "secret": "": the same refusal; nothing stored, no secret written, no seam call.
  • Controls: a real secret still rotates on PATCH (new ref, the working ref removed) and is bound on POST; secret: null and an absent secret on PATCH change no credential; secret: null on POST creates with no ref.

One new case in datasource-connection-service.test.ts (D3 block): a resolver returning '' fails closed with a reason that names the empty credential and contains neither "could not be resolved", "missing sys_secret row" nor "encryption key".

Red on base, green on head, ablation
  • Base (the two source files at f7c545ad, the tests at head): Tests 4 failed | 107 passed (111) across the two files. The three refusal pins received { status: 200, success: true }; the status-reason pin received "credential 'sys_secret:abc' could not be resolved or decrypted (missing sys_secret row, or the encryption key changed)". All five controls green.
  • Head: Tests 111 passed (111).
  • Ablation (scripts/ablation-replace.mjs, the guard line of assertSecretNotEmpty replaced by return;): Tests 3 failed | 5 passed (8), exactly the three refusal pins. Restore: blob after restore equals HEAD's (1933a09e), git diff HEAD empty.

Acceptance notes

  • Leftover, same class (seat carries): migrateCredential's drop-inline refusal (datasource-admin-service.ts, the resolved == null || resolved === '' check) still says the existing ref "could not be resolved or decrypted (a missing sys_secret row, or a changed encryption key)" when the row decrypts to empty. Its outcome (refuse to drop the last inline copy) is right; only the cause text conflates.
  • An admin who used to send "secret": "" to mean "no change" now gets the 400; the remedy is in the message and the changeset.

Verification

All readings at head 66a746c6 (one commit on origin/main 00d0668d, after PR #28955 merged). Pins red/green and the ablation were taken before the restart, on base f7c545ad; the restart moved no file this diff touches.

Package suite, typecheck, lint
  • pnpm --filter @objectstack/service-datasource test: Test Files 69 passed (69), Tests 1152 passed (1152), exit 0.
  • pnpm --filter @objectstack/service-datasource typecheck (tsc over src, tests included): exit 0.
  • The repo's pnpm lint command scoped to packages/services/service-datasource: exit 0.
Derived gates (`dispatch-gates.mjs --commands`, 62 families)
  • All 62 exit 0, check-adr-0087-registration --base origin/main and check-changeset-no-major included; dispatch-gates --ran: 62 derived, 62 run, 0 NOT-MEASURED. Record: pm-orders/server/28928-gates-ran.txt.
  • check:type-check-debt: OK, --re-measure: OK.
  • Repo-wide pnpm lint and the CI-only families are CI's; see the PR's check-runs.

Dogfood battery (owed: ! title and the (narrowing) arm), once at the final head after pnpm turbo run build --filter=@objectstack/dogfood... (exit 0): pnpm --filter @objectstack/dogfood test, Test Files 383 passed | 3 skipped (386), Tests 3163 passed | 28 skipped (3191), exit 0.


Generated by Claude Code

…secret instead of sealing it over the working credential

POST /api/v1/datasources and PATCH /api/v1/datasources/:name took
"secret": "" as a value: a create sealed an empty credential, and an edit
minted a new sys_secret holding nothing, repointed credentialsRef at it and
removed the working row. Both now refuse it 400 DATASOURCE_ADMIN_ERROR naming
`secret` before anything is written. null, an absent secret and a
whitespace-only value are unchanged.

A row sealed empty before still fails closed at connect; its status reason
now says the credential is empty instead of blaming a missing row or a
changed encryption key.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mTYb1BTJNRqf3bdJ8
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 3 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 c708de67b2c00c095afbf5fb36c6c093dbe85458 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c708de67b2c00c095afbf5fb36c6c093dbe85458

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head 66a746c6f45bcac47f811b0408ad2e341306d7fa · 2026-11-02T06:44Z

Taken by the dispatching seat. Face narrowing (Clause-②: no (narrowing), Metadata-protocol: none), at CONTRACT_REVIEW_TIER with ④. The review runs as an isolated sub-agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 66a746c6f45bcac47f811b0408ad2e341306d7fa
Local-runs: none
Scope: packages/services/service-datasource/src/datasource-admin-service.ts, packages/services/service-datasource/src/datasource-connection-service.ts, packages/services/service-datasource/src/tests/datasource-empty-secret-refusal.test.ts, packages/services/service-datasource/src/tests/datasource-connection-service.test.ts + packages/services/service-datasource/src/admin-routes.ts (splitSecret, badRequest, the create/update/test doors), packages/services/service-datasource/src/contracts/datasource-admin-service.ts (SecretInput), packages/services/service-datasource/src/datasource-secret-binder.ts (bind/resolve), packages/services/service-datasource/src/datasource-admin-plugin.ts (writeSecret seam), packages/services/service-settings/src/local-crypto-provider.ts (encrypt), packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts, content/docs/data-modeling/external-datasources.mdx §4, objectui packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx, packages/client/src/index.ts, packages/cli/src/commands/datasource/*

Rendered by the seat's subagent, read-only, at 2026-11-02T06:49Z. Inputs: card #28928 (body + 3 comments: triage 6569553065, claim 6569714758, os-dev-report 6570874270), PR #29064 (body, 5-file list, git diff origin/main...66a746c6), the head's check-runs, and the cited code read with git show 66a746c6:PATH at the head. The changeset .changeset/28928-empty-datasource-secret-refused.md is a prose face: read for ②, not reviewed.

Check-runs on the head at review time (45): completed success — Auto Label, Test UI, Dogfood Regression Gate, Plan Test Core shards, Lint · scoped · query-options erasure, Type Check · source gates, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, filter, Check Changeset, Governed Surface Queue Guard, Lint · ESLint, Check PR Size, Flag docs affected by code changes, PR-body guards. Skipped (path-filtered): the UI/docs/dogfood-CLI/temporal/opt-in families. Still in progress: Test Core (1/8 … 8/8), Build Core, Live E2E UI, Type Check · workspace, Type Check · debt ledger, Type Check · consumer gates, Lint · scoped · slot lookup & corpus, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI, release & engine gates. No failure or cancellation on the head. The seat lands only once the in-progress families conclude.

① Derived judgments

Each accept-set / public-surface change the diff implies, read in the tree:

  1. POST /api/v1/datasources with "secret": "" or "secret": { "value": "" } → 400 DATASOURCE_ADMIN_ERROR, message Invalid datasource 'NAME'. + "The secret is empty …". Right. splitSecret (admin-routes.ts L664) maps a bare string to { value } and an object through as-is, so both spellings reach assertSecretNotEmpty with value === ''. The guard is the second statement of createDatasource, after assertValidName and before assertNoDraftCredentialsRef, getDatasourceRecord, writeSecret, putDatasourceRecord and the pool seam: nothing is read for the secret or written. The thrown plain Error carries no code/status, so the route's badRequest answers 400 SERVICE_ERROR_CODE['datasource-admin'] = DATASOURCE_ADMIN_ERROR — the same path as the Datasources: POST /api/v1/datasources accepts and silently drops a top-level password or connectionString that os compile refuses, answering 201 #28549/Datasources: the test door and every undeclared top-level key still bypass the refusal DatasourceSchema gives (the rest of #28549) #28612 undeclared-key and Datasources: the admin create and edit doors drop the schema-declared ssl, autoConnect and description, so a datasource created there loses its TLS settings #28633 inline-ssl.key refusals. The seat's ruling (reuse the doors' existing shape; they have no VALIDATION_ERROR) is honoured.
  2. PATCH /api/v1/datasources/:name with the same two bodies → the same 400; the stored record, its external.credentialsRef and the working sys_secret row are untouched. Right. In updateDatasource the guard sits after getDatasourceRecord (a read of the record, not of the secret — a missing name still answers 404 first, as the siblings do), after the undeclared-key and inline-ssl.key checks, and before the merge, assertValidRecord, writeSecret, tryRemoveSecret, putDatasourceRecord and reregisterPool. The new test pins ops to [] through the real routes over the real service, so no seam is reached.
  3. null and an absent secret keep the stored credential. Right. splitSecret maps null to undefined; assertSecretNotEmpty returns on secret == null; the if (secret) branches in both doors are then skipped exactly as on main. Controls pinned on both doors.
  4. A whitespace-only secret is still sealed. Right per the seat's ruling (it is a usable value as sent; resolve() returns it unchanged). The guard compares value !== '' only, so it cannot catch anything else and the message cannot echo a credential.
  5. POST /api/v1/datasources/test is untouched. testConnection still passes secret: '' to the probe and stores nothing. Right as a scope line (the order's ruling 3 names create and update). Observation only: a test with "" answers a probe result for a body the save door now refuses; the console never sends it (it spreads secret only when truthy, L448), so nothing moves at the consumer.
  6. Connect path: a credential that resolves to '' is still failed-credentials, no driver built, and the reason now says the row holds an empty value. Right. The new secret === '' branch is split off the old secret == null || secret === '' and passes the same five arguments its == null sibling passes (no mappedObjects, as before — unchanged behaviour). undefined keeps the old "could not be resolved or decrypted" reason. The D3 test pins the new text and the absence of the old one.
  7. No exported type, answer member or error code changes. SecretInput (contracts/datasource-admin-service.ts) is unchanged; assertSecretNotEmpty is a module-private function; DatasourceAdminServiceConfig is unchanged. Right.
  8. Callers (brief's question). The console's DatasourceResourcePage.tsx sets body.secret only if (f.secret) on save (L439) and spreads it only when truthy on test (L448): it never sends an empty one. packages/client/src/index.ts carries only the /api/v1/datasources route-map convention — no create/update method and no secret. packages/cli/src/commands/datasource/* call only the external/* read and draft routes. objectui's external/api.ts and MetadataTypeActions.tsx send no secret. hotcrm has no caller of the doors. No test in the tree sends secret: '' to the doors (the turso-driver-config.test.ts hits are the driver's config builder, not the doors). So no first-party caller moves; only a direct API script that sent "" to mean "no change" gets the 400, and the message carries the remedy. Right, and the changeset's "Who moves" says exactly this.

② Semver level

  • Changeset: '@objectstack/service-datasource': patch, title fix(service-datasource)!:, Clause-②: no (narrowing), Metadata-protocol: none, BREAKING banner, Before/Now/Rows-sealed-before/Who-moves, the remedy, and one ADR-0087 marker not-required (no-migration-prescription). The PR body carries the same two declaration lines. Matches what the diff publishes: the package ships src, and the diff moves two of its source files; a non-protocol narrowing takes patch with its BREAKING banner (AGENTS.md rule 3). Not skip-changeset. Right.
  • Clause-②: no (narrowing) is right: one body per door that answered 201/200 now answers 400; no answer member or exported type changes.
  • Metadata-protocol: none — right for this door. secret is the doors' own credential channel, split off the body by splitSecret before the draft is judged; it is not a key of any metadata schema, and the metadata protocol's own guard (protocol-datasource-credentials-ref.ts) refuses an author-written credentialsRef and points to these doors. No key, accepted value or requirement of anything a metadata author writes changes; packages/spec/** is untouched (ruling 6). none therefore holds even though the PR is a narrowing: the narrowed set is an HTTP-door input, not metadata.
  • ADR-0087 disposition not-required (no-migration-prescription): nothing at rest is rewritten — the refused value stored nothing usable, and a row sealed empty before is reported at connect with an empty-credential reason, not migrated. The changeset's "remedy" is a request-shape fix, not a consumer-code FROM → TO migration. The dev reports check-adr-0087-registration --base origin/main exit 0 among the 62 derived gates; that family is not a named CI check-run I can read (it rides in the still-running lint/tooling families), so the disposition is judged here on its text and holds.
  • Check Changeset on the head: success.

③ Boundary flags

  • Dev flags in the os-dev-report: open_questions: []. One out_of_scope_findings entry (class b, named producer): migrateCredential's drop-inline refusal (resolved == null || resolved === '', datasource-admin-service.ts L1179) still says "could not be resolved or decrypted (a missing sys_secret row, or a changed encryption key)" when the row decrypts to empty. Read in the tree: confirmed; the outcome (refuse to drop the last inline copy) is right, only the cause text conflates. Answered: carried by the seat as a noted, unfiled follow-up; not a blocker — it is the same text-only class as ruling 4, on a door the card did not reach.
  • The seat's two rulings already given (reuse 400 DATASOURCE_ADMIN_ERROR; do not refuse whitespace-only) are honoured in the tree and not re-decided here.
  • Ruling 4 answered in the tree: an empty stored value IS reachable (resolve() returns the sealed string as-is, '' included), so the reason was changed. Right.
  • Ruling 1 (branch restart after fix(service-automation): a connection marked reconnect required says the cause that actually happened (#28863) #28955): the PR head is one commit on origin/main 00d0668d; Implemented-by names the harness branch. No concern for this record.
  • No packages/spec/**, no new error code, no new gate: confirmed on the 5-file list.

④ Design judgment

The narrowing read against the published spec on main:

  • Spec. content/docs/data-modeling/external-datasources.mdx §4 declares: credentials never inline; the binder seals cleartext into sys_secret and mints credentialsRef; resolution is fail-closed — a declared ref that cannot be resolved leaves the datasource unconnected with a clear error. The package's own contract SecretInput.value: string says the door's secret is "the cleartext value to encrypt". Nowhere does the spec, the docs or the console declare that an empty secret means anything (clear, keep, or rotate). The capability consumers were sold is "send a credential and it is sealed and used"; an empty one was never a working value — on main it sealed a row that failed closed at the next connect while destroying the working row. The narrowing takes no declared capability away at the consumer; it makes the spec's own fail-closed refusal real at write time instead of at the next reboot. Not an implementation gap narrowed at the consumer: there is no declared "clear the credential" capability (no unbind door exists on main, and null is documented as keep), so nothing is being retired by the back door.
  • The refusal shape holds against the code it cites: same throw-first position and same plain-Error shape as the three sibling body refusals; badRequest relays it 400; the message names secret, the remedy and "Nothing was written", and cannot contain a credential. The create-door guard runs before assertNoDraftCredentialsRef, the update-door guard after the two body checks — a cosmetic ordering difference that only decides which of two simultaneous faults is reported; neither order writes anything.
  • Trying to break it.
    • "secret": { "value": "" } — caught (object passes splitSecret as-is). Pinned.
    • "secret": " " — sealed by ruling; usable as sent.
    • "secret": {} / { "value": null } / a number — pass the guard (value !== ''), then writeSecret → binder.bind → cryptoProvider.encrypt(undefined) → Buffer.from(undefined, 'utf8') throws, answered 400 before putDatasourceRecord and before tryRemoveSecret (the write precedes the removal in updateDatasource). So a malformed shape fails closed with a confusing message but writes and removes nothing — pre-existing, not this narrowing's set, and not the silent rotation the card reports. Observation; not a finding against this PR.
    • Unknown name on PATCH with "" — 404 wins (record read first), consistent with every by-name door.
    • A row sealed empty before this change — still failed-credentials, no driver built, now with the empty-credential reason; the turso driver's "empty bound secret is unset" rule is never reached because the connect path refuses before the driver is built, exactly as on main.
    • Pool in service during a refused PATCH — untouched (no reregisterPool), so the live connection keeps serving on the working credential; this is the point of refusing before the write.
  • Tests drive the real routes over the real service with every seam recorded (ops), so the "nothing written, nothing removed" claim is pinned at the HTTP layer, as the card's 动手的读者 line asked (HTTP-layer pin). The dev's red-on-base / ablation readings are the dev's; the head's package suite rides in Test Core, still running.

Must-fix: none.
Should-fix (non-blocking, carried by the seat): migrateCredential's drop-inline cause text conflates an empty row with a missing/undecryptable one — the dev's own out-of-scope finding; a follow-up card, not this PR.

Implemented-by: claude/project-thread-xqgh8b
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-02T06:53Z

Head 66a746c6, report 6570874270 (on #28928), claim 6569714758. Clause-②: no (narrowing): the contract review is owed and is posted on this head, PASS (6571023263, served at CONTRACT_REVIEW_TIER, claimed 6570912701).

item reading
Shape Title carries !; base main, Fixes #28928. 5 files (+265 / −1): the empty-secret refusal in datasource-admin-service.ts beside the doors' undeclared-key refusals, the connect path's empty-credential status reason in datasource-connection-service.ts, two test files and a @objectstack/service-datasource patch changeset (BREAKING banner, ADR-0087 not-required marker). Metadata-protocol: none, which the review judged right: secret is the doors' own channel, not a metadata key. No packages/spec/**, no gate, no new error code.
Rule Create and update refuse "secret": "" (and {value: ""}) 400 DATASOURCE_ADMIN_ERROR naming secret, before any read or write: no new sys_secret row, no repoint, no delete. null and an absent secret keep the stored credential; a whitespace-only secret is a usable value as sent and is sealed, per the seat's ruling. A stored credential that decrypts to empty is now named as empty in the status reason.
Review No must-fix. Its one should-fix is the dev's own out-of-scope finding, carried by this seat as #29069 (migrateCredential's drop-inline refusal text). Its two observations (an empty secret on the test door, malformed secret shapes failing closed at encrypt) are pre-existing and outside this narrowing.
Evidence 4 pins red on base, 5 controls green both ways, head 111/111; ablation red, restore clean. service-datasource 1152/1152, typecheck, scoped lint, 62/62 derived gates, type-check-debt OK, dogfood battery 383 files at the head. CI on head is still running; auto-merge waits for it.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with every check green or an expected skip.

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

Development

Successfully merging this pull request may close these issues.

Datasources: PATCH with an empty secret replaces the stored credential with an empty one, and the status reason then blames a missing secret row

1 participant