Repository navigation
Description
Filed while implementing #10240 (predicate-delete tombstoning). Deliberately kept out of that PR — see "Why not there" below.
Blocked-by: #10240
The gap
#10240 repairs the tombstone write so that a multi: true delete of sys_attachment join rows marks the orphaned sys_file status='deleted' + deleted_at=now. That repair is forward-only: it changes what happens on the next predicate delete, and touches no row already written.
Every attachments-scope file orphaned by a predicate delete before the fix is still sitting at status='committed' with deleted_at NULL and zero sys_attachment join rows. That backlog is not merely late — it is permanent, for the reason #10240 documents: sys_file's declared lifecycle nominates a sweep candidate only via ttl { field: 'deleted_at' } or retention { maxAge: '7d', onlyWhen: { status: 'pending' } } (system-file.object.ts), and an untombstoned orphan matches neither. The platform sweep never nominates it, the reap guard (createSysFileReapGuard) is never asked about it, and the storage bytes are never reclaimed.
The same backlog exists for the update verb from before #10171 landed, for the identical reason — the two cards fixed the same terminal state through two different verbs.
What a fix would need to decide
Not designed here, because the shape is a real decision and not a mechanical one:
- Where it runs. A
osCLI subcommand an operator invokes deliberately, versus a one-shot migration, versus a bounded self-healing pass inside the existing platform sweep. ADR-0057 §3.3 says detection and scheduling stay inside the single platform sweep and forbids a bespoke sweeper — that constraint should probably decide this, but it was written about ongoing detection, not backfill. - What it may do on its own. Writing a tombstone starts a 30d grace window and ends in an irreversible byte delete. A pass that only reports candidates is strictly safer than one that tombstones them; whether the safe version is useful enough is the question.
- How it separates a real orphan from a hook bypass. A file with zero join rows may still be owned through the
ref_*ownership columns (field-file lineage, ADR-0104/ADR-0104 D3 wave 2 — file-as-reference:独占所有权模型 + 到开启回收的分步序列(PR-5b 门禁改由 #3617 承载) #3459 PR-5b), which the reap guard already re-verifies. A backfill has to ask the same question, not a weaker one. - Whether the scope filter is enough. Only
scope === 'attachments'files are reachable through the join-row count at all — the other scopes have their own seam (releaseOwnershipinfile-reference-lifecycle.ts).
Why not there
#10240's PR carries the behaviour repair only. Backfilling existing data is a different blast radius (it writes tombstones that become byte deletes 30 days later, against rows nobody has looked at), it needs the design decision above rather than a mechanical edit, and folding it in would put an irreversible data operation into a bug-fix PR reviewed for a hook-context change.
Related: #5180 asks the broader platform question for the whole sys_record_share / sys_attachment / sys_comment family of polymorphic weak-reference tables; this card is the narrow sys_file byte-reclamation half.
Backlinks: #10240 (the forward-only fix), #10171 (the update verb's twin), #2755 (where the tombstone flow landed), #3459 / #4797 (the reap guard's ownership and gate re-verification).
Activity
os-warren commentedon Aug 22, 2026
State update, not a dispatch: the
Blocked-by: #10240on this card has cleared — #10240 merged earlier today. Nothing else about this card's position changed.It is still not dispatchable, and I am not queueing it. Two reasons, both from the card's own body:
1. The four questions under "What a fix would need to decide" are decisions, not implementation choices. Question 1 is the sharpest, and the card is honest about why it does not answer itself:
An ADR that plausibly governs but was written about a different case is exactly the shape a seat must not resolve on its own. Dispatching this would be dispatching a ruling.
2. The blast radius is irreversible. A tombstone starts a 30-day grace window and ends in a byte delete, against rows nobody has looked at. Question 2 — whether a report-only pass is useful enough — is the one that decides whether this can be built safely at all, and it is unanswered.
Question 3 is also load-bearing and easy to under-build: a file with zero join rows may still be owned through the
ref_*ownership columns (ADR-0104 / #3459), whichcreateSysFileReapGuardalready re-verifies. A backfill must ask the same question, not a weaker one — a weaker one deletes live bytes.ttl { field: 'deleted_at' }nor theretention { onlyWhen: { status: 'pending' } }clause. The population is now fixed and finite. That makes a report-only pass strictly more attractive than it was when this was filed: it can size the problem exactly, and sizing it is probably what decides questions 1 and 2.Adjacent and worth reading together rather than separately — the same lifecycle declaration produces both: #10246, where a live attachment pointing at a grace-window tombstone 404s for up to 30 days and then silently starts working, because the reap guard only revives files the sweep nominates and the sweep does not nominate inside the window. That card is also parked for a ruling. One
ttltimer currently serves both a retention posture and a download posture, and both cards are downstream of that.Left unlabelled beyond
bug/domain:services; grading is triage's.Generated by Claude Code
huangyiirene commentedon Aug 22, 2026
Unlock scan + re-grade (triage seat, 2026-08-22 round): the body's
Blocked-by: #10240is discharged — #10240 closed completed 2026-08-21, its fix PR #10951 MERGED. Release double-check passed: this is the card's only and most recent stated condition, and no newer merged PR sits on this card.Re-graded into the decision inbox rather than the queue (
needs-user-decision+domain:services;bugstands): the card's own question 2 is a destructive-action fork — a backfill that writes tombstones starts a 30-day clock ending in irreversible byte deletion, against rows nobody has looked at — and that is the manual floor regardless of how good the engineering answers to questions 1/3/4 are.[facets-block]
一句话问题:对修复前已搁浅的
sys_file孤儿,先跑只读清点报告,还是直接授权墓碑化回填(30 天后不可逆删字节)?推荐 A(置信度高):四轴同向——量级读数是 B 是否值得的前提,而 A 恰好生产这个读数。本分析看不见什么:真实部署的孤儿数量与字节量级(零读数);update верб(#10171 前)与 delete 动词(#10240 前)两类存量各占多少。裁决格式:回「A」/「B」;A ⇒ 晋级
pm:queue派 services 席做 report-only 清点,清点读数回贴本卡后第二步再裁。Generated by Claude Code
huangyiirene commentedon Aug 22, 2026
Maintainer ruling recorded — A: report-only inventory first; the tombstoning decision returns with the numbers
Provenance: maintainer, 2026-08-22, live session with the triage seat (「同意所有」, item 18 = A).
State:
needs-user-decision→pm:queue(services lane), Bug stands. Scope: a read-only reconciliation pass (operator-invokedossubcommand or equivalent — dev picks the harness within ADR-0057 §3.3's no-bespoke-sweeper constraint, report-only is compatible) that enumerates candidate orphans across both legacy classes (pre-#10171 update-verb and pre-#10240 delete-verb), applying the sameref_*ownership re-verification the reap guard uses — never a weaker question — and reports count + byte magnitude back onto this card. ⛔ Writes nothing, tombstones nothing. Step 2 (authorizing the destructive backfill) is re-adjudicated here when the numbers land.Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Retriage (triage seat, 2026-08-23 round, session
session_01U8eA4js4F6L7yvUksoCaWE): standing grade maintained —pm:queuestands,pm:retriagecleared with this comment.The objection's premise — "the four questions under 'What a fix would need to decide' are unruled decisions" — is contradicted by the thread: this card already went through the decision inbox and was ruled by the maintainer on 2026-08-22 (comment of 13:52Z, batch ruling item 18 = A). The ruling scoped the dispatchable card to exactly the safe half the objection asks for:
ref_*ownership re-verification as the reap guard, never a weaker question.The card body still reads pre-ruling — that is the known shape "决策评论不带 session ID,裁后回队与从未裁过同形". Takers: the dispatch scope is the 2026-08-22 13:52Z ruling comment, not the body's open-question section. The dispatching seat should quote that ruling verbatim in the dispatch brief's ruling section.
Generated by Claude Code
claude commentedon Aug 23, 2026
os-dev-report
{ "issue": 10950, "status": "done", "branch": "claude/issue-10950-sys-file-orphan-inventory", "pr": "https://github.com/objectstack-ai/objectstack/pull/11304", "premise_still_valid": true, "summary": "Step 1 of the 2026-08-22 ruling, report-only. Ships inventoryStrandedFileOrphans() + formatStrandedOrphanInventory() from @objectstack/service-storage and the operator surface `os storage orphans` (--json/--max-candidates/--samples; no --apply, no write path). PREMISE CONFIRMED on current main, mechanically rather than by reading: LifecycleService.reap() builds exactly two nomination filters for sys_file (deleted_at older than cutoff; created_at older than cutoff AND status pending), and a row with deleted_at NULL + status committed satisfies neither -- new tests drive the REAL LifecycleService against the REAL SystemFile.lifecycle and observe that a guard is handed the tombstoned and pending rows and NEVER the stranded orphan, at 400 days and at 3650. OWNERSHIP QUESTION IS THE GUARD'S OWN: createSysFileReapGuard's hold test was extracted as findFileHolder() and both the guard and the inventory now call it, so 'same question, never weaker' is a property of the code; ablating its ref_* limb turns the guard's OWN pre-existing pin red, which is the proof they share one predicate. A file with zero join rows that is ref_*-owned is excluded and has a dedicated test. BOTH LEGACY CLASSES are covered by one predicate because the two verbs leak into an identical terminal state (both simply never ran tombstoneOrphanedFiles) and the data carries no verb provenance -- so the count is a COMPLETE enumeration of both, and the module states that the per-verb split is impossible from the data rather than implying one. Population confirmed fixed and finite: both verbs now reach the orphan rule through the single shared helper, so nothing is still minting members. SCOPE is in the payload on every count (attachments only; other scopes are the field-reference seam, reconciled by verifyFileReferences, which skips attachments-scope -- the two passes partition rather than overlap). Byte magnitude self-declares as a lower bound when a stranded row carries no usable size. DISCLOSURE: commit eeea16939f's message body contains the text 'fixes (#10171' -- the parenthesis almost certainly defeats GitHub's closing parser, and both #10171 and #10240 are already closed (completed) so any parse is a no-op; force-push to amend is forbidden, and the PR BODY is clean (verified against the live API with a deliberately loose pattern: zero hits).", "tests": "All on final commit 1cdb38c40c, clean tree; exit codes captured before any pipe; each gate quoted by its OWN printed verdict line. SUITE -- `pnpm --filter @objectstack/service-storage test`: 'Test Files 25 passed (25) / Tests 426 passed (426)' (25 test files on disk = 25 run, so the new file executed). Focused verbose run of the new file + attachment-lifecycle.test.ts: 'Test Files 2 passed (2) / Tests 68 passed (68)', including all 11 pre-existing createSysFileReapGuard pins -- the extraction is behaviour-preserving. TYPECHECK -- `pnpm --filter @objectstack/cli typecheck` VERDICT command-exit 0, script name echoed ('tsc --noEmit') so it really ran. service-storage declares NO typecheck script, so `--filter ... typecheck` there would exit 0 having run nothing and is NOT claimed; instead it was measured the ledger's own way (`tsc --noEmit -p packages/services/service-storage/tsconfig.json`) = exactly 51 errors, equal to the frozen DEBT entry, with zero errors from the new files. ABLATION -- findFileHolder weakened to the join-row-only question (the exact weaker question the ruling forbids). Mutation proven ON DISK by anchor count, not by an editor exit code: anchor 1 before / 0 after, marker 0 before / 1 after, with the mutated line printed. No rebuild leg is involved or needed and this is stated rather than skipped: the tests import the module by RELATIVE path, so vitest resolves this package's own source -- no `exports` hop, no dist. Direction observed = RED, as predicted: 5 failures -- 'EXCLUDES a file with ZERO join rows that is ref_*-owned', 'writes nothing', 'the guard and the inventory reach the same verdict', 'findFileHolder names the surface', and critically 'vetoes and un-tombstones a field file that regained an owner (ownership re-verify)' which is the reap guard's OWN pre-existing pin. Restore ran under a trap on EXIT/INT/TERM and was proven absent (marker 0, anchor 1); `git status` clean afterwards. GATE UNION re-derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, NO hand-supplied paths, on the final commit with a clean tree (7 committed paths; the re-derivation added check:entry-guard, check:parse-guard and check:pnpm-filter-targets over the first pass because .changeset/ and scripts/ entered the diff -- all three were then run). ALL GREEN, verdict lines: check-nul-bytes 'OK (scanned 6337 text file(s) ... no raw ASCII control bytes)'; check:published-files 'OK -- 69 publishable package(s)'; check:slot-lookup 'ratchet holds: 107 unswept site(s) in 25 file(s), none new'; check-test-source-alias 'OK -- 72 packages with tests scanned'; check-type-source-resolution 'OK -- 77 packages'; check:cross-package-test-inputs 'OK: 14 package(s) read outside themselves, all declared'; check-engine-double-contract 'OK -- 385 pinned, 133 in the DEBT ledger, 2 exempt' (the new pinned double was recorded via --write: +1 row, 'No seam losses'); where-matcher 'conformance holds: 283 matcher(s) ... none new'; check-i18n-bundles 'OK (9 package(s) -- all bundles in sync)'; query-options-erasure 'ratchet holds: 67 unswept non-test site(s), none new'; check-ci-filter-parity 'OK: all 88 declared cross-package glob(s)'; check:plugin-teardown-shape '63 Plugin implementation(s) ... baseline fully burned down'; check-changeset-no-major 'introduces no major bump'; check-empty-changeset 'No empty-frontmatter changeset (1 declaring changeset(s) added)'; check-adr-0087-registration 'adds no declared-breaking changeset'; docs affected-docs exit 0 (its unreachable-row census is a standing repo fact, not this diff); check:entry-guard '138 scripts/ file(s) -- every entry guard goes through invoked-as.mjs'; check:parse-guard '137 scripts/ file(s)'; check:pnpm-filter-targets '120/148 --filter occurrence(s) ... resolve'; check-type-check-coverage 'OK -- 65/78 workspace packages type-checked'; check:type-check-debt (--re-measure) 'OK -- 33 ledger entr(ies) re-measured in 785.6s, 1897 raw tsc error(s) total, NONE ABOVE ITS RECORDED NUMBER'. That last one first refused with PREREQUISITE NOT MET ('1 workspace dependenc(ies) ... have no built type entry point on disk -- @objectstack/service-knowledge'); that package was built and the gate re-run to a real verdict rather than being recorded as a pass. Every heavy command went through scripts/pm/os-verify-lock.sh; self-reporting one long hold: the debt re-measure held the lock 787s (13m07s). MEASURED NUMBERS -- `os storage orphans --json` run end-to-end against a real booted stack: scope 'attachments', filesScanned 0, alreadyOnTtlPath 0, heldByAttachment 0, heldByFieldOwner 0, stranded 0, strandedBytes 0, bytesAreLowerBound false, truncated false. See open_questions: that is a confirmed-empty deployment, not the production figure the ruling asks for.", "open_questions": [ { "question": "The ruling's deliverable is 'count + byte magnitude reported back onto this card', and step 2 is re-adjudicated when those numbers land. The harness now exists and was run end-to-end, but the only deployment reachable from this container is one freshly provisioned during the run -- its boot log states the mechanism ('new datastore attested at creation: adr-0104-file-references ... no legacy data can exist here'), so its reading of 0 stranded / 0 bytes is a confirmed-empty population and is NOT the production figure. Who runs `os storage orphans --json` against real tenant data, and against which deployment(s), so step 2 has the number it was deferred for?", "options": [ "A -- Maintainer/operator runs `os storage orphans --json` against each production deployment once this merges, and pastes the payloads onto #10950; step 2 is adjudicated on the real totals. Read-only, no downtime, no flag, safe to run on a live database (a concurrent writer can only make the counts drift, never be corrupted by this pass).", "B -- Treat the deferred step 2 as answerable without production numbers, on the mechanism alone (the leak is permanent and the population is fixed and finite). Cheaper by one round trip, but it authorises an irreversible byte delete with no idea whether the backlog is 3 files or 300GB -- which is precisely the gap triage's four-axis block named ('没有测量说明泄漏量级 ... 痛感未知') and precisely what option A of the ruling was chosen to close.", "C -- Extend this PR to seed a scratch database with synthetic orphans and report that count as the magnitude. Rejected and not done: it would put a fixture-derived number where the ruling asks for a deployment number, and a fabricated magnitude is the one output that could wrongly authorise deleting live bytes." ], "recommendation": "A. It is the only option that produces the artefact the ruling actually deferred step 2 on, and it costs one operator command. The dispatched scope is complete without it -- the harness, its correctness proof and the operator surface all ship here -- but the CARD is not: #10950 must stay open until a real deployment's payload is posted, which is also why the PR says `Part of #10950` and not a closing reference." } ], "out_of_scope_findings": [] }Generated by Claude Code
claude commentedon Aug 23, 2026
os-dev-report
{ "issue": 10950, "status": "done", "branch": "claude/issue-10950-sys-file-orphan-inventory", "pr": "https://github.com/objectstack-ai/objectstack/pull/11304", "premise_still_valid": true, "round": "patch round 1 — REWORK addressed; final commit a4502beaeb", "delta": "Two commits since 1cdb38c40c. (1) 9ea89ad852 — the reported failure. Adding --json to `os storage orphans` made it a member of the bootSchemaStack JSON-stdout-purity family; discoverFamily() found it, FAMILY did not list it. Joined properly, NOT narrowed: `'storage orphans': []` added to FAMILY, which is what describe.each drives, so the member gets the same three assertions as the other nine. discoverFamily() untouched; --json kept. Also refreshed the header's now-stale population count (nine -> ten), since a stale count is the same drift one level up. VERIFIED the member boots to a REAL inventory payload in the uncompiled fixture rather than only exercising its error path — that distinction is the difference between listed and driven, and an error-path-only member would have been the defect the suite name warns about. (2) a4502beaeb — answering the addendum's per-path question honestly forced a narrowing: findFileHolder, hasFieldReferenceOwner, FileHolder and formatBytes were on the public barrel with ZERO consumers outside the package (verified by grepping every `from '@objectstack/service-storage'` and dynamic import in packages/, examples/, apps/ — the only external consumer is my own CLI command, using inventoryStrandedFileOrphans, formatStrandedOrphanInventory and StrandedOrphanInventoryEngine). Unpulled declared surface is implementation-first here, so the barrel now publishes only what something imports. Both callers still share ONE predicate by relative import — the ACCEPTed sharing property is unchanged, and its blast radius is now smaller. Nothing else was touched; every previously ACCEPTed property is as reviewed.", "addendum_1_per_path_justification": { "packages/services/service-storage/src/stranded-orphan-inventory.ts": "REQUIRED — the read-only pass itself. In service-storage because that package owns sys_file, sys_attachment lifecycle, the reap guard, and the sibling read-only pass verify-file-references.ts.", "packages/services/service-storage/src/stranded-orphan-inventory.test.ts": "REQUIRED — its coverage, including the mandated zero-join-row-but-ref_*-owned exclusion case and the LifecycleService premise proof.", "packages/services/service-storage/src/attachment-lifecycle.ts": "REQUIRED by the ruling's 'never a weaker question'. The guard's ownership predicate was inline inside createSysFileReapGuard; extracting it as findFileHolder is what makes 'the same question' a property of the code. Without touching this file the inventory would necessarily carry a SECOND copy of the ownership test — the exact drift #10171/#10240 are about, one seam over.", "packages/services/service-storage/src/index.ts": "REQUIRED — the cross-package barrel; packages/cli is a different package and can only reach the pass through it. Narrowed in a4502beaeb to pulled surface only.", "packages/cli/src/commands/storage/orphans.ts": "REQUIRED — the ruling names an operator-invoked `os` subcommand as the harness, and this is the artefact that will produce the production number the deferred step 2 is waiting on.", "packages/cli/test/json-stdout-purity.e2e.test.ts": "REQUIRED, and FORCED by the gate — adding --json made the command a family member and the suite is red until the member is both listed and driven. This is the additionally-permitted path.", "scripts/engine-double-contract.pinned.json": "REQUIRED, and FORCED by the gate — the new test's pinned engine double must be recorded or check:engine-double-contract stays red. Written by the gate's own --write (+1 row, 'No seam losses'); the ledger was not hand-edited and no baseline was weakened.", ".changeset/sys-file-stranded-orphan-inventory.md": "REQUIRED — user-visible change (a new CLI command and new exported API) in a fixed-version group." }, "addendum_2_writes_nothing": "Confirmed: no writes at all, under any flag. Evidence I relied on, four independent kinds. (a) STRUCTURAL — the pass accepts `Pick<AttachmentLifecycleEngine, 'find'>`; the type has no write member, so a write is a compile error, not a runtime hope. (b) TEXTUAL — the only engine call in the whole module is `engine.find('sys_file', …)` at line 202 (plus the find inside findFileHolder); grepping the module for update/insert/delete/upsert/create/save/deleteMany/updateMany/execute returns NOTHING. (c) BEHAVIOURAL — every test drives the pass through a Proxy engine double that throws on ANY member access other than `find`, so update/insert/delete/updateMany/deleteMany/execute and anything added later all fail identically; that is stronger than stubbing the verbs I happened to think of. (d) SURFACE — the CLI command declares no --apply/--fix/--write flag and has no write path; the only flags are --database-url, --max-candidates, --samples, --json. No tombstone flip, no reaping, no status mutation. On side-effecting reads: reads use `context: { isSystem: true }` and pass no raw-values or mutation context; they trigger no hook, because hooks fire on writes. NO code path can write under any flag — if one could, it would be here in plain words, and there is none.", "addendum_3_ref_star_strength": "A file with zero join rows but a live ref_* owner is EXCLUDED, never reported as stranded. The predicate is literally the reap guard's, because both call one function: `findFileHolder(engine, fileId, row)` asks `engine.find('sys_attachment', { where: { file_id }, limit: 1 })` first and returns 'attachment' if any row comes back; otherwise it returns 'field-owner' when `hasFieldReferenceOwner(row)` holds — `row.ref_object != null && row.ref_id != null && row.ref_id !== ''` — and only `null` when neither surface holds it. The inventory branches on that: 'field-owner' increments heldByFieldOwner and `continue`s, so such a row can never reach the stranded counter or the byte total. What happens to a file whose ONLY owner is a ref_* reference: the join-row query returns empty, the ownership limb fires, the file is counted in heldByFieldOwner and reported separately — it is excluded from `stranded`, excluded from `strandedBytes`, and never appears in `samples`. ref_field is deliberately NOT consulted, preserving the guard's reading that a slot is identified by (object, record) so a missing field name cannot read as 'unowned'. PROVEN BY ABLATION, not by inspection: weakening findFileHolder to the join-row-only question turns five tests red, and one of them is createSysFileReapGuard's OWN pre-existing pin ('vetoes and un-tombstones a field file that regained an owner'). The inventory cannot drift weaker than the guard without breaking the guard's own tests.", "addendum_4_both_classes": "BOTH are in scope, and the count is a complete enumeration of both rather than one of them. The two verbs converge on ONE terminal state — scope='attachments', status='committed', deleted_at NULL, zero sys_attachment join rows — because in both cases the miss was simply that tombstoneOrphanedFiles never ran (pre-#10240 the delete verb's per-row stash died with the row; pre-#10171 the update verb had no re-point leg at all). So the single predicate enumerates both classes. What is NOT possible is splitting the total BY VERB: nothing in the data records which verb stranded a given row — there is no provenance column — so the classes are indistinguishable by construction, not by omission here. The module states that in place rather than implying a split it cannot make, and a test ('counts BOTH legacy classes — they share one terminal state, so one predicate enumerates them') pins it with one row per class plus a live control. The population is also confirmed fixed and finite: both verbs now reach the orphan rule through the single shared helper, so nothing is still minting members.", "measured_numbers_and_their_scope": "COUNT 0 stranded. BYTE MAGNITUDE 0 bytes (bytesAreLowerBound false — no stranded row carried an unrecorded size, trivially, since there were none). SCOPE OF THE FIGURE, stated plainly because a count without it is unusable: (1) POPULATION SCOPE — attachments-scope sys_file rows only, which is what a join-row question can speak about at all; other scopes are the field-reference seam, reconciled by verifyFileReferences, which skips attachments-scope, so the two passes partition rather than overlap. The payload carries `scope: 'attachments'` so this cannot be read as 'all orphans'. (2) DEPLOYMENT SCOPE — exactly one deployment: a SQLite database created BY THE RUN ITSELF in this container moments earlier (plus the same command driven by the purity e2e against its own ephemeral fixture). NO production, staging or shared deployment was read, because none exists in this container — I searched for any objectstack database on the filesystem and the only hits were the files my own runs created. (3) WHAT THAT MAKES THE ZERO — a CONFIRMED-EMPTY population, not an unmeasured one: the boot log states the mechanism ('new datastore attested at creation: adr-0104-file-references … no legacy data can exist here'), so a deployment with no attachment history has no pre-fix orphans by construction. It exercises the whole operator path end-to-end and is NOT evidence about any deployment that has such a history. ⛔ THIS IS NOT THE PRODUCTION FIGURE THE RULING ASKED FOR, and it must not be pasted onto the card as one. The harness that produces that figure is what ships here; see open_questions.", "tests": "Final commit a4502beaeb, clean tree, exit codes captured before any pipe, each gate quoted by its own printed verdict line. THE REPORTED FAILURE, failing-then-passing: FAILING = CI Test Core (2/6) on head 1cdb38c40c, job 97176253583 — 'expected [ Array(10) ] to deeply equal [ Array(9) ] + \"storage orphans\"' at json-stdout-purity.e2e.test.ts:201. PASSING = local run of that exact file on the fix: 'Test Files 1 passed (1) / Tests 31 passed (31)' (28 before: 9 members x 3 + 1 discovery; 31 now: 10 x 3 + 1), with the discovery assertion green and all three new-member assertions green — 'os storage orphans --json > emits ONE JSON document on stdout — a bare JSON.parse, no extraction', '> leaves no kernel-logger record on stdout', '> still shows the operator every boot diagnostic — on stderr'. DRIVEN, NOT MERELY LISTED: reproduced the fixture by hand and captured the member's stdout — a full inventory payload (scope attachments, filesScanned 0 … duration 1072), exit 0, with 4 boot-diagnostic lines on stderr; so the member exercises its real path, not its error path. REGRESSION SET: `pnpm --filter @objectstack/service-storage test` 'Test Files 25 passed (25) / Tests 426 passed (426)'; `pnpm --filter @objectstack/cli typecheck` VERDICT command-exit 0 with the script name echoed ('tsc --noEmit'). GATE UNION re-derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, no hand-supplied paths, on a4502beaeb with a clean tree: 8 paths, 19 path-matched families — the new test file pulled check:type-check-coverage and check:type-check-debt from convention-triggered into PATH-MATCHED (exactly the widening you warned about); the later narrowing commit added no further families. ALL GREEN: engine-double-contract 'OK — 385 pinned, 133 in the DEBT ledger, 2 exempt'; where-matcher 'conformance holds: 283 matcher(s), none new'; query-options-erasure 'ratchet holds, none new'; cross-package-test-inputs 'OK: 14 package(s) read outside themselves, all declared'; entry-guard '138 scripts/ file(s)'; parse-guard '137 scripts/ file(s)'; pnpm-filter-targets '120/148 --filter occurrence(s) resolve'; published-files 'OK — 69 publishable package(s)'; slot-lookup 'ratchet holds: 107 unswept site(s), none new'; test-source-alias 'OK — 72 packages'; type-source-resolution 'OK — 77 packages'; i18n 'OK (9 package(s) — all bundles in sync)' with 'services/service-storage in sync (4 bundle(s))'; changeset-gate-self-tests, objectui-changeset, ci-filter-parity 'OK: all 88 declared cross-package glob(s)', plugin-teardown-shape 'baseline fully burned down', changeset-no-major 'no major bump', empty-changeset '1 declaring changeset(s) added', adr-0087 'no declared-breaking changeset', docs affected-docs exit 0; type-check-coverage 'OK — 65/78 workspace packages type-checked'; type-check-debt (--re-measure) 'OK — 33 ledger entr(ies) re-measured in 410.9s, 1897 raw tsc error(s) total, NONE ABOVE ITS RECORDED NUMBER'. TWO NON-VERDICTS I REFUSED TO RECORD AS PASSES, both re-run to real verdicts: check:i18n first printed 'PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked' (the recreated worktree had cli's dependencies built but not cli itself) — built it and re-ran to the OK above; check:type-check-debt first returned lock exit 99 'never acquired · waited 540s' which is 'did not run', not a result — retried after the holder finished and got the OK above.", "summary": "Patch round complete on final commit a4502beaeb, pushed. The purity-family failure was mine and is now green, joined properly rather than narrowed. The addendum's per-path question additionally forced a real narrowing of the public barrel. All four addendum questions are answered in their own fields above. PR #11304 stays DRAFT with `Part of #10950` intact — the card must remain open for the production-numbers step. Everything previously ACCEPTed is untouched.", "open_questions": [ { "question": "Unchanged from the first report and still the one thing standing between this card and its deferred step 2: the ruling's deliverable is a count and byte magnitude from real tenant data, and no such deployment exists in this container. Who runs `os storage orphans --json` against production, and against which deployment(s)?", "options": [ "A -- Maintainer/operator runs it against each production deployment once this merges and pastes the payloads onto #10950; step 2 is adjudicated on the real totals. Read-only, no downtime, no flag, safe on a live database (a concurrent writer can make the counts drift, never corrupt them).", "B -- Adjudicate step 2 on the mechanism alone, with no magnitude. Cheaper by one round trip, but it authorises an irreversible byte delete without knowing whether the backlog is 3 files or 300GB — the exact gap triage's four-axis block named, and what option A of the ruling was chosen to close.", "C -- Seed a scratch database with synthetic orphans and report that as the magnitude. Rejected and not done: it would put a fixture-derived number where the ruling asks for a deployment number, and a fabricated magnitude is the one output that could wrongly authorise deleting live bytes." ], "recommendation": "A. The dispatched scope is complete without it — harness, correctness proof and operator surface all ship here — but the CARD is not, which is exactly why the PR says `Part of #10950` and not a closing reference." } ], "out_of_scope_findings": [] }Generated by Claude Code
feat(storage): report-only inventory for stranded sys_file orphans (o…
claude commentedon Aug 23, 2026
Maintainer ruling recorded (atomic four-part write)
Ruled 2026-08-23, live PM chat, verbatim: 「10950 不考虑存量,其他接受你的建议」 — Ruled: 不考虑存量 — the legacy-orphan reconciliation is NOT wanted (the one explicit per-card ruling in the quote). Closing not-planned. What remains in place: the forward fix (#10240) stops new stranding; the read-only inventory command
os storage orphans(PR #11304) exists for operators if the question ever returns. Reopening is free if the stranded population starts mattering (e.g. storage cost or a user-visible symptom).Generated by Claude Code
fix(cli,cloud-connection,types): CLI help, warnings and refusals stat…