Skip to content

fix(objectql)!: a non-system write to a secret field may only seal a new value or keep its own record's ref - #27695

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-27582-secret-ref-own-record-only
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-27582-secret-ref-own-record-only

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Requested by Sales · project thread

Fixes #27582

Clause-②: no (narrowing) — a non-system write to a secret field refuses a value already spelled secret: unless it is the reference that record's column already holds (so a create always refuses one), where it stored such a value as given before. Nothing is widened.
Metadata-protocol: none
Skills-docs: none — nothing a metadata author writes changes; the rule is inside the engine's credential write path, and the system-context page gains the row CI requires for the new elevation read.

维护者速读

  • 改了什么:secret 字段收到形如 secret: 的已存引用时,非系统写入只能保留本记录这一列已存的那个引用(保留即不改写该列);新建记录不接受任何引用;其他引用一律以 VALIDATION_ERROR / 400 拒绝,且无论引用指向什么,答复都相同。系统上下文写入保持原样放行。
  • 为什么改:分诊裁定"失败即拒绝:非系统写入只能封存新值或保留本记录自己的引用"。此前已是引用的值对所有调用方原样放行。
  • 风险与回滚:拒绝发生在任何凭据行或记录写入之前;判断只读本次写入自己的已存行(按 id 更新用已读的前像,按条件更新用已有的匹配行备忘),不查 sys_secret。界面回传的掩码、null 清空、新值加密、读取掩码都不变。回滚只需 revert 本 PR。
  • 先看:packages/objectql/src/engine-secret-internal-fields.ts(encryptSecretFields 的引用分支与 SecretRefWriteError),packages/objectql/src/engine.ts(三处调用点各自传入本次写入的已存行),packages/objectql/src/secret-field-own-ref-write.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/secret-field-own-ref-write.test.ts,预期 19/19 通过。
  • 需要你做的:无,席位落地。

What changes

