Repository navigation
fix(objectql)!: a non-system write to a secret field may only seal a new value or keep its own record's ref - #27695
Conversation
… or keep its own record's ref Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude <noreply@anthropic.com>
…ew value or keep its own record's ref Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude <noreply@anthropic.com>
…cret-ref-own-record-only
…stored-reference pass-through Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
…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>
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head ed18660 · 2026-10-29T20:26Z |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: 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 ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: FAIL |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: 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 ① Derived judgmentsUnchanged 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 levelUnchanged from 6507826814: ③ Boundary flags
④ Design judgmentUnchanged 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: VERDICT: PASS |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F 维护者速读(席位终稿)
席位意见: ACCEPT。规则覆盖引擎全部写门,系统主体是唯一放行且线上请求带不上;收窄声明、BREAKING、全量 dogfood 齐全。 席位核对ACCEPT · head
|
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 拒绝,且无论引用指向什么,答复都相同。系统上下文写入保持原样放行。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 inengine-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
secretvalue that is already asecret:ref used to be left as it was for every caller. Now:context.isSystem === true, the engine's existing marker) keeps that pass-through, unchanged.SecretRefWriteError: ADR-0112VALIDATION_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".
encryptSecretFieldstakes aStoredRowsReader, and each of its three call sites inengine.tssays what its own rows are: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.mdxgains row 23e for the new elevation read, and its site register is regenerated (check:system-context-censusrequires 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.insertManyrow, orbeforeInsert-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 oncode,status,httpStatus,objectandfield. A ref naming an existing row and one naming nothing get an identical message, which does not contain the handle.beforeUpdatehook 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.POST /data/:object,/createManyand/import, each 400VALIDATION_ERRORor 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.error-http-status-spelling.test.tsregisters the new refusal (both status spellings agree).Acceptance notes
engine.tsedit is wider than the claim's file surface. The claim allowsengine.tsonly to pass the system-context flag. That flag was already in reach (contextis an argument). What was not in reach is the record's own stored row, which the ruling's "its own record's ref" needs, so the three call sites now pass it. Nothing else inengine.tschanged.system-context.mdx, plusengine.tsfor fix(objectql): extend the credential-column guard from aggregates to filter and sort keys #27477) all landed onmainwhile this was in progress.origin/main5e2f386 is merged in, and every result below was taken after that merge. None of the 39 open PRs touches any of this PR's seven files, so there is nothing left tomerge-treeagainst. Against today'sorigin/main(86f2afe) the merge is still clean, and none of the newer commits touches these files.Verification
Every reading below is at head
75e18b19, afterorigin/main5e2f386 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.turbo run build --filter='@objectstack/dogfood^...'pnpm --filter @objectstack/objectql typecheckpnpm --filter @objectstack/objectql exec vitest runTest Files 491 passed (491)·Tests 9224 passed (9224)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-lifecycleandwebhook-meta-door-credential-redactionTest Files 9 passed (9)·Tests 60 passed (60)eslint --no-inline-config packages/objectqleslint --no-inline-configon the new dogfood fileAblations: each clause of the rule goes red when removed, and each restore is proven blob == HEAD
Each leg runs
scripts/ablation-replace.mjson one clause, then runs the new unit file (19 cases). The restore is then proven: blob8796f4077121(engine-secret-internal-fields.ts) and0af248405c8f(engine.ts) == HEAD, andgit diff HEADis empty.13 failed | 6 passed (19)--delete)7 failed | 12 passed (19)1 failed | 18 passed (19)3 failed | 16 passed (19)2 failed | 17 passed (19)3 failed | 16 passed (19)2 failed | 17 passed (19)objectqlrebuilt, then the REST dogfood file runablation-dist-preflightmarker 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 at75e18b19.Gates: 94 derived, 94 run, each exit code recorded, 0 NOT-MEASURED
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 94 commands at75e18b19, the same list as before the merge.--ranreconcile: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-debtfirst 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).check:skill-examplesandcheck:dual-build-cjs-loadsfirst answered 3 (dist missing for packages outside this diff). They were built and both exited 0. After a container restart,check:dual-build-cjs-loadsanswered 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-censuswas 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