Skip to content

fix(objectql): deleting a record releases the sys_secret row its secret field named - #27657

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-v9mp90
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-v9mp90

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 #27581

Clause-②: no
Metadata-protocol: none
Skills-docs: none — nothing a metadata author writes changes; the fix is inside the engine's delete path, and the documented os secret orphans report is unchanged.

维护者速读

  • 改了什么:删除记录后,释放该记录 secret 字段引用的 sys_secret 凭据行。覆盖按 id 删除、按条件批量删除、REST 的 deleteMany,以及级联删除连带删掉的子记录。
  • 为什么改:删除流程原先不读被删行的 secret 列,凭据密文留在库里,没有任何记录引用它,却仍可解密;凭据表随每次删除增长。
  • 风险与回滚:沿用 fix(objectql): a secret field's sys_secret row is written only with its record, and released when the column lets go #27572 已上线的同一条释放规则:同一列没有其他行还引用该句柄,句柄属于这一列,删除跟随记录所在的事务;核实不了就保留该行并记一条 error 日志,这是可恢复的方向。被拒绝或回滚的删除不释放。回滚只需 revert 本 PR。
  • 先看:packages/objectql/src/engine.ts(delete 流程里取句柄的三处,以及 afterDelete 之后的释放点),packages/objectql/src/engine-secret-internal-fields.ts(deletedSecretSlots),packages/objectql/src/secret-field-write-commit.test.ts(新增的 delete 一段)。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/secret-field-write-commit.test.ts,预期 30/30 通过,其中 9 条是本 PR 新增。
  • 需要你做的:无,席位落地。

What changes

A committed delete now releases every handle the deleted rows held in a secret column, through the release #27572 added for a committed update that clears the field. There is one release rule; this change only hands it the delete's candidates.

  • Candidates, from reads the delete already makes. deletedSecretSlots (in engine-secret-internal-fields.ts) lists each handle a deleted row held in a secret column. It is retiredSecretSlots called with a null clear of every secret column, so a delete's candidates are exactly what clearing those columns would retire.
    • By id: the pre-image the delete already reads for its 404 gate.
    • By predicate, no cascade plan: the per-row hook read when one was made; otherwise one narrow id + secret-columns read through the write's AST and driver options, before the statement. An object with no secret field reads nothing new.
    • By predicate, with a cascade plan: the matched-row read inside the unit now also carries the secret columns, so the handles released are those of the rows deleted.
    • A cascade's detail records are deleted through the by-id path, so each releases its own handles.
  • Release, verified, after the hooks. releaseSecretHandles(object, deletedSecrets, context, 'retired') runs after the afterDelete chain and its result-shape check, before the roll-up recompute. A hook can still dereference the value, and a delete that is refused or rolled back never reaches it. Each candidate goes only when no remaining row of its column names the handle, the sys_secret row's namespace / key are that column's own, and the count and delete ride the record's unit. Otherwise the row is kept and one error line names the column.
  • Not in this change. Legacy orphans are not swept; they stay report-only through os secret orphans, per [Decision] sys_secret orphan reaping: which vehicle carries a destructive sweep over stored credentials — migration, admin command, or opt-in script? #8259. What a delete answers and what reads return are unchanged.

