Skip to content

fix(service-datasource)!: refuse every top-level key DatasourceSchema refuses at the datasource test, create and update doors - #28632

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-d8uf4e
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-d8uf4e

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28612

Clause-②: no (narrowing) — three doors refuse top-level keys they silently dropped: POST /api/v1/datasources/test (a top-level password / connectionString, and any other key DatasourceSchema refuses), POST /api/v1/datasources and PATCH /api/v1/datasources/NAME (every key the schema refuses, beyond the two credential keys PR #28607 closed). Each now answers 400 DATASOURCE_ADMIN_ERROR before anything is read, written or probed. Nothing is widened.
Metadata-protocol: none
Skills-docs: none — no page under skills/** or content/docs teaches the admin request body; the refusal text is DatasourceSchema's own, which already ships with the spec and does not move.

BREAKING accept-set narrowing at the datasource admin doors (@objectstack/service-datasource).

Built on PR #28607 (same file, landed as f3d84b33): this branch started from its head and has merged origin/main since, so the diff is only this change.

维护者速读

  • 改了什么:数据源管理接口的三个入口(连接测试 POST /api/v1/datasources/test、创建 POST /api/v1/datasources、编辑 PATCH /api/v1/datasources/NAME)现在会拒收请求体顶层任何 DatasourceSchema 不认识的键:400 DATASOURCE_ADMIN_ERROR,点名这个键,不回显值,什么都不写入、也不发起探测。哪些键算"不认识"、提示怎么说,都在运行时直接取自 DatasourceSchema 自己的报错(即 os compile 打印的那段),服务里没有手写清单。
  • 为什么改:卡 Datasources: the test door and every undeclared top-level key still bypass the refusal DatasourceSchema gives (the rest of #28549) #28612(fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607 合约评审 6555221422 的两条 should-fix):连接测试会悄悄丢掉顶层 password,测的其实是另一个连接;三个入口都会悄悄丢掉 url、host、type、readReplicas、拼错的键等,接口照样答成功。
  • 风险与回滚:schema 声明过的键(origin、description、ssl、autoConnect 等)照常通过;secret 由路由先拆走,仍是唯一的凭据通道。控制台、packages/client、CLI、QA 清单与 dogfood 都不发被拒的键。唯一可能受影响的是把 GET 的应答(含 hasSecret、redactedConfigKeys 等)原样 PATCH 回来的客户端,仓内实测没有这样的生产者。回滚即 revert。
  • 先看:packages/services/service-datasource/src/datasource-admin-service.ts(assertNoUndeclaredTopLevelKey 与 testConnection 开头的新调用);packages/services/service-datasource/src/__tests__/datasource-admin-top-level-credential.test.ts(Datasources: the test door and every undeclared top-level key still bypass the refusal DatasourceSchema gives (the rest of #28549) #28612 的三个 describe);.changeset/28612-datasource-undeclared-top-level-key.md。
  • 两分钟验证:pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-admin-top-level-credential.test.ts → 25/25 通过。
  • 需要你做的:无,席位落地(收窄需先过合约评审)。

What changes

One check, three doors (packages/services/service-datasource/src/datasource-admin-service.ts). PR #28607's private assertNoTopLevelCredential(name, body) and its hand list TOP_LEVEL_CREDENTIAL_KEYS become assertNoUndeclaredTopLevelKey(heading, body), with no list:

  • It takes the body's own top-level key names (those whose value is not undefined), gives each the value null, parses that skeleton through DatasourceSchema, and keeps only the strict shape's root unrecognized_keys refusal. So the key set is exactly what the schema refuses at run time, and the message is the schema's own (its guidance rows for password, connectionString, url, host, connector, the retired blocks, its alias table for type and the like, and its generic unknown-key text for anything else). Values never reach the parse, so the answer cannot echo a credential.
  • testConnection (the unsaved-draft probe): first statement, before the driver requirement, the config judgement, the record judgement and the probe. It throws, so the route answers 400 DATASOURCE_ADMIN_ERROR (the card asks for that code), under the heading this door already gives a draft it refuses: This connection draft is not a valid datasource.
  • createDatasource and updateDatasource: the same position as fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607's call (create: after the name rule and the draft credentialsRef refusal; update: after the 404 and the code-defined refusal, before the merge, on every patch including a label/active-only one), under the heading Invalid datasource 'NAME'.

Door-only fields. The service's DatasourceDraft declares name, label, driver, schemaMode, config, external, pool, active: all declared by the schema. The one field the doors accept on purpose that the schema does not carry is secret, the credential channel: the route's splitSecret removes it before the service sees the draft, so it never reaches the check (controls below, on all three doors). No other door-only field exists.

Keys the schema declares pass. origin, description, ssl, autoConnect, name on an edit, and the metadata protection fields are declared, so the key check lets them through even where the projection leaves them out (origin is still stamped runtime by the door). That is the card's scope: refuse what the schema refuses.

Tests

Three new describe blocks in src/__tests__/datasource-admin-top-level-credential.test.ts, same harness as #28607 (real routes over HonoHttpServer, real DatasourceAdminService on in-memory stores, entitled-caller.fixture). The probe seam now records probe in the op log, so "nothing probed" is read, not assumed.

  • Test door refusals (status 400 + error.code DATASOURCE_ADMIN_ERROR + success: false, exact message = heading + DatasourceSchema's refusal derived in the test, value absent, op log empty so no probe ran): top-level password, connectionString (URL userinfo), foo, and password beside a secret.
  • Create refusals (same envelope, exact message, nothing stored or minted): foo, url (URL userinfo), type (the schema's alias row), readReplicas (a retired block), and foo + host + password in one answer.
  • Edit refusals (same envelope, exact message, stored record unchanged): foo with a config, foo on a label-only edit, hasSecret (a GET answer key) on a label-only edit.
  • Controls: the test door still probes a valid draft whose credential arrives as secret (200, ok: true, one probe); create with description and origin (declared) answers 201 and stores origin: 'runtime'; an edit carrying name and origin answers 200. fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607's controls (secret-bound create 201, edit keeping its bound ref 200, config.password refusal) stay green.
Red first, green, ablation

Acceptance notes

  • The GET round-trip keys are refused, not tolerated. fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607's contract review noted that a general closed body "must tolerate the GET round-trip keys origin, hasSecret, redactedConfigKeys, definedIn". Measured: origin is declared by the schema and passes. The other three (and the list row's status / statusReason) are not declared, so the card's item 2 ("every top-level key DatasourceSchema refuses") refuses them, and no producer in the repo sends them back: the Console editor's draftBody builds a fresh body (label, driver, schemaMode, config, external, secret) and its test call sends driver, config, secret; packages/client has no create/update/test method; the CLI datasource commands are read-only; the QA checklist bodies and the dogfood suites send declared keys only. Tolerating them would be a consumer-side allowance for keys nothing writes. The changeset's remedy line says not to send them back.
  • Keys the schema declares that the doors still drop. description, ssl, autoConnect (and the protection fields) pass the key check and are then left out by toRecord / the update merge, as before. They are accepted by the schema, so they are outside this card ("refuse what the schema refuses"); reported to the seat as an out-of-scope finding.
  • Producers measured (order's stop-and-report check): none sends a top-level key the schema refuses. Searched: ui/packages/app-shell/src/views/metadata-admin/datasource/, packages/client/src, packages/cli/src, packages/qa/dogfood/test, docs/qa/platform-checklist/areas, examples/, apps/, skills/, content/docs.
  • Test door answer shape. The probe's existing refusals (no driver, bad config, invalid record) answer 200 with result.ok: false. This one answers 400 DATASOURCE_ADMIN_ERROR, as the card's item 1 asks ("the same DATASOURCE_ADMIN_ERROR"): a key that changes what is being tested is a malformed request, not a failed connection.
  • The Datasources: POST /api/v1/datasources accepts and silently drops a top-level password or connectionString that os compile refuses, answering 201 #28549 changeset says "Other unknown top-level keys are still dropped as before". That changeset is not edited; this PR's changeset carries the superseding sentence.

Verification

All readings on head f1438f52 (the merge of origin/main at de3fafcb, after #28607 landed as f3d84b33; the diff against main is the three files above).

  • Package: pnpm --filter @objectstack/service-datasource test → Test Files 67 passed (67), Tests 1125 passed (1125). pnpm --filter @objectstack/service-datasource typecheck exit 0; tsc --listFiles includes the test file.
  • Derived gate families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 61 families (same list before and after the merge), each run after a full pnpm build --concurrency=2 (117/117) with its exit code captured before any pipe and stdin from /dev/null: 61 × exit 0. --ran: ✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED.
  • Hand gates: node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD exit 0; node scripts/check-platform-checklist.mjs --changed-from origin/main exit 0 (no anchored file in the change set); pnpm exec eslint --no-inline-config packages/services/service-datasource exit 0, no findings. The repo-wide pnpm lint is CI's.
  • Full dogfood (owed by the ! and the (narrowing) arm), after the full build, under the lock, pnpm --filter @objectstack/dogfood test → Test Files 1 failed | 364 passed | 3 skipped (368), Tests 1 failed | 2938 passed | 28 skipped (2967). The one failure is not this change: route-ledger-live-mount-parity.dogfood.test.ts reports runtime/route-ledger.ts: GET /packages/:id/customizations — LEDGERED BUT NOT MOUNTED, a runtime route that arrived with feat(runtime,spec): GET /packages/:id/customizations answers a base's overrides, extensions and switched-off items in one read #28595 (519089e1) in the merged main. This diff touches only service-datasource. Reported to the seat; not fixed here.
  • CI families (Test Core incl. test:repo, the lint farm, the type-check lanes) are read off this PR's check-runs by the seat.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5nv4Jd9m4jHpZgRJVT

…ring at the datasource admin write doors

POST /api/v1/datasources and PATCH /api/v1/datasources/:name projected the
body onto the record's known keys, so a top-level password or
connectionString vanished and the write answered success while os compile
refuses the same datasource by name. Both doors now refuse either key before
anything is written, with DatasourceSchema's own refusal for that key under
the heading the record judgement uses. Only key names are judged, so the
answer never echoes the credential.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5moUefysT3ba6SpdYY
… refuses at the datasource test, create and update doors

The unsaved-draft probe (POST /api/v1/datasources/test) and the two write
doors built their record from the known keys only, so a top-level password
on the probe, and any other key the strict shape refuses (url one level too
high, an alias such as type, a retired block, an unknown key) on all three
doors, vanished without a word. The doors now refuse them 400
DATASOURCE_ADMIN_ERROR with DatasourceSchema's own root unrecognized-key
refusal, derived from the body's key names at run time; values are never
judged or echoed.

Co-Authored-By: Claude <noreply@anthropic.com>
…el key refusal

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>

# Conflicts:
#	packages/services/service-datasource/src/__tests__/datasource-admin-top-level-credential.test.ts
#	packages/services/service-datasource/src/datasource-admin-service.ts
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via connectionString (literal, a string literal in TOP_LEVEL_CREDENTIAL_KEYS))
What this run could not see
  • the SDK route bridge reached 60 of 214 client-bound route-ledger rows — the other 154 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 154: 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; 99 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 d77dc07a5e5a4b5478763fb3a2126913c92f8c01 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 d77dc07a5e5a4b5478763fb3a2126913c92f8c01 → 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

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f1438f52c4d31ddd71759cafd70dda5e3f9bc3f3
Local-runs: none
Scope: packages/services/service-datasource/src/datasource-admin-service.ts, packages/services/service-datasource/src/tests/datasource-admin-top-level-credential.test.ts + packages/spec/src/data/datasource.zod.ts, packages/spec/src/shared/strict-object.ts, packages/spec/src/kernel/metadata-protection.zod.ts, packages/services/service-datasource/src/admin-routes.ts, packages/services/service-datasource/src/contracts/datasource-admin-service.ts, packages/spec/src/api/error-code-ledger.zod.ts, .changeset/28549-datasource-top-level-credential.md, ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx, packages/client/src/index.ts, packages/cli/src/commands/datasource/introspect.ts, packages/cli/src/commands/datasource/list-tables.ts, packages/cli/src/commands/datasource/validate.ts, docs/qa/platform-checklist/areas/integration-system.json, packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts, packages/qa/dogfood/test/datasource-restore-environment-rows.dogfood.test.ts, packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts, packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts

Inputs read: card #28612 (body, claim 6555258668, dev report 6556117106), parent #28549 (body, triage, claim, dev report 6555151884, landing record) and the parent's contract review 6555221422 on PR #28607, PR #28632 body, file list and net diff at the head above, the 45 check-runs on that head, and the cited code at origin/main (d77dc07a5). The narrowing face has no path hits; the floor is every non-prose path of the diff, listed first on the Scope: line. The changeset is read for ②, not reviewed. Nothing was checked out, built or run; the one mirror of the Console page in the sibling checkout /home/claude/objectui was compared byte-for-byte to the ui/ copy on origin/main (identical) and is not a run.

① Derived judgments

  1. Accept-set narrowing at three doors, generalised from two keys to the schema's whole refused set — right. On main the three doors still project through known keys only: testConnection builds its probe from this.toRecord({ ...input, name: PROBE_DRAFT_NAME }), createDatasource spreads this.toRecord(input), updateDatasource merges label/driver/schemaMode/config/pool/active/external by name. The fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607 check assertNoTopLevelCredential filtered a hand list TOP_LEVEL_CREDENTIAL_KEYS = ['password', 'connectionString']. The diff deletes that list and the method, and assertNoUndeclaredTopLevelKey(heading, body) takes Object.keys(body) (values not undefined), parses the null-valued skeleton through DatasourceSchema and keeps only the root unrecognized_keys issue. So the refused set is exactly the set DatasourceSchema refuses at run time, with no second copy. The premise of the card (item 2) holds at origin/main and the diff closes it at all three doors.

  2. The key set is the schema's, read from the live spec. DatasourceSchema is a lazySchema whose body is strictObject({ surface, aliases, guidance, history }, shape) with a .superRefine(...) on the result, and strictObject is closedObject(z.object(shape, { error: strictObjectError(...) }).strict()). A .strict() object emits one root unrecognized_keys issue naming every unknown key whatever the declared fields do with their null values, so a declared key failing its type (driver: null) never masks the refusal and never reaches the kept issues (filtered on code === 'unrecognized_keys' && path.length === 0). No retired spelling sits in the shape as a tombstone (retiredKey( does not occur in datasource.zod.ts): readReplicas, replicas, capabilities, readOnly, retryPolicy, retry, healthCheck, healthcheck are all guidance rows, so each is refused at the root like foo. The declared keys that pass are the shape's: name, label, driver, config, pool, ssl, description, active, autoConnect, schemaMode, external, origin, and the MetadataProtectionFields (_lock, _lockReason, _lockSource, _provenance, _packageId, _packageVersion, _lockDocsUrl). Right.

  3. The refusal text comes from the schema's own guidance, one spelling — right. The message is the unrecognized_keys issue's message, composed by strictObjectError from the schema's aliases (type → driver, connection/options → config, enabled → active, tls → ssl, …) and guidance rows (host/port/database/user/username/filename/url/connectionString → belongsInConfig, connector, password, the retired blocks), the same path os compile prints through. The service restates nothing; the test's specRefusal derives the expected text from DatasourceSchema at run time and throws fixture premise broken if the spec stops refusing a key, so neither a re-inlined copy nor a spec drift can pass quietly. Right.

  4. The refusal code is already ledgered at this door — right, no published-surface addition. DATASOURCE_ADMIN_ERROR is the @objectstack/service-datasource row in packages/spec/src/api/error-code-ledger.zod.ts ("lifecycle/validation refusal from the datasource-admin service"), and admin-routes.ts badRequest answers every plain Error thrown by the admin service as 400 with SERVICE_ERROR_CODE['datasource-admin'] unless it carries the 503 or 404 envelope; the new throw carries neither. All three routes (POST /test, POST, PATCH /:name) sit inside that try/badRequest. No code, status or envelope field is added.

  5. The Test door moves from a projected probe to a thrown refusal — right, and the card rules the shape. On main the test door's own refusals (no driver, bad config, invalid record, lend refusal) answer 200 with result.ok: false; the diff makes an undeclared key a 400 DATASOURCE_ADMIN_ERROR thrown as the first statement, before the driver requirement and before any probe. Item 1 of the card asks for "the same DATASOURCE_ADMIN_ERROR", and the distinction holds: a key that changes what is being tested is a malformed request, not a failed connection. The Console's testDraft reaches both shapes correctly: its api() helper throws the envelope's error.message on a non-2xx, and the catch renders it as a failed test with the server's text. The heading This connection draft is not a valid datasource. is the one recordRefusal already uses at that door. Right.

  6. No legitimate producer sends a key the door now refuses — right, measured in the cited code. Console DatasourceResourcePage.tsx: draftBody builds a fresh body (label, driver, schemaMode, config, optional external: {}, optional secret); openEdit copies the GET detail field by field into EditorForm (name, label, driver, schemaMode, storedSchemaMode, config, secret: '') and keeps hasSecret in component state only for the placeholder text, so no GET record is ever sent back whole; the test-draft call sends driver, config, secret; the row "Test" goes to POST /:name/test, a different service. The /home/claude/objectui mirror of that page is byte-identical. packages/client exposes only datasources.external.* (no create, update or test method). The CLI datasource commands (introspect, list-tables, validate) are read-only. The four dogfood suites that POST create bodies send name, label, driver, config; every PATCH they send is { label }. The QA checklist's round-trip clause (integration-system, the ADR-0100 §B3 verify) says "PATCH … with the response's config back unmodified", a declared key, not the whole response; its change log already records that "PUT the response back" was corrected. No seed under examples/, apps/ or scripts/ posts to these doors.

  7. The GET round-trip keys hasSecret, redactedConfigKeys, definedIn (and list-row status/statusReason) are refused, not tolerated — right, and the parent review's suggestion is overruled with cause. Those keys are declared nowhere as input: not in DatasourceDraft, not in DatasourceSchema, and no producer sends them (item 6). The one key the GET answer carries that an editor might echo and the schema declares, origin, passes the check and is stamped by the door (control: origin: 'code' on create stores runtime). Tolerating undeclared keys here would be a consumer-side allowance the spec does not make, which is the exact posture ④ forbids. The changeset's remedy line names the keys not to send back. Right.

  8. Door-only field named with its reason — right. The PR body names secret as the one key the doors accept that the schema lacks, and the cited code agrees: splitSecret removes secret from the body before testConnection, createDatasource and updateDatasource see the draft, so it never reaches the check (controls on all three doors). DatasourceDraft declares name, label, driver, schemaMode, config, external, pool, active, every one declared by the schema. No other door-only field exists.

  9. Ordering at each door — right. Test: first statement, under an input && typeof input === 'object' guard (a null body still answers the driver is required ok: false). Create: after assertValidName and assertNoDraftCredentialsRef, before the driver requirement — fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607's position. Update: after the 404 and the code-defined refusal, before the merge, on every patch including a label/active-only one — the right reading of the [finding] POST /api/v1/datasources (and the PATCH door) never run DatasourceSchema's cross-field refinements — a mongo credentialsRef pairing that os build and PUT /meta refuse is accepted 201, and the bound secret is silently unused #21058 exemption, as the parent review already judged.

  10. No value reaches the judgement or the answer — right. Only key names are parsed (null values); the tests assert the placeholder password and the DSN host are absent from the message, the record store and secret store untouched, and the op log empty (the probe seam now records probe, so "nothing probed" is read, not assumed).

  11. Exports and DatasourceDraft unchanged — right. The deleted constant and the renamed method are module-private; the contract file is not in the diff.

  12. Metadata-protocol: none and Skills-docs: none — right. DatasourceSchema does not move; no page under skills/** or content/docs teaches the admin request body (the only datasources/test mentions are release notes). Flag docs affected by code changes concluded success.

② Semver level

.changeset/28612-datasource-undeclared-top-level-key.md: '@objectstack/service-datasource': patch, a BREAKING banner, Clause-②: no (narrowing) and Metadata-protocol: none as line-initial declarations, and exactly one ADR-0087 marker (not-required (no-migration-prescription)) with its reason. This matches what the diff publishes: a non-protocol accept-set narrowing on a released service package, no key added, removed or re-shaped, nothing at rest to migrate (a refused body was never stored). The level and disposition are the same the #28549 entry shipped under, and Check Changeset and Governed Surface Queue Guard both concluded success on this head. The PR body carries Clause-②: no (narrowing) at the start of a line, the one spelling this face is keyed on, and the changeset repeats it exactly. The entry's last bullet supersedes the #28549 entry's "Other unknown top-level keys are still dropped as before" sentence by name; that entry is left unedited, which is right for a changeset already landed.

③ Boundary flags

open_questions: []. The report's four deviations and two out-of-scope findings, each answered:

Check-runs on f1438f52 at the time of this record: 45 runs, 0 failures. 32 completed — 19 success (Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Lint · ESLint, Lint · docs & code guards, Lint · CI, release & engine gates, Lint · PM dispatch-gates, three scoped lint lanes, Type Check · debt ledger, Type Check · source gates, Dogfood Regression Gate, Test UI, Flag docs affected by code changes, Plan Test Core shards, Auto Label, filter) and 13 skipped (UI build/lint/typecheck lanes, opt-in harnesses, the conformance job). 13 still in_progress: Test Core 1–8, Build Core, Type Check · workspace, Type Check · consumer gates, Lint · repo & tooling gates, Live E2E UI. Their conclusions are the gate verdicts for their families; this record does not pre-empt them and the queue stays gated on them. No red on this head is the #28622 dogfood file — there is no red on this head at all. The dev's local readings (service suite 1125/1125, red-first 12/25, three single-door ablations, 61 derived families, full dogfood with the one known main failure) are the PR's account, read but not relied on.

④ Design judgment

The narrowing, judged against the published spec on main: DatasourceSchema is a closed shape whose history line says in so many words that until it was closed "these were dropped silently — a connection key written one level too high left the datasource connecting on driver defaults rather than failing". The build door and the meta door already refuse every undeclared top-level key by name; the admin door's three entrances alone still projected and dropped them, and the Test door answered about a different connection than the one described. Nothing declared is taken away at the consumer: DatasourceDraft never carried any refused key, no stored record ever held one, every key the schema declares still passes, and secret stays the credential channel. This makes the spec's own refusal real at the last three doors that did not give it. Right.

Attempts to break it, each read in the diff and the cited code:

  • A body with only declared keys ({ driver, config } on the test door, { label } on PATCH): present is non-empty, the skeleton parse fails on types but emits no root unrecognized_keys, detail is empty, the check returns. The Console's every body is of this shape. Holds.
  • Several refused keys at once (foo, host, password): one unrecognized_keys issue names all three, with a guidance line per key; the test pins the exact text. One answer, not three round-trips. Holds.
  • An alias (type) and a retired block (readReplicas): both are refused through the aliases / guidance tables, never through the shape, so they arrive as root unrecognized_keys and are caught; pinned. Holds.
  • secret beside password on the test door: splitSecret removes secret, the draft still carries password, refused before the probe (op log empty). Holds.
  • A non-object body (array or string reaching splitSecret): the rest-spread yields index keys, now refused at the root as unrecognized keys. Before, create threw on the name, the test door answered ok: false, and PATCH merged nothing and answered 200. A 400 for a malformed request is the better answer at every door. Holds.
  • A code-defined datasource patched with foo: the code-defined refusal fires first; the row is read-only before the body is judged. Acceptable ordering, as at fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607.
  • Spec drift (a refused key later declared): detail empties and the door opens for that key; the test's specRefusal throws in that case, so the pin goes red rather than the door going quiet. Sound.
  • origin / name on PATCH, origin: 'code' on POST: declared, pass, and the door stamps origin: 'runtime' and keeps existing.name as before; pinned by the controls. The spec's "never accepted from client input" is honoured by ignoring, not refusing, which is the status quo and what the GET round-trip needs. Holds.
  • Direct in-repo callers of the three doors: none outside admin-routes.ts (searched the live tree); no caller hands a StoredDatasource with definedIn or a GET detail to a door.

Findings:

  • should-fix (follow-up card, not this PR): keys DatasourceSchema declares that the doors still drop — ssl, autoConnect, description (and the protection fields) pass the new check and are then left out by toRecord and the update merge. ssl is honoured by the driver factory on a stored record, so an admin-created datasource that asks for TLS connects without it, silently; POST /api/v1/datasources answers 201 for a body the stored row does not match. That is a declared capability not delivered at the consumer, an implementation gap of the kind ④ says must be implemented or retired, never narrowed. It is outside this card's "refuse what the schema refuses" and the dev reported it with carrier: none; it should be filed so it is not lost in a PR's acceptance notes.
  • No must-fix.

Implemented-by: claude/project-thread-d8uf4e
Reviewed-by: session_01FEkQ5kE5BrPUPNMMyv6oCH

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

席位核对 · ACCEPT · PR #28632 · 2026-11-01T09:47Z

Head-sha: f1438f52c4d31ddd71759cafd70dda5e3f9bc3f3

维护者速读(席位终稿)

  • 改了什么:数据源管理的连接测试、创建、编辑三个入口,现在拒收请求体顶层任何 DatasourceSchema 不认识的键:400 DATASOURCE_ADMIN_ERROR,点名这个键,不回显值,不写入也不探测。拒收的键集和提示都在运行时取自 DatasourceSchema 自己的报错,没有手写清单。
  • 为什么改:以前三个入口都悄悄丢掉这些键还答成功;连接测试会丢掉顶层 password,测的其实是另一个连接(Datasources: the test door and every undeclared top-level key still bypass the refusal DatasourceSchema gives (the rest of #28549) #28612)。
  • 风险与回滚:schema 声明过的键照常通过拒收检查;secret 仍由路由先拆走。仓内没有生产者发被拒的键(契约复核逐一核过 Console、client、CLI、QA、dogfood)。注意:ssl、autoConnect、description 虽能通过,但仍会在存储时被丢,这是 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 的修复范围,已派发。回滚即 revert。
  • 先看:datasource-admin-service.ts 的 assertNoUndeclaredTopLevelKey 与 testConnection 开头的调用。
  • 两分钟验证:pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-admin-top-level-credential.test.ts → 25/25。
  • 需要你做的:无。席位按常设授权落地。
  • 席位意见:契约复核 PASS;键集取自 schema 本身,后续 schema 变化自动跟随。可落。

核对六项

  1. 形态:draft → main;Fixes #28612,本 PR 交付卡的两项期望,合并即关卡正确;其余闭合关键词零命中(已扫)。文件 3 个,在认领申报面内。
  2. 前提实测:红先在 fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607 head 0cc981aa 上 12/25 红(测试门 200、创建 201、编辑 200);消融逐门恢复(测试门 4 红、创建 9 红、编辑 6 红),每次复原 blob 回 HEAD。
  3. 门禁:本 head check-runs 48 个,35 success、13 skipped、0 失败。dev 本地 f1438f52:61/61 派生门禁 exit 0,包测 1125/1125。dogfood 1 红为 route-ledger-live-mount-parity(GET /packages/:id/customizations,feat(runtime,spec): GET /packages/:id/customizations answers a base's overrides, extensions and switched-off items in one read #28595 合入 main 所致,p0 main red: Dogfood Regression Gate — GET /packages/:id/customizations is ledgered but not mounted (#28595), so the route 404s at runtime #28622 由 1 号席位处理),本 diff 只动 service-datasource,不归本 PR。
  4. 接受的偏差:GET 回显键 hasSecret / redactedConfigKeys / definedIn 被拒收而非容忍,依卡的期望 2 与生产者实测(无发送方);契约复核认可,推翻 fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors #28607 复核的「容忍」建议。三次推送(两次合 main),读数皆在 f1438f52,接受。
  5. 范围外去向:声明过的 ssl / autoConnect / description(及保护类字段)通过检查后仍被 toRecord 丢弃 → filed 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(Datasources: the test door and every undeclared top-level key still bypass the refusal DatasourceSchema gives (the rest of #28549) #28612 的子卡,security,已派发,基于本 PR 叠加)。Metadata-protocol: none,无 Skills-docs: 欠账。
  6. 落地条款:Clause-②: no (narrowing) ⇒ 契约复核达档 PASS 在案:comment 6556237821(CONTRACT_REVIEW_TIER,同 head f1438f52)。check-governed-merges --pr 28632:NOT governed,210 行。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 09:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 1, 2026 09:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 819b101 Nov 1, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-d8uf4e branch November 1, 2026 10:09
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: the test door and every undeclared top-level key still bypass the refusal DatasourceSchema gives (the rest of #28549)

1 participant