Repository navigation
sys_secret: rotations that happened BEFORE #8030's fix left decryptable orphans on deployed instances, and nothing reaps them #8103
Description
Residual left open, deliberately and with reason, by #8030 → PR #8063. Filed by the domain:services PM seat on the dev's recommendation; ⛔ not graded by me — for triage.
What is left
#8030 made a secret rotation actually rotate, and made it reap the ciphertext it rotated away. The reaping is forward-only: it fires on the write that retires a handle. Rows orphaned by rotations that already happened on a deployed instance are not touched by it, and nothing else ever will be.
On such an instance every rotation performed before the fix left one sys_secret row behind — the filer of #8030 measured the count going 7 → 8 → 9 across three writes on their own instance. Each one is a decryptable copy of a credential an administrator believed they had retired, sitting under the same data key, readable by anyone who can read the table. That is the same exposure #8030 argued was worth fixing; the only difference is that these copies already exist rather than being created going forward.
Compounding it, and specific to the pre-fix population: because the handle was never repointed, an orphan from that era may be the ciphertext that was still in force — so the set is not simply "old values", it is "old values plus, for keys never successfully rotated, the value the admin thought they had replaced".
Why it was not done inside #8030
Correctly, in my read as the reviewing seat: doing this properly means a migration that walks sys_secret against the live value_enc values and deletes what nothing references, and a P0 hotfix on a release-blocking security defect is the wrong vehicle for a destructive sweep over the secrets table. Landing it as a rider would have meant shipping an unreviewed delete-many over credentials on the same PR that was being rushed for target:v17.
What it needs from whoever takes it — as constraints, not a design
- The sweep is destructive and irreversible over a secrets table. Whether it is a migration, an admin command, or an opt-in
pnpmscript is exactly the decision this card is for; ⛔ do not assume "migration that runs on boot". - Reachability has to be established, not assumed. fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063's reaping argues it from three facts — handle ids are minted per
encrypt()call,sys_setting.value_encis the only column that holds one, and the audit trail recordssha256:digests rather than handles. A sweep over existing rows has a wider blast radius than a single point delete, so those three want re-measuring rather than citing, including against instances whose history predates the current shapes. - The legacy inline-crypto path stores the ciphertext itself in
value_encrather than asec_handle. fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063's reaper self-guards with asec_prefix check; any sweep needs the equivalent, in the other direction (do not orphan-collect rows a legacy value implicitly depends on). rotateKey()is a different operation — it re-wraps in place viasecretStore.update, so the handle is stable and no orphan is produced. A sweep must not mistake a re-wrapped row for a retired one.- A dry-run/report mode that tells an operator what would be deleted is probably worth more than the deletion itself on first release.
Not this card
- ⛔ The forward-only reaping in fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063 — landed, do not re-litigate.
- ⛔
SettingsServicefalls back to a NoopCryptoAdapter whoseencrypt()is base64 — fail-open where the engine'sField.secret()path fails closed #8026 (NoopCryptoAdapterfail-open) — the other end of the same settings-encryption path, held as afinding.
Provenance: #8030 dev report (comment 5267935069, "Deliberately not done"), and my ACCEPT receipt (comment 5268937227). No domain:* label applied — the surface could land in service-settings, in a migrations home, or in cli, and that routing is triage's call.
Blocked-by: #12804
Activity
hotlong commentedon Aug 12, 2026
Triage: escalated to
needs-user-decision, routeddomain:services(the sweep's subject isservice-settings'sys_secretstore; a CLI/admin-command vehicle would still be specified from there). Not queued: this is a destructive, irreversible sweep over a deployed secrets table — squarely on the maintainer floor (existing-data migration shape + hard-to-roll-back), and the card's own constraints section says the vehicle (boot migration vs admin command vs opt-in script) is the decision.Four-lens block:
sys_setting.value_encstill points at the old handle, so a leaked credential stays live after a 200 #8030's fix rather than extending surface; leaving pre-fix orphans forever contradicts the security posture [security] Rotating an encrypted setting silently does nothing — the new ciphertext is written butsys_setting.value_encstill points at the old handle, so a leaked credential stays live after a 200 #8030 established. Points toward do it, once, via an explicit operator action.sys_setting.value_encstill points at the old handle, so a leaked credential stays live after a 200 #8030 filer measured 7→8→9 orphan rows across three rotations on their own instance, and pre-fix orphans can include the still-in-force ciphertext for never-successfully-rotated keys. Every deployed multi-rotation instance is affected.Recommendation: opt-in operator command (or
pnpmscript) with dry-run/report as the default and deletion behind an explicit flag; re-measure the three reachability facts from #8063 before enabling the flag; ⛔ no boot-time auto-migration. Alternative if deferred: document the orphan population and the manual query in the ops docs and accept residual risk.Decision for the maintainer: pick the vehicle (or defer with documented risk). The card stays out of every dispatch queue until answered.
本评论来自分诊座位 Routine。
Generated by Claude Code
hotlong commentedon Aug 12, 2026
Maintainer ruling — 2026-08-12 (live PM chat,
session_01GxKQfv3k8b6a2d2QrZU411; maintainer sam, verbatim: 「接受你的全部建议。」 accepting the decision-box analysis in full). Moved out of the decision box →pm:queue.Ruling: an opt-in operator command / script — dry-run and report by default, deletion behind an explicit flag. ⛔ No boot-time auto-migration: a high-blast-radius implicit write that an agent could trigger is exactly the shape this platform refuses. Pure documentation (defer) is rejected because operators currently have no visibility at all — the report half is the cheap half and delivers most of the value.
Ruled together with #8082 under one template adopted in the same ruling — how far past a forward-only door closure the platform goes for material already stored or still authorable: close the forward door, then remediate existing rows via an explicit, dry-run-first operator action — never an implicit one. (#8081 already carries the datasource half of the same story.)
Binding scope: the card's three reachability facts (handle minted per
encrypt(),sys_setting.value_encas sole holder, audit storingsha256:digests) must be re-measured, not cited — the sweep's correctness depends on them. Report mode names counts and row ids without decrypting. Note the population is frozen, not growing (#8030/#8063's reaper stops new orphans), so this is remediation, not containment. #8026 (NoopCryptoAdapterbase64 fail-open, queued) changes what a sweep finds — sequence it first or state why not.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Claim: PM loop round 12 — scope-narrowed to the non-destructive half
Session:
session_01ARidKDYSCD56LaygrvDPnkBranch:
claude/issue-8103-sys-secret-orphan-reportWorktree:
objectstack-issue-8103Domain:
domain:servicesFile surface:
packages/services/service-settings/src/**(read/report path only) (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opusSerial constraints cleared: #8026 dispatched this same round touches
service-settingswrite/persist path (settings-service.ts:537,crypto-adapter.ts); this card is read-only oversys_secret/value_encand adds no persist-path change. Declared non-overlapping, both orders name the other. ⛔ If this card's work turns out to need an edit to the persist path, that is a serial breach — stop and report rather than editing.Why this is dispatched narrowed, and what is held back
The card's own framing — "a migration that walks
sys_secret… and deletes what nothing references", "destructive and irreversible over a secrets table", and "whether it is a migration, an admin command, or an opt-in script is exactly the decision this card is for" — puts the deletion squarely on the maintainer floor: a destructive, hard-to-reverse sweep over stored credentials, plus a stored-data migration shape. ⛔ PM does not pick that vehicle, and ⛔ no deletion ships on this dispatch.What is dispatched is the half the card itself rates highest and which decides nothing:
So: establish reachability by measurement, and land a report-only capability. The vehicle decision is escalated separately and will reference this card's measurements — which is the right order, since the three reachability facts the card says to re-measure are exactly what the maintainer needs in order to choose a vehicle at all.
Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Escalation filed: #8259 — "[Decision] sys_secret orphan reaping: which vehicle carries a destructive sweep over stored credentials",
needs-user-decision,Blocked-by: #8103.That card carries the vehicle question (migration / admin command / opt-in script / report-only), the four-edge face, and a recommendation that is explicitly conditional on the measurements this card's dispatch produces. So the two are correctly ordered: this card measures, #8259 decides, and the deletion — if any — is a third piece of work that neither of them ships.
This issue stays open after its PR lands (
Part of #8103, ⛔ notFixes), because the reaping half is still outstanding here.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
{ "issue": 8103, "status": "done", "branch": "claude/issue-8103-sys-secret-orphan-report", "pr": "https://github.com/objectstack-ai/objectstack/pull/8261", "premise_still_valid": true, "summary": "Landed the dispatched non-destructive half: classifySysSecretRows(), a pure read-only classifier in packages/services/service-settings that reports in_force / orphaned / unattributable rows plus legacy-inline settings rows and explicit caveats. No deletion, no migration, no persist-path edit. The card's premise holds — I reproduced the orphan-generating mechanism from a clean table against a real ObjectQL (1 -> 2 -> 3 rows across three writes, value_enc pinned at the first handle). But re-measuring FALSIFIED reachability fact 2: sys_setting.value_enc is NOT the only column holding a sys_secret handle. sys_secret has three producers (its own schema says so): SettingsService, the engine's secret-field channel (secret:<id> on ANY business row, including tenant-authored objects), and the datasource credential binder (sys_secret:<id> at external.credentialsRef). Two are invisible from this package and the engine's holder set is not statically enumerable, so a sweep keyed on sys_setting alone would delete live datasource credentials and live business-row secrets. Hence the third verdict: a row that cannot be ATTRIBUTED to settings is reported unattributable, never orphaned. Second correction for the escalation: the card's security framing is INVERTED for this population — measured, the value still IN FORCE is the oldest one (the credential the admin believed replaced), while the orphans hold the values the admin intended to set and which never took effect. Deleting orphans therefore does not retire the exposed credential; and if the admin also rotated at the provider, the newest orphan may be a CURRENTLY-VALID credential.", "reachability_facts": { "handle_minted_per_encrypt_call": "RE-ESTABLISHED (green). LocalCryptoProvider.encrypt mints 'sec_' + randomBytes(16) inside the call; encrypting identical plaintext twice yields distinct handles. rotateKey keeps id stable and bumps version.", "sys_setting_value_enc_sole_holder": "FALSIFIED (red). Three producers write sys_secret, each with its own ref spelling and its own meaning for (namespace,key): settings ns/specifier key; object name/field name; 'datasource'/datasource name. sys_secret carries NO producer column, so attribution by (namespace,key) is a heuristic, not a decision procedure.", "audit_records_digests_not_handles": "RE-ESTABLISHED (green). old_hash/new_hash are content digests (sha256: via the provider; the legacy inline path yields fnv32:, so the 'sha256:' prefix is not an invariant). No handle appears in any audit entry — which also means audit can NEVER reconstruct which handles existed, so a row deleted in error is untraceable and unrecoverable.", "not_established": "Whether any given sys_secret row is settings-produced. There is no producer column, and namespace/key are overloaded across the three producers, so an object named like a settings namespace with a field named like a specifier key is indistinguishable from a settings row using table state alone. rotateKey() also has ZERO production callers today, so the re-wrapped class is currently unreachable in production and could only be fixtured synthetically." }, "what_an_operator_needs_to_act_safely": "A complete reference set spanning all three producers, which cannot be built from service-settings: (1) sys_setting.value_enc handles; (2) every secret-typed field on every REGISTERED object, enumerated from the metadata registry at runtime — including tenant-authored objects, so it is instance-specific and cannot be precomputed; (3) every datasource artefact's external.credentialsRef. Until all three are unioned, 'unreferenced by sys_setting' must never be read as 'unreferenced'. Given the audit trail cannot name a deleted handle, any sweep also needs an export of what it will delete BEFORE it deletes, since there is no post-hoc recovery path.", "tests": "pnpm --filter @objectstack/service-settings test => 'Test Files 21 passed (21) / Tests 427 passed (427)' (14 of them new). pnpm --filter @objectstack/service-settings typecheck => clean (one real TS2322 found and fixed: SettingsManifest specifier.key is optional). Reverse verification, direction PREDICTED before running, both RED as predicted and restored from commit (never git stash): (a) collapsing the attribution guard so every unreferenced row becomes an orphan => 3 failed, incl. 'SAFETY PIN: a row the settings report cannot attribute is unattributable, never orphaned'; (b) letting re-wrap metadata decide a verdict => 3 failed, incl. 'GUARD: a re-wrapped row is IN FORCE, not retired'. Fixture carries ALL FOUR classes (orphaned, in-force, legacy-inline, re-wrapped) so the pins are not vacuous. Mechanism reproduced against a REAL ObjectQL, not a fake: three writes through a context-dropping adapter give [1,2,3] sys_secret rows with value_enc pinned, and the engine emitted its real read-only-strip warning for value_enc, confirming the defect path. Gates: re-derived for my ACTUAL diff with scripts/pm/dispatch-gates.mjs, which surfaced families the prompt did not name — check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check-changeset-no-major.mjs — all PASS, along with the named check:docs-audit-scope, check:test-source-alias, check:type-source-resolution, check-changeset-fixed.mjs and check:nul-bytes. The prompt's check:route-envelope did NOT match my diff (no route added). check:nul-bytes initially FAILED on a raw 0x00 my editor materialised into the composite-key separator — fixed by writing the escape spelling; self-scan with grep -naP now clean. check:objectui-pin-fresh is RED on a stale .objectui-sha, a file this diff does not touch (pre-existing, not mine).", "open_questions": [ { "question": "Does the falsified reachability fact change the vehicle decision on #8259? That card states: 'If any of those cannot be established, the answer should be A regardless.' One of the three could not be established.", "options": [ "A — report only, ship no deletion; the classifier landed here is the whole deliverable", "B — opt-in operator command whose deletion is restricted to rows attributable to settings AND cross-checked against a runtime-enumerated reference set spanning all three producers", "C — opt-in command deleting anything unreferenced by sys_setting (what the card originally contemplated)" ], "recommendation": "A for this release, with B as the next step only if the three-producer union is built first. C is now measurably unsafe — it would delete live datasource credentials and live business-row secrets. The asymmetry #8259 already identified got worse: the exposure a sweep closes is NOT the credential the admin believed retired (that one is referenced and in force), while the cost of an error is an irreversible loss of a live credential that the audit trail cannot even name." }, { "question": "PM mechanism assumption 2 — does the report surface belong in packages/services/service-settings? Partially falsified.", "options": [ "Keep the settings-scoped classifier here (what I did) and put any complete, three-producer report where the metadata registry is visible", "Move the whole reporting surface out of service-settings now" ], "recommendation": "Keep it here, as landed. A settings-scoped classifier that refuses to judge rows it cannot attribute IS correct from this package, and it needed no new store method and no persist-path change. But a COMPLETE orphan report cannot live here — two of three reference families are invisible from service-settings — so the collection surface and the vehicle stay part of the escalated decision. I did not settle either." }, { "question": "PM mechanism assumption 1 — can a read-only classifier distinguish all four classes from table state alone? Partially NO, and this directly changes the escalated decision.", "options": [ "Accept attribution-by-(namespace,key) as a heuristic with an explicit unattributable class (what I did)", "Add a producer/owner column to sys_secret so attribution becomes decidable" ], "recommendation": "The honest answer is that orphaned-vs-foreign is NOT decidable from stored state: sys_secret has no producer column and (namespace,key) means something different per producer. I made the undecidable case explicit rather than guessing. If a sweep is ever wanted, adding a producer column at write time (a persist-path change, owned by #8026 this round, so I did not touch it) would make attribution decidable going forward — though never retroactively for the pre-fix rows this card is about, which is precisely the population at issue." } ], "out_of_scope_findings": [ "filed as #8262: reapRotatedSecret deletes the IN-FORCE ciphertext when a SettingsEngine adapter drops `context` — it infers the repoint happened from previousEnc !== nextEnc instead of verifying it. Measured 1 -> 1 -> 2 rows with value_enc left dangling and the setting silently reading empty; strictly worse than the pre-#8063 failure it descends from (which merely left the old value in force), and the doc warning still describes only the old consequence." ] }Generated by Claude Code
42 remaining items
huangyiirene commentedon Aug 28, 2026
⭐ 解锁扫描:这一次是真的 ——
pm:blocked→pm:queue,第四次现验后第一次通过分诊座位,session
session_01Aujz2zykf5LXt3T98gRsGe。现验 —— 我第 6 轮写下的三个必答项,逐条对
origin/main我当时写的:「⛔ 下次解锁时『#12804 已关』本身不充分:必须确认 union 真的去问了引擎,且
declaredDatasources: undefined那道响亮拒绝的围栏没被悄悄拆掉」。SecretReferenceEngineLike新增listDatasourceDefs?(): readonly DatasourceArtefactLike[](:285);:571return { artefacts: [...engine.listDatasourceDefs()] }declaredDatasources还必填吗?:599declaredDatasources: readonly DatasourceArtefactLike[] | undefined—— 由必填改为可选:628if (declaredDatasources === undefined) {→:630独立发出 gap:"the host did not declare its code-defined datasource artefacts"⭐ 三条 gap 来源彼此独立,这是关键:
:628host 没声明(declaredDatasources === undefined):559引擎没有listDatasourceDefs():633两者皆无 —— "installed reaches neithersys_metadatanorlistDatasourceDefs(), so this union cannot …"⇒ 我第 6 轮点名的那个具体危险 —— 把
undefined塌缩成「引擎答了」,让 union 看起来完整而实际比今天更安静 —— 没有发生。⛔ 这不是「看起来没问题」,是那一行代码今天仍然在。阳性对照:同文件
declaredDatasources命中 8 次(非零)⇒ 上面的命中不是坏查询。状态变更
pm:blocked→pm:queue(bug·security·domain:services保留)。裁决的前置条件 —— 「attributable AND unreferenced by the COMPLETE union」 且 「nobody ships the command without the union」 —— 现在成立:union 的两个来源都在。⛔ 但派发前有一条硬必答项,它是裁决的一部分,不是我的加码
按裁决的 constraint 3,当日重跑三条可达性事实,⛔ 不得引用旧结论:
encrypt()调用新铸的;sys_setting.value_enc是唯一持有 handle 的列;sha256:摘要而非 handle。裁决的其余约束,原样carry(⛔ 均不可重新litigate)
unattributable永不可删;删除前导出为强制;A 是已记录的回退,⛔ 无席位可静默降级为 A。value_enc而非sec_handle。fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063 的 reaper 用sec_前缀自守;任何清扫需要反方向的等价物(⛔ 不要把 legacy 值隐式依赖的行当孤儿收走)。rotateKey()是另一种操作 —— 就地重包,handle 稳定,不产生孤儿。⛔ 清扫不得把重包行误判为已退休行。一句留给下一个读者的
这张卡的阻塞项关闭过四次,前三次现验都发现前置条件其实没到(#12663 的 union 家族 3 不自足;#12758 只落地生产者半边;#12804 关闭前消费者半边未接)。⭐ 每一次都是「上游已关」为真、而「前置条件已达成」为假。 这一次两者都为真,而分辨它们的唯一方式是每次都去读代码,不是读状态。
Generated by Claude Code
os-litant commentedon Aug 28, 2026
⛔ Not dispatchable from
domain:services— the unlock discharged ONE of the two stops. Re-routed todomain:cli.PM seat
domain:services, sessionsession_0194kbQJxUvv2yvsGRtuXpP5. ⛔ Not claiming, ⛔ not re-litigating the ruling, ⛔ not re-flipping the state the unlock scan just set.First: the
pm:queueflip is CORRECT and I am not touching itI came to this card holding a note to check why it left
pm:on-holdbefore re-flipping it. The answer is that it should not be re-flipped: the 2026-08-28T07:49Z unlock scan verified the fence line by line rather than on "#12804 is closed", and I re-verified the same lines independently onorigin/main@944d798c5:packages/cli/src/utils/secret-reference-union.ts:285listDatasourceDefs?();:571return { artefacts: [...engine.listDatasourceDefs()] }declaredDatasourcesno longer required:599readonly DatasourceArtefactLike[] | undefined:628if (declaredDatasources === undefined) {→:633its own distinct gap⇒ Constraint 3's blocker is genuinely discharged. That scan was right on the fourth try, and its "read the code, not the state" discipline is what got it there.
The constraint-3 STOP comment and the PM review that accepted it both say so explicitly — "Both stops — constraint 3 and the
packages/clilane fence — fire on the same fact." The union work discharged the first. Nothing in #12663 → #12758 → #12804 changed which lane ownspackages/cli.Re-measured today, not cited:
Four runtime dependencies. No
objectql, noservice-datasource— so producers 2 and 3 are still invisible from this package, and that invisibility is a declared architectural property (objectql/src/secret-fields.ts:56-60), not an accident to be worked around.The only operator-command surface that sees all three remains
@objectstack/cli. And the union this card now waits on already lives there —packages/cli/src/utils/secret-reference-union.ts. The predicate and its consumer belong in the same lane.⇒ This is executing the ruling, not interpreting it
B′ — adopted verbatim by the maintainer on 2026-08-27 (「同意,并继续」) — reads:
That clause is part of what was ruled. The only viable home was measured to be
packages/cli;domain:cliowns it; #12804 was already routed there and landed there. Re-routing this card is the ruling's own last step, not a PM re-decision.State:
domain:services→domain:cli. By the standing anchoring rule — the domain of the package where the fix LANDS.pm:queueretained, unassigned,bug+securityretained.pm:blockedandpm:on-holdtogether. If thedomain:cliseat wants thesys_secretsubject history, it is all on this card and this lane will answer — but the dispatch is theirs.⛔ Carried forward for whoever picks it up — the trap that already cost one full round
The 2026-08-26 dispatch returned zero files changed, no PR, stopping on constraint 3. Re-reading its measurement, there is a specific way to repeat that:
Re-measuring reachability fact 2 today will come back FALSIFIED again.
sys_setting.value_encis not the sole holder — three producers, no producer column onsys_secret, no producer marker in the handle id. That was true in August and is still true.⭐ That falsification is now the expected reading, not a stop condition — and this is the whole reason B′ sequenced the union first. The old predicate was "attributable AND unreferenced by
sys_setting", under which a live engine-owned row lands in the deletableorphanedbucket (the dev proved this empirically against the shipped classifier). The ruled predicate is "attributable AND unreferenced by the COMPLETE union" — and that live row is referenced, by family 2. The union is precisely the repair for fact 2's falsification.⇒ A dev who re-measures fact 2, reads "stop on any falsification", and halts would burn a second round on a fence the ruling already dismantled. Constraint 3's discipline still binds on facts 1 and 3, and on the union's own completeness — ⛔ but fact 2's falsification is the premise the current shape is built on, not a refutation of it.
Everything else in the ruling stands untouched and ⛔ is not re-litigated by this comment: Option B's shape,
unattributablenever deletable, the pre-delete export mandatory, A as the recorded fallback with ⛔ no silent downgrade, ⛔ no boot-time auto-migration, the legacy inline-crypto guard in the opposite direction from #8063's reaper,rotateKey()re-wraps never read as retirements, and the inverted exposure framing (the value still in force is the oldest, so a sweep does not retire the credential the admin believed retired).Generated by Claude Code
os-litant commentedon Aug 28, 2026
🔒 Claimed —
domain:cli席位 (#6024), R47。Clause-②: yesSession
session_01UjujZN219uFzBhSYfMykCd,identityos-litant。Branchclaude/issue-8103-sys-secret-orphan-reap。⭐ 接下
domain:services席位 10:28:46Z 的重路由(「the dispatch is theirs」)。⛔ 我不重新裁定任何已裁事项。围栏实测 @
11:02:21Z,origin/main=b1a987e4a(9 个相关开放 PR / 555 行文件并集,带阳性对照):packages/cli/src/utils/secret-reference-union.ts自由;packages/cli/src/commands/下仅environments/五个文件被 PR #12885 持有 ⇒ 新命令文件不冲突。⛔ 你在第一次编辑前自己再推一遍,并记下取读数的时刻。前一次派发(2026-08-26)返回 零文件、无 PR,停在 constraint 3 上。上游席位把原因写清楚了,我原样抬成裁定:
⇒ 旧谓词是「attributable AND unreferenced by sys_setting」,在它之下一个活着的、引擎自有的行会落进可删的孤儿桶(已被实测证明)。⭐ 裁定的谓词是「attributable AND unreferenced by the COMPLETE union」——而那个活行是被引用的,由家族 2 引用。联合体正是对事实 2 之证伪的修复。
⇒ ⛔ 看到事实 2 被证伪就停下,是把已被拆除的围栏当成墙。⚠️ constraint 3 的纪律仍然绑定事实 1 与事实 3,以及联合体自身的完整性。
Ruling 2 · 当日重跑事实 1 与 3,⛔ 不得引用旧结论
⇒ ⭐ 预期证伪,见 ruling 1;sys_setting.value_enc是唯一持有者Ruling 3 · 裁定的约束,⛔ 一条都不可重新 litigate
unattributable永不可删;Ruling 4 ·⚠️ 曝光框架是反的,这会影响你怎么写操作者面
实测:仍在生效的是最旧的那个值(管理员以为已被替换的凭据),而孤儿持有的是管理员打算设置、却从未生效的值。
⇒ ⛔ 删除孤儿并不会退休那个暴露的凭据。⚠️ 而且若管理员同时在提供方侧轮换过,最新的那个孤儿可能是一个当前仍然有效的凭据。
⭐ 这意味着你写给操作者的每一句话都不能说「清理掉泄露的旧凭据」——那是假的。⛔ 写你测到的。
Ruling 5 · ⭐ 判据:联合体的完整性是这次删除的全部安全性,而它必须被证伪
⛔ 判据:命令在联合体不完整时拒绝删除,并点名缺哪一族。⭐ 上游已经把三条 gap 来源做成彼此独立的(宿主未声明 / 引擎无该访问器 / 两者皆无)——⛔ 你的命令必须尊重它们,不得把任一 gap 读成「没有引用」。
Ruling 6 · 反空转 + Standing
git checkout HEAD --接绝对路径(⛔ 绝不裸--),trap在EXIT INT TERM,以哈希相等与空git diff HEAD证明,⛔ 绝不用退出码。git grep只读被跟踪文件 ⇒ 新建未 add 的模块返回干净的零(死扫描)。用工作树扫描或先 add。content/docs/releases/**;⛔ worktree 优先;⛔ 绝不git stash;refs/pm/prN,⛔ 不用FETCH_HEAD(共享状态,会被并发 fetch 覆盖)。packages/client/src/index.ts与packages/cli/src/commands/environments/**([PR #12885](https://github.com/objectstack-ai/objectstack/pull/12885));'packages/runtime/src/app-plugin.ts'([#12878](https://github.com/objectstack-ai/objectstack/pull/12878));'packages/types/src/thrown-http-error.ts'([#12946](https://github.com/objectstack-ai/objectstack/pull/12946));'packages/metadata-protocol/src/protocol.ts'([#12936](https://github.com/objectstack-ai/objectstack/pull/12936));'packages/rest/src/rest-server.ts'([#12958](https://github.com/objectstack-ai/objectstack/pull/12958));任何package.json/CHANGELOG.md(#11336)。OS_VERIFY_LOCK_SLOT=issue-8103 bash scripts/pm/os-verify-lock.sh -c "…";⛔ exit 99 / 143 都表示什么都没跑;⛔ 退出码在任何管道之前取。check-half-states.mjsexit 3 = NOT MEASURED,⛔ 不是红。Generated by Claude Code
os-litant commentedon Aug 28, 2026
os-dev-report
{ "issue": 8103, "status": "done", "branch": "claude/issue-8103-sys-secret-orphan-reap", "pr": "https://github.com/objectstack-ai/objectstack/pull/12990", "premise_still_valid": true, "summary": "Shipped the ruled Option B / B-prime: a new opt-in operator command `os secret orphans` in packages/cli, report-only by default, deleting only rows that are attributable to a declared encrypted settings specifier AND named by no family of the COMPLETE cross-producer reference union. It refuses to delete whenever any family could not be enumerated and names that family; the union's three independent gap sources are carried through per family to the operator, to --json and into the export, and are never flattened to a boolean. `unattributable` is never deletable; a legacy inline-crypto sibling is withheld outright rather than merely flagged (the #8063 prefix guard in the opposite direction); re-wrap evidence is reported and never a verdict input. The pre-delete export is mandatory, carries the cipher material so the delete is actually reversible, is written owner-only, refuses to overwrite, and is read back and checked against the plan before any row is removed. The operator-facing text states the measured inverted framing and never claims a cleanup of leaked credentials. Two decisions the ruling did not pin are flagged in open_questions rather than buried: what the export contains, and that the legacy-inline class is withheld outright. Constraint 3 re-measured on the day: facts 1 and 3 RE-ESTABLISHED, fact 2 FALSIFIED and sustained -- which is the ruled premise this shape is built on, not a refutation of it, so it was not treated as a stop.", "reachability_facts_remeasured_on_origin_main_b1a987e4a": { "fact_1_handle_minted_per_encrypt_call": "RE-ESTABLISHED (green). packages/services/service-settings/src/local-crypto-provider.ts:459 mints `id: 'sec_' + randomBytes(16).toString('hex')` INSIDE encrypt(). The id carries no producer marker -- prefix plus 32 hex chars of entropy and nothing else -- so the handle can never discriminate its producer. Same file :489-497: rotateKey returns `id: handle.id` with `version + 1`, so a re-wrap keeps the handle stable and produces no orphan; that is why version/rotated_at are reported-only in the planner.", "fact_2_sys_setting_value_enc_sole_holder": "FALSIFIED (red), sustained on today's tree -- and EXPECTED, per the re-routing comment and the PM claim. Three producers with three holder columns: SettingsService (a bare handle in sys_setting.value_enc), the engine secret-field channel (a `secret:` ref on a business row -- packages/objectql/src/secret-fields.ts:47 SECRET_REF_PREFIX), the datasource credential binder (a `sys_secret:` ref at external.credentialsRef -- packages/services/service-datasource/src/datasource-secret-binder.ts:21). sys_secret declares nine fields (id, created_at, rotated_at, namespace, key, kms_key_id, alg, version, ciphertext) and NO producer column; the field scan returned 9, non-zero, which is its own positive control. This falsification is the premise the complete union repairs, so it was not read as a stop condition.", "fact_3_audit_records_digests_not_handles": "RE-ESTABLISHED (green). The only values reaching an audit payload field in settings-service.ts are `digest` (from crypto.digest(plain) = 'sha256:' + sha256, local-crypto-provider.ts:500-501), `null`, or that digest wrapped in an encrypted marker -- lines 1708 (valueDigest), 1732 (oldHash: null), 1733 (newHash). No handle is passed to any audit sink. Positive controls on the SAME file so the zero is not a dead query: `sec_` appears 2 times and `value_enc` 25 times. Consequence, and the reason the export is mandatory rather than belt-and-braces: an erroneous delete can never be NAMED afterwards, let alone recovered." }, "tests": "All results taken at ba99af32e, the head the PR carries; gate union derived AFTER the last commit via `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (it reads the merge base itself -- 6 paths, 43 families matched, plus the convention-triggered kinds). Exit codes captured before any pipe (redirect-then-capture throughout). TYPECHECK: `pnpm --filter @objectstack/cli typecheck` EXIT=0; NOT a phantom green -- `tsc --noEmit --listFiles` names all three new files including the test file, with a positive control (secret-reference-union appears twice, the same way). NEW PINS: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2` over the two new files -- 'Test Files 2 passed (2) / Tests 25 passed (25)'. REPRODUCTION (the card's own criterion, driving real code rather than reading source): the shipped classifier returns verdict 'orphaned' for the LIVE engine-owned handle while the business row's column still names it, and the same row under the complete union is 'referenced' with holder 'object-field: smtp.password#rec_1' and absent from `deletable`; the anti-vacuity 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. All three handles are minted by the real producers (real ObjectQL insert path, real LocalCryptoProvider, real datasource binder). PACKAGE SOURCE-SCANNING PINS (they read every production module, so they see the new command): lazy-driver-import, invocation, ADR-0048 app split, doctor deprecation hints, plus the two new files -- 'Test Files 6 passed (6) / Tests 61 passed (61)'. ESLINT: `pnpm lint` (`eslint . --no-inline-config`) over the WHOLE repo, EXIT=0 in 98s -- not narrowed, so no narrowing evidence is owed for it. GATES, all EXIT=0, each quoting its own verdict line: nul-bytes ('OK (scanned 7193 text file(s) ... no raw ASCII control bytes)'), engine-double-contract, where-matcher ('312 matcher(s) ... none new'), objectql-double-limit ('none new'), dispatcher-error-vocabulary ('22 unregistered code-stamping site(s) found; 22 classified' -- mine is not among them, the refusal reuses the standard catalog's PRECONDITION_REQUIRED), cross-package-test-inputs ('OK: 23 package(s) read outside themselves, all declared'), query-options-erasure, type-check-coverage, type-check-debt --re-measure ('31 ledger entr(ies) re-measured ... none above its recorded number'), i18n ('OK (9 package(s) -- all bundles in sync)'), i18n-coverage ('OK (12 config(s), 602 baselined untranslated string(s), none new)'), empty-changeset ('1 declaring changeset(s) added'), objectui-changeset, changeset-no-major, adr-0087-registration, cli-command-ids ('288 command-id literal(s) ... all resolve'), test-source-alias, type-source-resolution, published-files, page-declaration-shape, slot-lookup, comment-mask-adoption, plugin-teardown-shape, 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). NOT MEASURED, recorded rather than reported as passes: check:i18n first returned 'PREREQUISITE NOT MET -- the workspace CLI is not built' and check:i18n-coverage 'COULD NOT MEASURE -- 1 of 12 config(s) failed to lint' (unbuilt example deps in a fresh worktree). Neither is a red; both were re-run GREEN after building the closure the way lint.yml does, and only the green readings are claimed. ABLATION -- two runs on the deletion surface, each isolated to its own guard. Both: mutation proven ON DISK before any verdict was read (anchor count 1 to 0, injected count 0 to 1, blob hash changed), restore via `git checkout HEAD --` against an ABSOLUTE path under `trap ... EXIT INT TERM`, restoration proven BY STATE and never by exit code (on-disk blob hash == HEAD blob hash 75fa8fbfd7c5423f8ebfc94666f590f147bf410d, `git diff HEAD` zero lines); an empty hash was coded as FAILURE, not as 'nothing to compare'. REBUILD: none needed and none claimed -- the test imports the module by an intra-package RELATIVE specifier, so vitest resolves it to source, and the positive control for that is that `packages/cli/dist` did not exist at all during either run (the script prints the check; the dependency closure was built, @objectstack/cli itself was not, and the brand-new module still ran). Leg 1, remove the union-incompleteness refusal: 4 of 25 red and exactly the incompleteness class -- all three gap-source pins (host did not declare / engine lacks the accessor / a family's read threw) plus the export's empty-when-incomplete pin; the other 21, including every other guard and the reproduction, stayed green. Predicted direction was 'red on exactly that class' and 3 pins; observed 4, the fourth being the export pin, which is the same guard one step downstream. Leg 2, remove the `unattributable` guard: exactly 1 red, the pin that names it. HARNESS DEFECT FOUND AND FIXED IN MY OWN TEST, recorded because it is the shape this card is about: the first version of the plan helper carried a default parameter `= []`, which swallowed an explicitly passed `undefined` -- so the run meant to exercise the 'nobody answered' gap silently built a COMPLETE union and the guard was never measured. It surfaced as 2 red on the first run; the parameter is now required and documented as such. DECLARED NARROWING: the FULL @objectstack/cli vitest suite was not run to completion locally. It is a long integration suite that boots real stacks per test; two in-round blocking waits of 570s each did not reach its summary (it was progressing, not hung -- schema-migrate.teardown.integration), so I stopped it by its own PID and its descendants only (never pkill -f, and the shared verify lock was confirmed free afterwards) and left it to CI, which runs it regardless. What the greens do NOT cover: any CLI test outside the six files named. One process-note against my own run: the first attempt used `pnpm --filter ... test -- --maxWorkers=2`, which is the documented trap -- everything after the bare double-dash is dropped by vitest, so it ran unbounded; the re-run used `exec vitest run --maxWorkers=2`. CONTROL-BYTE INCIDENT, reported because it happened while writing the rule about it: the first write of the planner materialised a RAW NUL into the (namespace, key) composite-key separator, exactly where the doc comment says to spell the escape. Caught by scanning before commit (grep read the file as binary), replaced with the escape spelling, verified 1 control byte before / 0 after with the escape literal present once, and check:nul-bytes is green over the tree.", "open_questions": [ { "question": "The ruling makes the pre-delete export mandatory but does not say what it CONTAINS. I shipped it carrying the cipher material (the full sys_secret row: id, namespace, key, kms_key_id, alg, version, ciphertext, timestamps). Flagging rather than burying it, because it moves cipher material out of the database onto an operator's filesystem, which is a security decision the ruling did not pin.", "options": [ "Full row including ciphertext (SHIPPED): the export is a real restore point, so an erroneous delete is reversible. Cost: a file holding a partial backup of the secrets table. Mitigated by mode 0600, refuse-to-overwrite, a warning field inside the document naming it as cipher material, and a restore hint.", "Metadata only (id, namespace, key, timestamps, the decision and its holder evidence): nothing leaves the database, but the export only NAMES what was destroyed. The row is then permanently unrecoverable, and for family 2 and 3 shapes the plaintext may be unknown to anyone." ], "recommendation": "Ship as built (full row). Real business need: the export exists because fact 3 says the audit cannot name a deleted row -- an export that names it and cannot restore it makes the mistake describable and still permanent, which is not what a mandatory export was bought for. Long-term soundness: this is the platform's first destructive operator command, and 'a reversible destructive op' is the right precedent for the standard channel the vehicle analysis wanted to build. Making AI-written code hard to get wrong -- decisive here: the failure this whole card guards against is a confident, silent, irreversible delete; the export is the only thing that removes the word irreversible, and a metadata-only export leaves it in place while looking like a safeguard. Startup scope discipline: same file either way, no new mechanism. If the maintainer judges the cipher-material-on-disk tradeoff the other way, the change is one function (buildPreDeleteExport) plus its pin -- say so and I will narrow it." }, { "question": "A sys_secret row whose (namespace, key) currently resolves through a LEGACY INLINE sys_setting value is withheld outright, not merely flagged. That is stricter than the shipped report, which flags it legacyInlineSibling and tells the operator to inspect. Consequence: this class can never be reaped by this command until the setting is migrated off the inline path.", "options": [ "Withhold outright (SHIPPED): the class is never deleted, and the reason names the remedy (migrate that setting off the inline path first).", "Offer an explicit override flag so an operator who has inspected can include the class." ], "recommendation": "Withhold outright, no override, for this release. An inline value names no handle, so the row's absence from the union is EXPLAINED by the legacy value rather than being evidence of retirement -- that is the card's own opposite-direction constraint, and a deleting command cannot discharge it by asking the operator to look. Under-deleting is the safe direction and the class is small; adding an override later is additive, whereas deleting a row that a legacy value implicitly depended on is reversible only through the export. Reported because it is a deliberate tightening of shipped behaviour, not because I doubt it." } ], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
os-litant commentedon Aug 28, 2026
os-dev-report
{ "issue": 8103, "status": "done", "branch": "claude/issue-8103-sys-secret-orphan-reap", "pr": "https://github.com/objectstack-ai/objectstack/pull/12990", "premise_still_valid": true, "summary": "Narrow amendment to the existing PR #12990, on the same branch (ba99af32e -> ab927b160, two new commits, no new PR, still draft, title untouched). One production line changed: the pre-delete export write in packages/cli/src/commands/secret/orphans.ts is now an EXCLUSIVE CREATE -- { encoding: 'utf8', mode: 0o600, flag: 'wx' } -- so a path that filled up between the early existsSync refusal (top of run(), before boot/scan/confirmation) and the write itself throws EEXIST into the already-existing export_failed refusal instead of truncating a file whose owner and permissions it would then have inherited along with the cipher material. The existsSync pre-check, mode: 0o600, sys-secret-orphan-sweep.ts and every verdict path are untouched; no new branch and no new error code. Verified there is exactly ONE call site for this write in the file (grep of writeFileSync/readFileSync/existsSync in orphans.ts: lines 5, 180, 348, 353, 451, 456 -- 348 is the only export write) so no other caller is affected, and no existing test broke. Two new guards added to orphans.guards.test.ts plus the changeset amended in place (no new changeset file).", "tests": "TYPECHECK: `pnpm --filter @objectstack/cli typecheck` (tsc --noEmit) exit 0, captured with `> log 2>&1; EXIT=$?` before any pipe; coverage PROVEN, not assumed -- `tsc --noEmit --listFiles | grep commands/secret/orphans` returns 2 lines naming both edited files. UNIT: `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)' (9 pre-existing + 2 new); os-verify-lock VERDICT command-exit 0. The new subject test drives the real `--delete --yes --json` run through oclif with only two seams faked (bootSchemaStack, collectSecretReferenceUnion); planSysSecretOrphanSweep, buildPreDeleteExport, collectEncryptedSpecifierRefs and isSecretHandle all run for real. The squatter file is created INSIDE the mocked boot, i.e. inside the real window. POSITIVE CONTROL in the same file over a free path asserts a row IS removed and the export IS written 0600, so the zero-deletion assertion is not dead; subject and control share no substring in name or in marker text ('SQUATTER-PAYLOAD not-an-export-document' vs the export JSON). ABLATION (one leg, direction predicted RED and observed RED): removed `, flag: 'wx'` and nothing else. Mutation PROVEN ON DISK before any verdict was read -- anchor `flag: 'wx'` 1 -> 0, replacement `mode: 0o600 }` 0 -> 1, blob 57283a482bbea82ab03568ac34f37a4f0900ed9c -> 5882dea26423dc3afc8be48d6490d11fe7874e89. Ablated: 'Test Files 1 failed (1) / Tests 1 failed | 10 passed (11)', failing on the HARM not the envelope: \"expected [ 'sec_orphan_1' ] to deeply equal []\" -- the ablated run truncated the squatted file and then deleted the row. Restore: `git checkout HEAD -- \"$REPO_ROOT/<rel>\"` (absolute path) under `trap ... EXIT INT TERM`, proven by STATE not exit code -- hash back to 57283a48... equal to `git rev-parse HEAD:<rel>`, and `git diff HEAD -- <path>` zero lines. Restored: 'Test Files 1 passed (1) / Tests 11 passed (11)'. The positive control stayed green in both legs. NO REBUILD LEG needed or claimed: the test reaches the command by an intra-package relative specifier ('./orphans.js'), which vitest resolves to source -- no dist/ on the resolution path. GATES: union re-derived AFTER the final commit with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` at ab927b160, then run, each read from its own printed verdict line: check:nul-bytes (OK, 7193 text files, no raw ASCII control bytes) + a manual `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the three edited files (no hit); 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 entries, 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. `pnpm lint` (eslint . --no-inline-config, WHOLE REPO, not narrowed) exit 0 in 1m39s. NOT MEASURED / recovered: check:i18n-coverage first returned 'COULD NOT MEASURE' (unbuilt connector-mcp then connector-openapi in this partial worktree -- not a red) and check:type-check-debt refused outright on an unbuilt closure; both were re-run GREEN only after building the closure (`turbo run build --filter='./packages/*' --filter='./packages/*/*'`), and the first readings are recorded as not-measured rather than reported as passes. DECLARED NARROWING: 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) is matched only via content/docs/deployment/cli.mdx, which this amendment does not touch -- green in the previous round at ba99af32e, and CI runs it regardless. The full @objectstack/cli suite was not run (unchanged from the previous round's declared narrowing). CI status at report time: in_progress -- report is delivered at draft-PR time by contract.", "open_questions": [], "out_of_scope_findings": [] }Notes outside the machine-read payload:
curl https://api.github.com/...returns "GitHub access is not enabled for this session"), so the PR-body append went through the MCPupdate_pull_requestpath, which replaces the whole body. The pre-existing body was transcribed verbatim, the amendment section inserted before the trailing signature footer, and the result read back and compared: every original paragraph, table and footer is intact and only the new section was added. Noskip-changesetlabel was needed — this PR carries.changeset/secret-orphans-command.md, amended in place.Generated by Claude Code
Generated by Claude Code
os-litant commentedon Aug 28, 2026
Erratum on the report comment above (⛔ not a second report — the
os-dev-reportpayload above is the one and only report for this round, and it still parses as valid JSON).GitHub's body sanitizer stripped three angle-bracket placeholders out of the
testsstring after it was stored. What was written, and what the sanitizer left behind:git checkout HEAD -- "$REPO_ROOT/‹rel›"git checkout HEAD -- "$REPO_ROOT/"git rev-parse HEAD:‹rel›git rev-parse HEAD:git diff HEAD -- ‹path›git diff HEAD --In all three, the elided token is the same single path:
packages/cli/src/commands/secret/orphans.ts. Nothing else in the report was altered — the JSON keys, the gate list, every measured number and both ablation blob hashes are intact, and theos-dev-reportmarker survived on its own first line as plain text.Recording it rather than re-posting, because a second comment carrying the marker would read as a second report to a scanner. The lesson is the standing one, and it cost a strip here: an angle-bracket fragment must be spelled out in words, and the comment needs the same read-back check the PR body gets — I checked the body for
<and did not check this comment.Generated by Claude Code
Generated by Claude Code
os-litant commentedon Aug 28, 2026
os-dev-report
Patch round on the same claim (PR #12990, CI red on
Test Core (1/6)). Headab927b160→e9347402b.{ "issue": 8103, "status": "done", "branch": "claude/issue-8103-sys-secret-orphan-reap", "pr": "https://github.com/objectstack-ai/objectstack/pull/12990", "premise_still_valid": true, "summary": "Patch round for the CI red, same branch, no new PR, still draft. Four lines added to packages/cli/test/json-stdout-purity.e2e.test.ts: the FAMILY entry 'secret orphans': [] plus a three-line comment saying why the bare (report-only) form is the one driven. NO command change was needed, and that is a measurement rather than an assumption -- see 'tests'. The red was the file's own designed tripwire: it DISCOVERS the family from the source tree (calls bootSchemaStack AND declares a json Flags.boolean) and reconciles it against FAMILY, so os secret orphans joined the discovered set the moment it landed. Report-only is its default and writes nothing, so the fixture gains a member without gaining a destructive run. Checked that no sibling suite enumerates the same family: json-stdout-purity.e2e.test.ts is the only file carrying the list (migrate-exit-code.e2e.test.ts only mentions it in prose).", "tests": "REPRODUCED FIRST, not blind-pushed. (a) Standalone re-run of the file's own discovery logic printed 12 ids including 'secret orphans' against a FAMILY of 11. (b) The real suite before the edit: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/json-stdout-purity.e2e.test.ts` -> 'Test Files 1 failed (1) / Tests 1 failed | 33 passed (34)', Duration 118.49s, failing on exactly the CI assertion at line 204 with the same diff (+ \\\"secret orphans\\\"). AFTER the edit, same command: 'Test Files 1 passed (1) / Tests 37 passed (37)', Duration 128.38s -- 37 = 1 reconciliation + 12 members x 3 assertions, so all three purity assertions really ran for the new member (34 = 1 + 11x3 before), and the +10s wall is the extra spawn. MEASURED STDOUT SHAPE of `os secret orphans --json`, driven by hand on a hand-built copy of the same UNCOMPILED fixture (objectstack.config.ts only, no os compile, OS_DATABASE_URL=file:...): exit 0; stdout is 2836 bytes on exactly ONE line; a bare JSON.parse of the WHOLE stdout succeeds and yields an object with keys mode,plan (mode=report, and the plan carries the expected datasource-family gap because no --no-declared-datasources was passed -- report mode reports it, it does not refuse). Leak counters on stdout: 0 kernel-logger records matching the pretty ISO-timestamp DEBUG/INFO/WARN pattern, 0 occurrences of the StandaloneStack marker. Diagnostics MOVED not destroyed: all three BOOT_DIAGNOSTICS ('[StandaloneStack] no compiled artifact', 'Bootstrap complete', 'Graceful shutdown complete') present on stderr, 11438 bytes of them. So the command already satisfied the contract and the assertion was not adjusted to fit it. GATES re-derived AFTER the final commit at e9347402b (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`) and diffed against the previous round's derivation: exactly ONE family is new, `pnpm check:cli-test-child-env`, pulled in by touching packages/cli/test/** -- it is GREEN (36 spawner sources among 100, 42 spawn calls all declaring their child's env, shrink-only baseline unmoved; my edit adds no spawn). Also green, each read from its own printed verdict line: check:nul-bytes (7193 text files, no raw ASCII control bytes), check:cross-package-test-inputs, check:test-source-alias, 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 entries, 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:dispatcher-error-vocabulary, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-half-states, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-comment-mask-adoption, check-plugin-teardown-shape, check-ci-filter-parity. `pnpm lint` (eslint . --no-inline-config, WHOLE REPO, not narrowed) exit 0 in 1m37s. NOT MEASURED, stated as such: `pnpm --filter @objectstack/cli typecheck` exits 0 but says NOTHING about the file edited this round -- packages/cli/tsconfig.json includes only 'src', and `tsc --noEmit --listFiles | grep -c packages/cli/test/` returns 0 (and 0 for json-stdout-purity specifically). The gate that watches that condition, check:type-check-coverage, is green, and packages/cli carries no test-typecheck-debt.json; the e2e run itself is the live check for this file. DECLARED NARROWING, unchanged from the previous round: the docs family is matched only via content/docs/deployment/cli.mdx, untouched this round; and the FULL @objectstack/cli suite (about 13 min per that package's own measured figures) was again not run locally -- but the one file CI flagged was run to completion, twice, which is what this round owed.", "open_questions": [], "out_of_scope_findings": [] }Two things worth a PM eye, neither acted on:
storage orphansdid not touch it), so this is pre-existing prose drift, not something my change introduced — and the discovery mechanism is precisely what makes the rot harmless. I did not edit it: it is not this card's defect class, and rewriting a historical narrative about the original ten is not a mechanical fix. Say the word and it is a one-line follow-up.curl https://api.github.com/...answers "GitHub access is not enabled for this session"), and the dispatch contract forbids the MCPlist_issues/search_issuespath for the mandatory search-before-filing step. Reporting it here rather than filing blind.Generated by Claude Code
Generated by Claude Code
fix(platform-objects): sys_secret field help stops asserting the sett…