Tests

  • packages/objectql/src/secret-field-write-commit.test.ts, a new [#27581] block of 9 cases on the existing double.
    • Released: a by-id delete (and no other record's row); a predicate delete (the narrow read); a predicate delete with a per-row hook (the hook's read); a cascade, by id and by predicate, releasing the detail rows' handles with the parent's.
    • Kept: a handle another row still names, until the last row goes; a row holding another column's handle; a delete rolled back with its unit; a delete refused by beforeDelete, and one rolled back by an aborting afterDelete; a release that cannot confirm, which logs one line naming the column and not the handle.
    • The double gained deleteMany and an isolation mode where a rollback undoes only the writes made under that unit's handle, as a database does. In the default snapshot mode a rollback restores every store, so a release that escaped the unit could not be seen.
  • packages/qa/dogfood/test/secret-field-delete-release.dogfood.test.ts (new, 4 cases), at the REST data door of a booted stack with a provider wired, counting sys_secret rows at the driver.
    • DELETE /data/OBJECT/ID removes that record's row and no other.
    • POST /data/OBJECT/deleteMany removes the row of every record it deletes.
    • A handle another record still names is kept until the last one is deleted.
    • An atomic deleteMany that rolls back (its second id names no record) keeps the record and its row.

Acceptance notes

  • Which delete paths exist. By id; by predicate (multi: true) on three plans (no relation points at the object; a cascade inside one unit; a split cascade); cascade members, deleted by id. The REST DELETE, the bulk deleteMany door (per-id engine deletes, atomic or not) and the cross-object /batch delete all reach the engine's delete by id. There is no soft-delete or recycle-bin path: softDelete and enable.trash were retired from the spec in the 16.x line as never enforced, and no runtime keeps a deleted record, so there is no "kept record" case to protect.
  • One release rule. The release body is unchanged. The delete passes the existing 'retired' occasion, so a kept row logs the same line ("the sys_secret row that OBJECT.FIELD named before this write was not removed"). No second rule and no new log text.
  • The rollback pin at the REST door, and its ablation. On the dogfood stack's sqlite-wasm driver, the release that does not ride the unit goes red by timeout (Test timed out in 5000ms), not by an assertion. The batch holds the pool's single connection, so a statement outside the unit waits for it. The assertion-level red for "the release rides the unit" is the engine case on the double's isolation mode (ablation A3 below).
  • Scope in engine.ts. The delete flow, two names in the secret-fields import list, and one sentence in releaseSecretHandles's docblock naming the delete occasion. engine-secret-internal-fields.ts gains the two small helpers and one header sentence.
  • This branch is the card thread's harness-pinned claude/project-thread-v9mp90; the claim on objectql: deleting a record does not release the sys_secret row its secret field names #27581 (6500734029) names it.

Verification

Final head b15352e9 is 54efb0b8 with origin/main (f7ff693c) merged in. The red-first and ablation legs ran at 54efb0b8, with the fix committed. The final-head readings below were taken at b15352e9 after rebuilding what the merge touched.

Red first, unit, typecheck, lint and the REST door
  • Red first, on the unfixed tree (engine.ts and engine-secret-internal-fields.ts at base 9c513147, the new tests kept): secret-field-write-commit.test.ts showed 6 failed / 24 passed (30). The 6 were the four released cases, the shared-handle case's last delete, and the cannot-confirm case. The 3 kept cases were green there, as a keep should be.
  • pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=3 at b15352e9: Test Files 488 passed (488), Tests 9163 passed (9163).
  • pnpm --filter @objectstack/objectql typecheck: exit 0; check:test-typecheck: OK, 39 files / 233 errors / 64 pinned signatures held, unchanged.
  • pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/secret-field-delete-release.dogfood.test.ts test/secret-field-orphan-rows.dogfood.test.ts at b15352e9: Test Files 2 passed (2), Tests 9 passed (9).
  • eslint on the four changed source and test files: exit 0. pnpm lint (the repo-wide run) at b15352e9: exit 0.
  • The full dogfood battery is not derived here: the title has no ! and Clause-② 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 blob (engine.ts ab2b884a7373, engine-secret-internal-fields.ts 621c5ec3c95f) with git diff HEAD empty.

Dist-resolved, which the dogfood file consumes. Each mutation carried a marker; @objectstack/objectql was rebuilt and ablation-dist-preflight found the marker in 4 built files before the run.

  • D1, the delete's release call removed: 3 failed / 1 passed (DELETE by id, the deleteMany door, the shared handle's last delete).
  • D2, the shared-handle check removed: 1 failed (the shared handle, gone after the first delete).
  • D3, the release's store options stripped of the unit: 1 failed, the atomic rollback, by test timeout (see the acceptance notes).
  • Restore: rebuilt with declarations, --absent exit 0 for all three markers, the file 4 passed (4), and the tree clean against HEAD.
Local gate subset
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 67 commands at b15352e9. All 67 were run, each exit code captured before any pipe: 67 × exit 0. --ran reconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.
  • check:dual-build-cjs-loads answered exit 3 on its first run (seven packages had no dist/); those were built, and it is exit 0 above.
Overlap with open PRs

git merge-tree of this head against every open PR whose diff touches these files: #27477 (head 5eb62f09, on engine.ts) merges clean. No other open PR touches them, measured over every open PR. The server seat 1 hot-file queue names #25522 and then #27165 for engine.ts; neither has an open PR. This head also merges clean with the current origin/main.