The rule, in the secret-fields path #27572 already uses (encryptSecretFields, whose body lives in engine-secret-internal-fields.ts): a non-system write may only seal a new value or keep its own record's ref.

  • The ref branch. A secret value that is already a secret: ref used to be left as it was for every caller. Now:

    • The system principal (context.isSystem === true, the engine's existing marker) keeps that pass-through, unchanged.
    • Any other caller's ref is kept only when every row the write lands on already holds exactly that value in that column. Then the key is dropped, as the echoed read mask is, so the statement does not rewrite the column at all.
    • Anything else is refused with the new SecretRefWriteError: ADR-0112 VALIDATION_ERROR / 400, the standard-catalog pair the sibling credential refusal (EmptyCredentialWriteError) and the connector credential door already use. It is thrown before any credential row or record is written. The message names the column and never the value, and nothing about the handle is looked up, so the answer does not depend on what the ref names.
  • Whose rows are "its own". encryptSecretFields takes a StoredRowsReader, and each of its three call sites in engine.ts says what its own rows are:

    • the insert passes none (a new record has no ref of its own);
    • the by-id update passes the pre-image it has already read;
    • the predicate update passes the matched-row memo (readPriorRows).

    The reader is called only when a non-system payload carries a ref, so every other write reads exactly what it did before.

  • Every engine write door. Single and batch insert, insertMany (the refused row is reported on its own and the others land), the by-id update and the predicate update. So every REST door that writes through them is covered too: create, createMany, import, PATCH.

  • Docs. content/docs/permissions/system-context.mdx gains row 23e for the new elevation read, and its site register is regenerated (check:system-context-census requires both).

  • Not in this change. Values stored before this change are not re-examined. The write preview (ObjectQL.validate()) runs no credential pass, as before.

Tests

  • packages/objectql/src/secret-field-own-ref-write.test.ts (new, 19 cases): a real engine over an in-memory driver, on every door listed above.
    • Refused: a non-system create, batch create, insertMany row, or beforeInsert-hook value naming another record's ref; the same with no context at all; a by-id update naming another record's ref; a column that holds nothing; a predicate update where one matched row does not hold the ref; a predicate that matches no row. Each refusal is asserted on code, status, httpStatus, object and field. A ref naming an existing row and one naming nothing get an identical message, which does not contain the handle.
    • Accepted: keeping the record's own ref, as an extra key or as the only key, and the statement does not carry the column; the echoed mask; a beforeUpdate hook that writes the stored row back; a predicate ref that every matched row holds; a new value; and the system principal's pass-through on create, by-id and predicate writes.
  • packages/qa/dogfood/test/secret-field-own-ref-write.dogfood.test.ts (new, 5 cases): the same rule at the REST data door of a booted stack with a provider wired.
    • Refused: POST /data/:object, /createMany and /import, each 400 VALIDATION_ERROR or a per-row import error, and nothing written. A PATCH naming another record's ref is refused with the same status, code and message as a PATCH naming a ref that resolves to nothing, and the stored value stays.
    • Accepted: a PATCH echoing the mask the GET returned (what the form sends back), and a PATCH carrying the record's own ref, leave the stored ref and the credential unchanged.
    • Unchanged: a new value still seals, and a system-context write still takes a ref as given.
  • error-http-status-spelling.test.ts registers the new refusal (both status spellings agree).

Acceptance notes

Verification

Every reading below is at head 75e18b19, after origin/main 5e2f386 was merged in, unless it names another commit.

Red first: the tests were committed before the fix (bf7eff9)
  • secret-field-own-ref-write.test.ts: Tests 11 failed | 8 passed (19).
  • secret-field-own-ref-write.dogfood.test.ts: Tests 3 failed | 2 passed (5).
Green: build, typecheck, the whole objectql suite, and the credential-channel dogfood files

The first four rows ran under one verify-lock hold; the two eslint rows ran after it. Lock verdict: command-exit 0.

Step Result
turbo run build --filter='@objectstack/dogfood^...' exit 0
pnpm --filter @objectstack/objectql typecheck exit 0
pnpm --filter @objectstack/objectql exec vitest run Test Files 491 passed (491) · Tests 9224 passed (9224)
dogfood, 9 credential-channel files: this PR's file plus secret-field-delete-release, secret-field-orphan-rows, write-response-credential-mask, flow-credential-channel, flow-trigger-record-credential-mask, approval-snapshot-credential-field, admin-credential-lifecycle and webhook-meta-door-credential-redaction Test Files 9 passed (9) · Tests 60 passed (60)
eslint --no-inline-config packages/objectql exit 0
eslint --no-inline-config on the new dogfood file exit 0
Ablations: each clause of the rule goes red when removed, and each restore is proven blob == HEAD

Each leg runs scripts/ablation-replace.mjs on one clause, then runs the new unit file (19 cases). The restore is then proven: blob 8796f4077121 (engine-secret-internal-fields.ts) and 0af248405c8f (engine.ts) == HEAD, and git diff HEAD is empty.

Leg Mutation Result
A1 the system-principal check passes every caller 13 failed | 6 passed (19)
A2 the "no stored row" clause removed (--delete) 7 failed | 12 passed (19)
A3 "every matched row holds it" weakened to "some row" 1 failed | 18 passed (19)
A4 any stored ref counts, not only the same value 3 failed | 16 passed (19)
A5 a kept ref is not dropped from the statement 2 failed | 17 passed (19)
A6 the by-id update passes no stored row 3 failed | 16 passed (19)
A7 the refusal message carries the value 2 failed | 17 passed (19)
D (dist) A1 held in source, objectql rebuilt, then the REST dogfood file run ablation-dist-preflight marker present (exit 0) · 3 failed | 2 passed (5) · after restore and rebuild, preflight marker absent (exit 0) · 5 passed (5)

Two first attempts did nothing and were redone; neither counts above. On an earlier head, A2 used an empty replacement where the tool requires --delete, and A5 used a replacement that was a substring of its anchor. The tool refused both, so no mutation was applied. The legs above are the corrected forms, all re-measured at 75e18b19.

Gates: 94 derived, 94 run, each exit code recorded, 0 NOT-MEASURED
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 94 commands at 75e18b19, the same list as before the merge.
  • Every command ran, and each exit code was captured before any pipe. --ran reconcile: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero (all 94 carry an exit code and none is 3). All 94 exit 0.
  • check:type-check-debt first answered 3 (prerequisite not met): its closure build timed out on the shared lock queue, so nothing was measured. It was re-run inside this run's own lock hold and exited 0 (0 raw tsc error(s) total).
  • Before the merge, at 2f31768, check:skill-examples and check:dual-build-cjs-loads first answered 3 (dist missing for packages outside this diff). They were built and both exited 0. After a container restart, check:dual-build-cjs-loads answered 1 once, on four unrelated packages whose interrupted builds had left no .d.ts. After the full closure build it exited 0 (112 published require entry point(s) across 69 package(s) load).
  • check:system-context-census was red until row 23e and its register line were added (--write). It exits 0 at this head.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json e35db835b41ff5afd4c366500796453016a89c71.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 19 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 e35db835b41ff5afd4c366500796453016a89c71 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 e35db835b41ff5afd4c366500796453016a89c71 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…et narrowing (#27582)

The refusal of a stored-reference value from a non-system write is a
narrowing of what the write door accepts, so the changeset now says
Clause-②: no (narrowing), carries the BREAKING banner with its
before / now / unchanged / who-sees-it and remedy, and records its
ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title fix(objectql): a non-system write to a secret field may only seal a new value or keep its own record's ref fix(objectql)!: a non-system write to a secret field may only seal a new value or keep its own record's ref Oct 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head ed18660 · 2026-10-29T20:26Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ed18660403e13dfee68d9f80d277ab49bebd5cf7
Local-runs: none
Scope: packages/objectql/src/engine-secret-internal-fields.ts, packages/objectql/src/engine.ts, packages/objectql/src/error-http-status-spelling.test.ts, packages/objectql/src/secret-field-own-ref-write.test.ts, packages/qa/dogfood/test/secret-field-own-ref-write.dogfood.test.ts, content/docs/permissions/system-context.mdx, .changeset/27582-secret-field-own-ref-only.md + packages/objectql/src/secret-fields.ts, packages/core/src/security/assemble-execution-context.ts, packages/metadata-protocol/src/protocol.ts, packages/core/src/utils/import-runner.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/batch-routes.ts, packages/services/service-automation/src/connector-credentials.ts, packages/services/service-automation/src/flow-credential-channel.ts, packages/plugins/plugin-auth/src/sso-client-secret.ts, packages/plugins/plugin-webhooks/src/webhook-headers.ts, packages/plugins/plugin-webhooks/src/webhook-secret.ts, packages/cli/src/utils/secret-reference-union.ts, ui/packages/app-shell/src/views/metadata-admin/widgets.tsx, .claude/agents/os-dev.md, .claude/skills/pm-dispatch/references/review-checklist.md, .github/workflows/ci.yml

Read at 2026-10-29T20:40Z: card #27582 (body, triage 6498936750, claim 6500815103, os-dev-report 6506504333 at 2026-10-29T19:36Z), PR #27695 body and file list, the net diff origin/main...pr27695b (7 files, +597/−6; the head's last commit moves only the changeset, the code is 75e18b1's), and the head's 54 check-runs: 39 success, 15 skipped, 0 other. Every required context that runs on a PR is green (Lint & Repo Gates, TypeScript Type Check, Test Core 1–8 and aggregate, Build Core, Governed Surface Queue Guard, Check Changeset twice, the later at 2026-10-29T20:27Z); the dogfood shards are skipped by ci.yml's own two-speeds rule (github.event_name != 'pull_request'), so no PR run measures the dogfood corpus. Security card: this record states rules and code, never a procedure.

① Derived judgments

  • The rule, as ruled and as coded. Triage ruled fail-closed: a non-system write may only seal a new value or keep its own record's ref. The ref branch of encryptSecretFields now does exactly that: context?.isSystem === true keeps the pass-through; otherwise the write's own stored rows are read once (ownRows ??=), the ref is kept only when there is at least one own row and every own row holds exactly that value in that column, the key is then dropped (as the echoed mask is), and anything else throws SecretRefWriteError before any sys_secret row or record statement. Nothing is looked up about what the ref names, so the two refusals (a ref that resolves, a ref that resolves to nothing) are one answer, and the message names the column and never the value (unit test pins message equality and non-containment; dogfood pins it over REST).
  • Completeness over the engine's write doors. engine.ts issues a record statement with a caller payload at exactly three exits, and each is behind a call site of encryptSecretFields: the insert loop (single, batch, and insertMany, which is insert with __partialRowErrors — the credential pass sits inside the same per-row try, so partial mode reports the refused row and lands the rest), the by-id update (driver exit driver.update or updateByIdExpecting under an ADR-0142 precondition), and the predicate update (driver.updateMany). The remaining driver.create sites in the file are the insert's own autonumber-resync exit. There is no engine upsert verb; the protocol's batchData upsert arms call createData/updateData. cloneData reads the source through engine.findOne (so a secret column arrives as the read mask), then engine.inserts the copy: the mask is dropped before the ref branch, so a clone is not refused and, as before this PR, carries no credential. The import doors (/import, /import/jobs) run runImport under the caller's context through the protocol's create/update faces; the undo door restores before snapshots that captureBefore takes from the engine-read existing row, i.e. the mask or null, never a ref. No writer outside the engine touches a secret column: the three credential channels (connector-credentials.ts, flow-credential-channel.ts, sso-client-secret.ts) write plaintext under SYSTEM_CONTEXT, and the webhook hooks compose plaintext into headers_secret/signing_secret inside the engine write. Judgment: no non-system path reaches a secret column without the rule.
  • The sole bypass. isSystem is never resolved from the wire: every HTTP context is assembled with isSystem: false (assemble-execution-context.ts, entryFields), no REST route reads a context off the body or query, and every { ...ctx, isSystem: true } spelling in rest/core/objectql is server-side elevation (import-job bookkeeping rows, hook/flow runAs: 'system', seed, migration). Row 23e's sentence "Inbound HTTP never carries the flag" is true of the code. The elevation surfaces a hook or flow author holds inherit the pass-through by design; the row's "Lose" column says so.
  • Verdict-to-statement window. By-id: the pre-image is readUpdatePriorRow's driver-level read (declared columns only, the caller's tenant/transaction bag), the not-found gate runs between that read and the call site, so [null] never reaches the check, and the statement writes the same id. Predicate: the D7 memo (readPriorRows) binds the middleware-composed AST and a buildDriverOptions bag; the statement binds the same AST and the same bag rebuilt from the same inputs, and inside an open transaction the same unit. The window is harmless by construction: a kept ref removes the key, so the statement never writes the column; a refusal writes nothing; a row that joins or leaves the match set between read and statement changes nothing about that column. Zero matched rows is refused (nothing to keep), which is the fail-closed arm.
  • Legitimate callers. Console password field: OBJECTUI_SECRET_MASK is echoed for a held value and dropped before the ref branch (dogfood pins the GET then PATCH round trip). Export then import: the export reads through the engine, so the file carries the mask, which the import drops. Clone: masked read, see above. Import undo: masked before. Seed replay and migration: system. A beforeUpdate hook that spreads previous into the payload keeps the record's own ref (unit test). The only class newly refused is the ruled one.
  • Tests and ablations. 19 unit cases over a real engine and a statement-recording stub driver (the pins include "the statement does not carry the column" on by-id and predicate keeps), 5 dogfood cases at the REST data door with a provider wired, the status-spelling registration; red first at bf7eff9; seven clause ablations plus a dist leg each red, restores proven blob == HEAD. The battery is adequate for the rule.

② Semver level

'@objectstack/objectql': patch with fix(objectql)!:, a BREAKING banner, Clause-②: no (narrowing) and Metadata-protocol: none at line start in both the PR body and the changeset, a remedy paragraph, and adr-0087: not-required (no-migration-prescription). Per AGENTS.md Post-Task step 3 a non-protocol narrowing ships patch and keeps its banner and disposition: correct. Metadata-protocol: none is right — nothing an author writes in metadata changes; a record value is not metadata. The disposition fits: no schema key moves, and the package's published surface is unchanged — the new symbols (SecretRefWriteError, StoredRowsReader, the two constants) live in engine-secret-internal-fields.ts, which neither index.ts nor core.ts re-exports and which package.json exports (. and ./core only) does not reach; a consumer branches on code/status, the pair EmptyCredentialWriteError already carries. Check Changeset is green on this head. Skills-docs: none is present as the narrowing rule requires.

③ Boundary flags

  1. Dogfood battery owed and not taken (REWORK condition). .claude/agents/os-dev.md Definition of done: the full dogfood suite runs once at the final head whenever the title carries ! or Clause-②: carries the (narrowing) arm, no shard, no path; review-checklist.md: before enqueue the seat verifies dogfood_battery pinned to the head, full, reds fixed or reported, and "missing or narrowed ⇒ REWORK". The os-dev-report declares dogfood_battery: n/a — no '!' in the PR title and no (narrowing) arm; the head under review carries both (title fix(objectql)!:, changeset and body no (narrowing), added after that report). What ran is 9 credential-channel dogfood files (60/60) at 75e18b1, a narrowed battery, and CI's dogfood shards do not run on a PR. So no full-corpus measurement of this narrowing exists. The code is byte-identical to 75e18b1, so the run is owed, not a change.
  2. Declared deviation, accepted. The claim allowed engine.ts only to pass the system flag; the diff passes each call site's own stored rows instead. The ruling's "its own record's ref" cannot be judged without the row, the flag was already in reach, and nothing else in engine.ts moved. Declared in Acceptance notes.
  3. Residual on stored state, rule level only. Values stored before this change are not re-examined (the PR says so). The existing sweeps (os secret orphans, sys-secret-orphan-report.ts, secret-reference-union.ts) classify unreferenced handles; none classifies a handle held by more than one holder, or held by a column other than the one it was minted for (releaseSecretHandles asks that question only at deletion). A report-only sweep of shared references is owed as a follow-up card; the seat files it, with the usual security discipline.
  4. Preview asymmetry, pre-existing. ObjectQL.validate() runs no credential pass, so a write preview cannot predict this refusal, exactly as it could not predict the empty-credential one. Unchanged by this PR; noted, no fix owed here.
  5. open_questions: [] in the report; none added by this review beyond item 3.

④ Design judgment

  • packages/objectql/src/engine-secret-internal-fields.ts — holds. The branch is placed after the empty-string refusal and before the provider gates, so the verdict depends on the payload and the record, never on what is wired. StoredRowsReader keeps the read demand-driven (called only when a non-system payload carries a ref) and memoised across a payload's secret fields. every over own?.[field] === value is strict and fail-closed; null from the reader is [], refused. Dropping the kept key mirrors the mask drop and is what makes the window argument hold. The error carries code, status, httpStatus, object, field, named SecretRefWriteError. No must-fix, no should-fix.
  • packages/objectql/src/engine.ts — holds. Three call sites, each naming its own rows correctly (none, the pre-image, the memo), docblock updated to the new rule, nothing else moved. No must-fix, no should-fix.
  • packages/objectql/src/error-http-status-spelling.test.ts — holds: the new refusal is registered and both spellings agree at 400.
  • packages/objectql/src/secret-field-own-ref-write.test.ts — holds: every door, both arms, the system pass-through, identical answers, the column-absent statement pins, hooks on both verbs. No gap that changes the verdict.
  • packages/qa/dogfood/test/secret-field-own-ref-write.dogfood.test.ts — holds: the REST doors (POST, createMany, import, PATCH), stored value read at driver level, sys_secret count unchanged across refusals and keeps, one answer for two refusals.
  • content/docs/permissions/system-context.mdx — holds: row 23e states Get/Lose truthfully against the code; the census register line names encryptSecretFields with one elevation read; check:system-context-census is green inside Lint & Repo Gates.
  • .changeset/27582-secret-field-own-ref-only.md — holds, see ②.
  • Must-fix: M1 — run the full dogfood battery at the final head (pnpm --filter @objectstack/dogfood test under the lock, no shard, no path), fix or report any red, and record it as dogfood_battery pinned to the head in the PR body; then a new record on this head. No code change is expected from it.
  • Should-fix: S1 — file the follow-up card for the report-only shared-reference sweep (③ item 3), stated at rule level.

Implemented-by: claude/issue-27582-secret-ref-own-record-only
Reviewed-by: session_01L6bhgUCizoYWQVJWjhdR6F

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ed18660403e13dfee68d9f80d277ab49bebd5cf7
Local-runs: none
Scope: packages/objectql/src/engine-secret-internal-fields.ts, packages/objectql/src/engine.ts, packages/objectql/src/error-http-status-spelling.test.ts, packages/objectql/src/secret-field-own-ref-write.test.ts, packages/qa/dogfood/test/secret-field-own-ref-write.dogfood.test.ts, content/docs/permissions/system-context.mdx, .changeset/27582-secret-field-own-ref-only.md + packages/objectql/src/secret-fields.ts, packages/core/src/security/assemble-execution-context.ts, packages/metadata-protocol/src/protocol.ts, packages/core/src/utils/import-runner.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/batch-routes.ts, packages/services/service-automation/src/connector-credentials.ts, packages/services/service-automation/src/flow-credential-channel.ts, packages/plugins/plugin-auth/src/sso-client-secret.ts, packages/plugins/plugin-webhooks/src/webhook-headers.ts, packages/plugins/plugin-webhooks/src/webhook-secret.ts, packages/cli/src/utils/secret-reference-union.ts, ui/packages/app-shell/src/views/metadata-admin/widgets.tsx, .claude/agents/os-dev.md, .claude/skills/pm-dispatch/references/review-checklist.md, .github/workflows/ci.yml

Second record on the same head, read at 2026-10-29T20:45Z. It supersedes record 6507826814 on this PR, whose one must-fix (M1) is now closed; everything that record judged in ①–④ stands unchanged and is cited here rather than restated. Re-read for this act: the PR (head still ed18660403e13dfee68d9f80d277ab49bebd5cf7, draft, title fix(objectql)!:), the card's os-dev-report addendum 6507875223 at 2026-10-29T20:43Z, and the two follow-up cards #27721 and #27722. Security card: rules and code only, no procedure.

① Derived judgments

Unchanged from 6507826814: the rule holds on every engine write door, the sole bypass is not reachable from the wire, the verdict-to-statement window is harmless by construction, and the only class newly refused is the ruled one.

② Semver level

Unchanged from 6507826814: patch, !, BREAKING, Clause-②: no (narrowing), Metadata-protocol: none, adr-0087: not-required (no-migration-prescription); published surface unchanged; Check Changeset green on this head.

③ Boundary flags

  1. M1 closed. Addendum 6507875223 pins the full dogfood battery to this head: ed186604, pnpm --filter @objectstack/dogfood test under the lock, no shard, no path; dogfood-closure build exit 0; VERDICT command-exit 0; Test Files 353 passed | 3 skipped (356), Tests 2849 passed | 20 skipped (2869), no failures. The short sha matches the PR's head.sha; the addendum also states, correctly, that the head's only change over 75e18b1 is the changeset. The skips are the suite's own environment-gated cases, not a shard or path narrowing. The review-checklist condition for a ! / (narrowing) PR (battery pinned to head, full, no unreported red) is met.
  2. S1 closed. The report-only shared-reference check is filed as secrets: a report-only check for stored secret: references that more than one holder names, or that a column other than the one that minted it holds #27721, rule level.
  3. The out-of-band scope note from the first review is filed as secrets: confirm the other stored-reference families (settings-service handles, datasource external.credentialsRef) apply the #27582 own-reference rule at their write doors #27722, an audit card at rule level; it is outside this PR's mechanism and does not bear on this verdict.
  4. Items 2 (declared engine.ts deviation, accepted) and 4 (pre-existing preview asymmetry) of 6507826814 stand as written.

④ Design judgment

Unchanged from 6507826814: every in-scope file holds against the code it cites and the scenario it is accepted against. No must-fix, no should-fix remains.

Implemented-by: claude/issue-27582-secret-ref-own-record-only
Reviewed-by: session_01L6bhgUCizoYWQVJWjhdR6F

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

维护者速读(席位终稿)

席位意见: ACCEPT。规则覆盖引擎全部写门,系统主体是唯一放行且线上请求带不上;收窄声明、BREAKING、全量 dogfood 齐全。

席位核对

ACCEPT · head ed18660403e13dfee68d9f80d277ab49bebd5cf7 · 2026-10-29T20:46Z

  • 形态: 7 files, +597/−6: the ref branch of encryptSecretFields + SecretRefWriteError, three engine.ts call sites, one status-spelling pin, two new test files (unit, dogfood door), system-context.mdx row 23e + census line, one changeset. No packages/spec, no package-index export, no governed path; Tier S. security p1.
  • Clause-②: no (narrowing) (the seat corrected the claim's no), Metadata-protocol: none, patch + ! + BREAKING, ADR-0087 not-required (no-migration-prescription). Contract review at CONTRACT_REVIEW_TIER: record 6507826814 FAIL on one ceremony point (no full-battery reading on the card), closed by the dev's addendum 6507875223; record 6507924217 on this same head: PASS, no open must-fix or should-fix.
  • File surface: the claim names engine.ts for the system flag; the change passes each write's own stored rows there instead, declared in the PR's Acceptance notes and accepted by the review.
  • 前提实测: red first on the unfixed tree (unit 11 failed / 8 passed, dogfood 3 failed / 2 passed); seven source ablations and one dist ablation each red, restored to the HEAD blob.
  • 门禁 check-runs: 54 on this head: 39 success, 15 skipped, 0 other (read 2026-10-29T20:46Z); mergeable_state: clean. Derived gates 94/94, 0 NOT-MEASURED; objectql 9224/9224; full dogfood battery at this head 353 files passed / 3 skipped, 2849 tests passed / 20 skipped.
  • 接受的偏差: the os-dev-report's line 1 is the seat signature (post-stamped's rule), os-dev-report on line 2.
  • 范围外去向: values stored before this change → report-only check secrets: a report-only check for stored secret: references that more than one holder names, or that a column other than the one that minted it holds #27721; the other stored-reference families' write doors → audit secrets: confirm the other stored-reference families (settings-service handles, datasource external.credentialsRef) apply the #27582 own-reference rule at their write doors #27722. Both rule-level.
  • 散文面: changeset, row 23e and the PR body checked against the diff at rule level; no reproduction text anywhere.
  • 落地条款: relay pr_ready + automerge_enable through the merge queue.

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

objectql: a secret field accepts an existing secret: ref from a non-system write, so it can name another record's credential row

1 participant