feat(cli): os secret orphans — reap pre-fix sys_secret orphans, deleting only over the COMPLETE reference union - #12990
Conversation
… over the COMPLETE reference union
The operator-run half of the pre-fix `sys_secret` residue. Report-only by
default; `--delete` removes ONLY rows that are attributable to a declared
encrypted settings specifier AND named by no family of the cross-producer
reference union, and it refuses whenever that union is incomplete, naming the
family that could not be enumerated.
Three guards are mechanical rather than advisory, because a delete over a
secrets table cannot delegate to an operator's attention:
- `unattributable` rows are never deletable;
- a row whose (namespace, key) currently resolves through a LEGACY INLINE
sys_setting value is withheld — the #8063 prefix guard in the opposite
direction, since an inline value names no handle and so explains the
absence;
- re-wrap evidence (version / rotated_at) is reported and is never a verdict
input: a re-wrap keeps the handle stable and is not a retirement.
The pre-delete export is mandatory and carries the cipher material, because the
audit trail records content digests rather than handles: without it an
erroneous delete can neither be named nor undone. It is written owner-only and
read back and checked against the plan before any row is removed.
The operator-facing text states the measured, inverted exposure framing: the
value still in force is the OLDEST one, the orphans hold values that never took
effect, so this retires nothing that is exposed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
The docs entry states the same two things the command's own output does: the delete is refused unless every producer family enumerated, and the exposure framing is inverted — the value still in force is the oldest one, so the sweep retires nothing that is exposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 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:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 23 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 4eba2b6d41420f379752012b009a3450248237c9 && git checkout 4eba2b6d41420f379752012b009a3450248237c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf10debd587f6ba891be9eadc2b76c91e15bd82b e9347402b0e73ce590a8ac253be111369926a73d && git checkout -B drift-repro bf10debd587f6ba891be9eadc2b76c91e15bd82b && git merge --no-ff e9347402b0e73ce590a8ac253be111369926a73d
node scripts/docs-audit/affected-docs.mjs --json bf10debd587f6ba891be9eadc2b76c91e15bd82b
|
PM 复核 — #8103 / domain:cli 席位复核对象是树,不是报告。以下每条都在 已验证通过判据一:联合体不完整时拒绝删除,并点名缺哪一族 — 成立,而且是结构性的,不是文档性的。 判据二:三个 gap 来源不得在消费侧拆平成布尔 — 成立。 其余: 围栏干净:6 个文件,未触碰 两个待裁问题 — 裁定问题一:导出应携带密文材料(已实现)还是仅元数据? 问题二:legacy inline 类整类扣留(已实现)还是给 override 开关? 必须修改(一处,落地前)导出文件的写入用的是默认写入标志(截断/创建),而"拒绝覆盖"的检查在引导、扫描和交互确认之前。两者之间隔着任意长的时间窗:若该路径上的文件在这段窗口内被他人抢先创建,写入会沿用既有文件的属主与权限, 改法:把写入改成排他创建(exclusive create),即在写入选项里显式指定排他创建标志。这样文件已存在时写入直接失败并进已有的 阳性对照已做:排他标志在该文件中出现 0 次,而 补一条守卫测试:导出路径已存在时, 选择现在改而不是另开卡的理由是同文件硬串行 —— 其余部分我不要求改动。自查出的两项(测试里默认参数吞掉显式传入的 undefined、复合键分隔符里混入裸 NUL 控制字节)是本席位鼓励的那一类自我证伪,已计入。约束三里事实 2 再测仍为 FALSIFIED 属预期读数,不是停止条件 —— 未因此停工是正确的。 CI 当前 31 项无红,3 项仍在跑。修改推上后重新拉取完整清单(最后一个分片结束后会新增汇总检查),全绿再撤草稿。 Generated by Claude Code |
… it lost the race for `os secret orphans --delete` refuses an export path that already exists, but that check runs before the boot, the scan and the confirmation — an arbitrarily long window before the write. The write itself used the default flag, which truncates an existing file and, because `mode` is applied only at creation, inherits that file's owner and permissions. Cipher material could land in a file the operator does not exclusively own. `flag: 'wx'` makes it a create-or-fail: EEXIST lands in the existing `export_failed` refusal, so nothing is deleted, and 0600 is guaranteed at the instant of creation. The pre-check stays — it still gives the better early message; the two are defence in depth, not alternatives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…sitive control Drives the real `--delete --json` run against two fake seams only — the boot and the reference-union collector. Everything that decides which rows are deletable (`planSysSecretOrphanSweep`, `buildPreDeleteExport`, `collectEncryptedSpecifierRefs`, `isSecretHandle`) runs for real. The subject test creates the export file *inside the mocked boot*, which is exactly the window between the early `existsSync` refusal and the write: the run must remove no row and must leave the squatter's bytes intact. The positive control runs the same path over a free destination and asserts a row IS removed, so the empty list above cannot be satisfied by a command that can never delete anything. Also amends the existing changeset to state the atomic guarantee rather than just the pre-check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
The family in json-stdout-purity.e2e.test.ts is DISCOVERED from the source tree — every command that calls bootSchemaStack and declares a `--json` flag — and reconciled against FAMILY. `os secret orphans` satisfies both halves, so it joined the discovered set the moment it landed and the reconciliation went red, exactly as that file's docblock promises it should. Listed and actually driven, not just named. Its report-only default is the form driven here: without `--delete` it boots, reports and writes nothing, so the family gains a member without the fixture gaining a destructive run. Measured on the uncompiled fixture before listing it, in case the red was hiding a real leak — it was not: stdout is a single 2836-byte line that a bare JSON.parse accepts (keys `mode,plan`), with zero kernel-logger records and zero `[StandaloneStack]` lines, while all three boot diagnostics stay on stderr. No change to the command was needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
Fixes #8103
The operator-run half of the pre-fix
sys_secretresidue: a newos secret orphanscommand that reports the rows no producer references any more and, only behind
--delete, removes the ones the maintainer ruling permits.Ruled shape, executed rather than re-litigated: B (2026-08-26) — an explicit
operator command, dry-run report as the default, deletion behind an explicit flag, a
mandatory pre-delete export, and the
unattributableclass never deleted — and B'(2026-08-27) — sequenced behind the cross-producer reference union, so the predicate is
"attributable AND unreferenced by the COMPLETE union". Report-only remains the recorded
fallback and this PR does not take it. There is no boot-time or upgrade-time sweep;
nothing on any boot path reaches this command.
The reproduction, on real code
The card turns on one measurement, and it is reproduced against the real producers
rather than argued (
sys-secret-orphan-sweep.test.ts, first describe block):secret-typed field's handle, still named by abusiness row's own column — is classified
orphanedby the shipped settings-scopedclassifier, i.e. it lands in the old predicate's deletable bucket;
referenced, with its holder coordinates(
object-field: smtp.password#rec_1), and never reaches the deletable list.Both halves are asserted in one test so neither can drift from the other. The fixture
mints all three handles through the producers that really write them — the real
ObjectQLinsert path, the realLocalCryptoProvider, the real datasource credentialbinder — so the ref spellings under test come from the producers, not from the test file.
Its positive control is that the same planner over the same fixture does return a
non-empty deletable list (a genuine settings orphan), so every "empty" assertion in the
file is falsifiable.
The safety property, and how it fails loudly
sys_secrethas three producers and no producer column, so "unreferenced bysys_setting" is not "unreferenced". The command therefore decides on the union of allthree holder families and refuses to delete whenever any family could not be
enumerated, naming that family. The union's three independent gap sources are respected
and none is read as "no references":
--declared-datasourcesand no--no-declared-datasourceslistDatasourceDefs()sys_settingPer-family status and reason are carried through to the operator, to
--jsonand into theexport — never flattened to a boolean. A row that is unreferenced by what enumerated is
withheld under its own class,
union_incomplete, distinct from the other withholds.Three further refusals are mechanical rather than advisory, because a delete over a
secrets table cannot delegate to an operator's attention:
unattributableis never deletable — the ruling's hard line.sec_guard from fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063turned in the opposite direction: an inline
value_encnames no handle, so asys_secretrow sharing its(namespace, key)looks unreferenced because of thelegacy value. The shipped report flags it and tells an operator to look; here it is
excluded from the deletable set outright.
rotateKey()keeps the handle stable, soversionand
rotated_atare reported and are never verdict inputs. Pinned in both directions:re-wrap evidence on a referenced row does not make it deletable, and the same evidence
stamped on a genuine orphan does not change its decision either.
The mandatory export
The settings audit trail records content digests, never handles, so a row deleted in
error can neither be named afterwards nor recovered.
--deletewithout--exportisrefused; the file carries the cipher material (an export that named the row without it
would make the mistake describable and still permanent), is written owner-only, refuses
to overwrite, and is read back and checked against the plan before a single row is
removed. Building an export that is short by even one deletable row is a hard error.
The operator-facing text states the measured, inverted framing
The tempting sentence — "this cleans up leaked old credentials" — is false for this
population, and the command never says it. On the pre-fix rotation path the handle was
never repointed, so the value still in force is the oldest one — the credential the
administrator believed they had replaced — while each orphan holds a value that never
took effect. Deleting orphans therefore retires nothing that is exposed, and if the
administrator also rotated at the provider the newest orphan may be a credential that is
currently valid there. That paragraph is data (
SWEEP_EXPOSURE_NOTES) and is asserted bytest, including a negative assertion with its own positive control.
Reachability facts, re-measured on the day
Re-measured against
origin/main@b1a987e4aon 2026-08-28, not cited:local-crypto-provider.ts:459mints'sec_' + randomBytes(16).toString('hex')insideencrypt(). The id carries no producer marker: a prefix plus 32 hex characters ofentropy and nothing else.
rotateKeyat:494returnsid: handle.idwithversion + 1, so a re-wrap keeps the handle and produces no orphan.sys_setting.value_encis the sole holder — FALSIFIED, sustained. Three producerswith three holder columns, and
sys_secretdeclares nine fields (id,created_at,rotated_at,namespace,key,kms_key_id,alg,version,ciphertext) — noproducer column among them. This falsification is the premise this PR is built on: the
union is the repair for it, which is exactly why B' sequenced the union first.
reaching an audit payload field are
crypto.digest(plain)('sha256:'+ sha256),null, or that digest wrapped in an encrypted marker. Positive controls on the samefile:
sec_appears twice andvalue_enc25 times, so the scan is alive over it; thezero is about the audit payload fields alone. This is what makes the export
load-bearing rather than belt-and-braces.
Verification
Gate union derived after the last commit, from the real changed set
(
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reads themerge base itself), and every result below was taken at
ba99af32e, the head this PRcarries. Exit codes captured before any pipe.
pnpm --filter @objectstack/cli typecheck— green.tsc --listFilesconfirms all threenew files are in the program, the test file included, so the green is not a phantom over
sources nothing read.
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2over the two new files —Test Files 2 passed (2) / Tests 25 passed (25).the new command: lazy-driver-import, invocation, ADR-0048 app split, doctor deprecation
hints — 6 files,
Tests 61 passed (61).pnpm lint(eslint . --no-inline-config, the whole repo, not narrowed) — green, 98s.check:nul-bytes,check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:dispatcher-error-vocabulary,check:cross-package-test-inputs,check:query-options-erasure,check:type-check-coverage,check:type-check-debt --re-measure,check:i18n,check:i18n-coverage,check:empty-changeset,check:objectui-changeset,check:changeset-no-major,check:adr-0087-registration,check:cli-command-ids,check:test-source-alias,check:type-source-resolution,check:published-files,check:page-declaration-shape,check:slot-lookup,check:comment-mask-adoption,check:plugin-teardown-shape,check:ci-filter-parity, and the docs family (doc-authoring,doc-anchors,docs-single-h1,docs-audit-scope,docs-redirects,role-word,doc-frontmatter,doc-route-spelling,docs-section-name,section-landing-index).check:i18nandcheck:i18n-coveragefirst returned PREREQUISITE NOT MET andCOULD NOT MEASURE on a fresh worktree — an unbuilt CLI and unbuilt example
dependencies, not a red. Both were re-run green after building the workspace closure the
way lint.yml does; the not-measured readings are recorded rather than reported as passes.
Ablation — two runs, each isolated to its own guard
Both mutate the planner, prove the mutation landed on disk (anchor count 1 to 0, injected
count 0 to 1, and a changed blob hash) before reading any verdict, and restore with
git checkout HEAD --against an absolute path under a trap on EXIT/INT/TERM. Restorationis proven by state, never by exit code: the on-disk blob hash equals the HEAD blob hash
and
git diff HEADis zero lines. No rebuild leg is needed and none is claimed — the testimports the module by an intra-package relative specifier, so vitest resolves it to source;
packages/cli/distdid not exist at all during either run, which is the positive controlfor that claim.
class: all three gap-source pins plus the export's empty-when-incomplete pin. The other
21, including every other guard and the reproduction itself, stayed green.
unattributableguard — exactly 1 red, the pin that names it.Declared narrowing
The full
@objectstack/clivitest suite was not run to completion locally. It is along integration suite (it boots real stacks per test); two in-round blocking waits of
570s each did not reach its summary, so it was stopped and left to CI, which runs it
regardless. What the greens above therefore do not cover: any CLI test outside the six
files named. Everything the change itself is judged by — the new pins, the package
typecheck, the package's whole-source scanning pins, repo-wide ESLint and the derived gate
families — was run.
Files
packages/cli/src/utils/sys-secret-orphan-sweep.ts— the pure planner and the exportbuilder. No I/O, no decryption; every refusal is a returned value, which is what makes
them testable.
packages/cli/src/utils/sys-secret-orphan-sweep.test.ts— the reproduction and the pins.packages/cli/src/commands/secret/orphans.ts— the command.packages/cli/src/commands/secret/orphans.guards.test.ts— the two command-level guards:an unreadable declared-datasources file never degrades into
[], and a driver without adeleteis refused rather than cast onto the union's read-only port.content/docs/deployment/cli.mdx,.changeset/secret-orphans-command.md.Nothing under
packages/specand no new published error code — the refusal reuses thestandard catalog's
PRECONDITION_REQUIRED, the same pair the union's own error carries.Generated by Claude Code
Amendment (2026-08-28) — the pre-delete export is now CREATED, never written onto
Narrow revision after the review ruling on this PR. One production line changes.
The window.
--exportrefuses a path that already exists, but that check sits near thetop of
run()— before the boot, before the scan, before the interactive confirmation —while the write happens after all three. Between them is an arbitrarily long window. The
write used the default flag, which truncates an existing file; and
modeis applied by theOS only when a file is CREATED, so a write onto a path some other process won inside that
window would have inherited that file's owner and permissions and put cipher material
inside them.
The change.
writeFileSync(exportPath, doc, { encoding: 'utf8', mode: 0o600, flag: 'wx' })— create-or-fail.
EEXISTlands in theexport_failedrefusal that already existed (no newbranch, no new error code), so nothing is deleted, and
0600is guaranteed at the instant ofcreation. The early
existsSyncpre-check is deliberately kept: it still produces the betteroperator message, and the two are defence in depth rather than alternatives. The pre-check was
never the guarantee;
wxis.The new pin (
orphans.guards.test.ts) drives the real--delete --jsonrun with only twoseams faked —
bootSchemaStackandcollectSecretReferenceUnion. Everything that decidesWHICH rows are deletable runs for real:
planSysSecretOrphanSweep,buildPreDeleteExport,collectEncryptedSpecifierRefs,isSecretHandle. The subject test creates the export fileinside the mocked boot — i.e. inside the window itself — and asserts the harm first (no row
removed, and the squatter's bytes byte-identical), then the envelope (
error: 'export_failed',message naming the path). Its positive control runs the same path over an unclaimed
destination and asserts a row IS removed and the export IS written
0600, so the emptydeletion list above cannot be satisfied by a command that can never delete anything.
Verification — every figure taken at
ab927b16, after the last commitpnpm --filter @objectstack/cli typecheck(tsc --noEmit) — exit 0, captured before anypipe.
tsc --noEmit --listFiles | grep commands/secret/orphansreturns 2 lines, namingboth edited files, so the green is measured over them and not a phantom.
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/secret/orphans.guards.test.ts—
Test Files 1 passed (1) / Tests 11 passed (11).pnpm lint(eslint . --no-inline-config, the whole repo, not narrowed) — exit 0, 99s.(
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack) and run, eachquoting its own verdict line:
check:nul-bytes(OK, 7193 text files, no raw ASCII controlbytes),
check:test-source-alias,check:cross-package-test-inputs,check:type-source-resolution,check:objectql-double-limit,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:type-check-debt --re-measure(31 ledger entriesre-measured, none above its recorded number),
check:i18n,check:i18n-coverage(12 configs, 602 baselined, none new),
check:page-declaration-shape,check:slot-lookup,check:published-files,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:dispatcher-error-vocabulary,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-comment-mask-adoption,check-plugin-teardown-shape,check-ci-filter-parity— all green.check:i18n-coveragefirst read COULD NOT MEASURE (an unbuiltconnector-mcp/connector-openapiin this partial worktree, not a red) and is reported green only after theworkspace closure was built the way lint.yml does.
check:type-check-debtlikewise refusedoutright on an unbuilt closure and was re-run after
turbo run build --filter='./packages/*' --filter='./packages/*/*'.doc-authoring,doc-anchors,docs-single-h1,docs-audit-scope,docs-redirects,role-word,doc-frontmatter,doc-route-spelling,docs-section-name,section-landing-index) is matched only viacontent/docs/deployment/cli.mdx, which this amendment does not touch. It was green in theprevious round at
ba99af32eand CI runs it regardless.Ablation — the flag itself, one leg
Delete
, flag: 'wx'from that one call and nothing else. Proven on disk before any verdictwas read: anchor
flag: 'wx'1 → 0, replacementmode: 0o600 }0 → 1, and the blob hash moved57283a48…→5882dea2…. Restored withgit checkout HEAD --against an absolute pathunder a
trap … EXIT INT TERM, and the restoration proven by state rather than by exit code:the on-disk blob hash is
57283a48…again — equal togit rev-parse HEAD:packages/cli/src/commands/secret/orphans.ts— and
git diff HEADfor that path is zero lines.Test Files 1 failed (1) / Tests 1 failed | 10 passed (11), and it fails namingthe harm rather than the envelope:
expected [ 'sec_orphan_1' ] to deeply equal []. Therun truncated the squatted file and then deleted the row — exactly the defect.
Test Files 1 passed (1) / Tests 11 passed (11).than a second copy of the subject.
No rebuild leg is needed and none is claimed: the test imports the command by an intra-package
relative specifier, so vitest resolves it to
orphans.tssource and nodist/artefact ison the resolution path.
Not a new changeset
.changeset/secret-orphans-command.mdwas amended in place rather than a second file added: italready describes this export, and its "refuses to overwrite" sentence now states the atomic
guarantee instead of only the pre-check.
Generated by Claude Code