The required CI families run on this PR's check-runs and are not re-run here.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (Not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgUFEohazYphk3zDAum

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • 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 b761968d9df2a2a72bbbdb96b4f67ca78a58739f → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

维护者速读(席位终稿)

  • 改了什么:删除记录后,释放该记录 secret 字段引用的 sys_secret 凭据行;按 id 删、按条件删(三种计划)、REST deleteMany / /batch 删除、级联连带删掉的子记录都覆盖。
  • 为什么改:删除流程原先不读被删行的 secret 列,凭据密文留在库里无人引用却仍可解密,凭据表随每次删除增长。
  • 风险与回滚:沿用 fix(objectql): a secret field's sys_secret row is written only with its record, and released when the column lets go #27572 已上线的同一条释放规则(同列无其他行引用、句柄属于该列、跟随记录所在事务);核实不了就保留该行并记一条 error 日志(可恢复方向);被拒绝或回滚的删除不释放。回滚 = revert 本 PR。
  • 先看:engine.ts 删除流程里取句柄的三处与 afterDelete 之后的释放调用;engine-secret-internal-fields.ts 的 deletedSecretSlots。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/secret-field-write-commit.test.ts,30/30 通过(9 条新增)。
  • 需要你做的:无。非受管面,席位落地。

席位意见: ACCEPT。只把删除的候选交给现成的释放规则,没有第二条规则;释放点在 afterDelete 之后、仍在本次写入的事务里。

席位核对

ACCEPT · head b15352e9d73111c29ccd775cdaef2a50c3c5934b · 2026-10-29T16:55Z

  • 形态: 5 files, +384/−12: engine.ts +32/−2 (delete flow, import list, one docblock sentence), engine-secret-internal-fields.ts +26/−1 (two helpers), one unit test file extended, one new dogfood file, one changeset. No packages/spec, no governed path; Tier S. Clause-②: no holds: what a delete answers and what reads return are unchanged; nothing is narrowed or widened.
  • 前提实测: red first on the unfixed tree: 6 of 30 failed (the four released cases, the shared handle's last delete, the cannot-confirm case); the keep cases green there, as a keep should be.
  • 抽查读数: the seat read the hunks on this head. By id: candidates from the 404-gate pre-image (readPreImage), taken after the delete runs. Predicate without a cascade plan: the per-row hook read when made, otherwise one id + secret-columns read through the write's AST and options before deleteMany, skipped when the object has no secret column. Predicate with a cascade plan: the matched-row read inside the unit carries the secret columns. The release is releaseSecretHandles(object, deletedSecrets, opCtx.context, 'retired') after the afterDelete chain and its result-shape check, before the roll-up recompute, so a refused or aborted delete never reaches it.
  • 消融: source A1–A4 (release call, shared-handle check, unit options, column ownership) each red and restored to the HEAD blob; dist D1–D3 red through the REST door, D3 by timeout on the single-connection sqlite-wasm pool, with the assertion-level red for "rides the unit" on the isolating double (A3). Accepted as stated.
  • 门禁 check-runs: 53 on this head: 37 success, 16 skipped, 0 other (read 2026-10-29T16:55Z); mergeable_state: clean. Derived gates 67/67, 0 NOT-MEASURED; pnpm lint exit 0; objectql 9163/9163.
  • 接受的偏差: none beyond the body: the PR body's header now carries the seat signature and the seat thread link (seat issue_patch, read back identical); the footer keeps the card thread's session, which wrote it.
  • 范围外去向: legacy orphans stay report-only through os secret orphans ([Decision] sys_secret orphan reaping: which vehicle carries a destructive sweep over stored credentials — migration, admin command, or opt-in script? #8259); no card owed. Skills-docs: none.
  • 散文面: the changeset checked against the diff (by id, predicate, cascade covered; no soft-delete path exists; one release rule).
  • 落地条款: relay pr_ready + automerge_enable through the merge queue. Overlap: merges clean with fix(objectql): extend the credential-column guard from aggregates to filter and sort keys #27477 (engine.ts) per the PR's merge-tree reading.

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.

objectql: deleting a record does not release the sys_secret row its secret field names

1 participant