Repository navigation
fix(objectql): a secret field's sys_secret row is written only with its record, and released when the column lets go - #27572
Conversation
…cord, and release it when the column lets go The credential pass now seals a secret value (the ref still lands on the row before validation and the post-image seam judge it) and hands the sys_secret row back instead of writing it. The row is written immediately before the record's own driver write, after every pass that can refuse the write, and taken back when the write does not complete. A committed update that clears a secret field or replaces its value releases the handle the column named before. Every release is verified against storage first: no row of the column may still name the handle (a predicate write shares one handle across its rows), the handle must be that column's own, and the delete rides the record's unit. Anything that cannot be confirmed keeps the row and is reported at error. Claude-Session: https://claude.ai/code/session_01L6bhgVuA8QG6cxFjdHMHgn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgVuA8QG6cxFjdHMHgn Co-authored-by: Claude <noreply@anthropic.com>
…a door; add the changeset Claude-Session: https://claude.ai/code/session_01L6bhgVuA8QG6cxFjdHMHgn Co-authored-by: Claude <noreply@anthropic.com>
… is kept; point at os secret orphans Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVuA8QG6cxFjdHMHgn
…after its filter Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVuA8QG6cxFjdHMHgn
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
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 d4d7941fefa8b031a2a53021beb815276f1f6e0f && git checkout d4d7941fefa8b031a2a53021beb815276f1f6e0f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 556f3230cdd43caa6a263a35c2133e1e9fbfbefc c0b6bfc75e7232d48e366fa9f5b5e9d8beac3820 && git checkout -B drift-repro 556f3230cdd43caa6a263a35c2133e1e9fbfbefc && git merge --no-ff c0b6bfc75e7232d48e366fa9f5b5e9d8beac3820
node scripts/docs-audit/affected-docs.mjs --json 556f3230cdd43caa6a263a35c2133e1e9fbfbefc
|
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw 维护者速读(席位终稿)
席位意见:接受。拆成「封存 → 随记录写入 → 核实后释放」三步,失败一律保留凭据行,方向对;席位逐段读过 diff。 席位核对 — ACCEPT · domain:server#1 · head c0b6bfc · 2026-10-29T13:50Z
|
Requested by Sales · project thread
Fixes #27495
Clause-②: no
Metadata-protocol: none
Skills-docs: none — nothing a metadata author writes changes; the fix is inside the engine's credential write path, and the documented
os secret orphansreport is unchanged.维护者速读
secret字段的sys_secret凭据行改为只随记录本身的写入一起落库;写入没完成时收回它已写的凭据行;更新把字段清为null或换成新值后,释放旧值的凭据行。resolveSecretField、调用方收到的拒绝都不变;历史遗留的孤儿行不清理,仍由os secret orphans报告。回滚只需 revert 本 PR。packages/objectql/src/engine-secret-internal-fields.ts(风险在releaseSecretHandles的核实与事务判断),packages/objectql/src/engine.ts(insert / update 的写入点与两处释放点),packages/objectql/src/secret-field-write-commit.test.ts。pnpm --filter @objectstack/objectql exec vitest run src/secret-field-write-commit.test.ts,预期 21/21 通过。What changes
The credential pass for a
secretfield is split into three steps on the existing secret-fields path (engine-secret-internal-fields.ts, whose bodiesengine.tscalls).encryptSecretFieldsstill encrypts the value and puts thesecret:ref on the row, so validation and the post-image permission check judge the stored reference exactly as they did. It no longer writes thesys_secretrow; it hands the sealed row back. The fail-closed refusals (no crypto provider, nosys_secretstore, the empty string) are unchanged and still fire at this point.persistSecretRowswrites the sealed rows immediately before the record's own driver statement, after every pass that can refuse the write: on insert, only for the rowsinsertManykeeps; on update, on both the by-id and the predicate branch.releaseSecretHandlesruns on two occasions.refused: a write that did not complete (validation, an option outside the list, a required field, a hook, the permission check, the driver refusing the statement) takes back the rows it wrote.retired: a committed update that clears asecretfield withnull, or replaces its value, releases the handle the field named before. The candidates come from the by-id pre-image, or from the predicate path's prior rows (the D7 memo when it was read, else one narrowid+ secret-columns read). An echoed ref or an echoed mask releases nothing. It runs after theafterUpdatechain, so a refusal that rolls the record back never reaches it.sys_secretrow'snamespace/keymust be this object and field. The delete rides the record's transaction: the explicit, still-open handle, else the ambient unit. If the record write rode a unit that does not coversys_secret, the row is kept, because the record could still roll back and name it.errorline that names the column, never the handle id, with the driver fault redacted, and points atos secret orphans. The caller's answer never changes.resolveSecret/resolveSecretField, the mask round trip and every refusal envelope are unchanged. Deleting a record is not touched.Tests
packages/objectql/src/secret-field-write-commit.test.ts(new, 21 cases): a real engine over a recording driver with snapshot transactions.sys_secretcount unchanged: insert refused by a required field, an option outside the list, abeforeInserthook, the post-image permission check, the driver refusing the statement, a batch statement, and a partialinsertMany; update refused by an option outside the list, the permission check, the driver; a predicate update refused by an option outside the list and by the driver; anafterInsertabort.nullclear removes the old row; a replacement removes the old row and keeps the new one; a predicate clear removes every matched row's handle; a handle a predicate write shared outlives every row but the last; a handle minted for another column is never released through this one; a release that cannot confirm keeps the row and logs one line naming the column and not the handle.packages/qa/dogfood/test/secret-field-orphan-rows.dogfood.test.ts(new, 5 cases): the same halves at the REST data door of a booted stack with a provider wired, countingsys_secretrows at the driver.Acceptance notes
before*hook, the insert-side permission check, anafter*abort) already wrote no row before this change, because they refuse before the credential pass or roll back with the record. Their cases were green on the unfixed tree and hold that order; the other classes were red there (15 of 21).secret-fields.tsand the engine's write and clear steps. Since the engine split, those steps' bodies live inengine-secret-internal-fields.ts;secret-fields.tsitself needed no change.claude/project-thread-r4cp59; the claim on objectql: a refused write carrying a secret field still mints an orphan sys_secret row, and clearing a secret with null leaves its sys_secret row behind #27495 names it.Verification
All readings below were taken at head
c0b6bfc7, afterorigin/main(d4ff1a02) was merged in and the dogfood dependency closure was rebuilt, unless a line says otherwise.Unit, typecheck, lint and the REST door
secret-field-write-commit.test.tsshowed 15 failed / 6 passed (21), each failure on asys_secretcount.pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=3: Test Files 487 passed (487), Tests 9143 passed (9143).pnpm --filter @objectstack/objectql typecheck: exit 0;check:test-typecheck: OK, 39 files / 233 errors / 64 pinned signatures held, unchanged.eslinton the three changedpackages/objectqlfiles: exit 0.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/secret-field-orphan-rows.dogfood.test.ts: Tests 5 passed (5).pnpm --filter @objectstack/dogfood typecheck: exit 0.eslinton the dogfood file: exit 0.!andClause-②has no(narrowing)arm.Ablation (each through scripts/ablation-replace.mjs, restore proven)
Source-resolved, against
secret-field-write-commit.test.ts; every mutation landed (anchor 1 → 0) and every restore came back to the HEAD blob163a38d28da2withgit diff HEADempty.Dist-resolved, which the dogfood file consumes (taken at
33868e7d; the one later commit touches only the unit test's double): each mutation carried a marker,@objectstack/objectqlwas rebuilt andablation-dist-preflightfound the marker indist/.--absentclean for both markers, the file 5 passed (5), and the tree clean against HEAD.Local gate subset
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 67 commands atc0b6bfc7. All 67 were run, each exit code captured before any pipe: 67 × exit 0.--ranreconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.33868e7d, two gates did not pass.check:objectql-double-limitflagged that the new test's recording double ignored the caller'slimit; it now applies the bound after its filter (c0b6bfc7).check:dual-build-cjs-loadsanswered exit 3 (seven packages had nodist/); those were built, and both are exit 0 above.Overlap with open PRs
git merge-treeof this head against every open PR whose diff touches these files: #27477, #27541 and #27543 (all onengine.ts) merge clean. No other open PR touches them. This head also merges clean with the currentorigin/main.The required CI families run on this PR's check-runs and are not re-run here.
Human Minimum
packages/specholds no runtime logic. (Not touched.)pnpm lintpasses on the changed files.main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw