Skip to content

Already-stranded sys_file orphans need a one-off reconciliation — the predicate-delete tombstone fix is forward-only #10950

Closed as not planned

Description

@os-warren

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:

  1. Where it runs. A os CLI 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.
  2. 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.
  3. 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.
  4. 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 (releaseOwnership in file-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

  1. added
    bugSomething isn't working
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 21, 2026
  2. os-warren commented on Aug 22, 2026

    @os-warren
    CollaboratorAuthor

    State update, not a dispatch: the Blocked-by: #10240 on 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:

    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.

    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), which createSysFileReapGuard already re-verifies. A backfill must ask the same question, not a weaker one — a weaker one deletes live bytes.

    ⚠️ One correction to how this card reads now that #10240 has landed. The card says the same backlog exists "for the update verb from before #10171 landed". Both forward-only fixes are now in, so the backlog is closed at both ends and still frozen in the middle — no new members are being added by either verb, and no existing member can ever be nominated, because an untombstoned orphan matches neither the ttl { field: 'deleted_at' } nor the retention { 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 ttl timer 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

  3. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 22, 2026
  4. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Unlock scan + re-grade (triage seat, 2026-08-22 round): the body's Blocked-by: #10240 is 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; bug stands): 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]

    • 实际业务需求:存量孤儿是真实的存储泄漏(预算在漏),且永远不会自愈——声明的生命周期两条提名路径都够不着未打墓碑的孤儿;但没有测量说明泄漏量级(几 MB 还是几百 GB),痛感未知。
    • 项目长远合理性:ADR-0057 §3.3 禁止另立 sweeper——报告型 pass 或一次性迁移都能与该约束共存;把回填塞进平台 sweep 需要先回答该条款是否覆盖 backfill 场景。
    • 防 AI 写代码犯错:report-only 先行是结构上最难出错的形状——AI 实现一个只读清点(附 ref_* 所有权复核,与 reap guard 同一问题)几乎无法造成损害;直接墓碑化则把一个不可逆操作交给首次实现。
    • 创业阶段不扩散需求:分两步(先报告、看了数字再裁是否墓碑化)只多花一轮,换掉全部不可逆风险。

    一句话问题:对修复前已搁浅的 sys_file 孤儿,先跑只读清点报告,还是直接授权墓碑化回填(30 天后不可逆删字节)?

    选项 客户可感知代价
    A 先 report-only 清点(CLI 子命令,输出候选清单+量级),看读数再裁第二步 存储继续多占一段时间;两步走
    B 直接授权墓碑化回填(带 ref_* 所有权复核 + 30 天宽限) 一步到位;首次实现即背不可逆操作,行数与量级未知

    推荐 A(置信度高):四轴同向——量级读数是 B 是否值得的前提,而 A 恰好生产这个读数。本分析看不见什么:真实部署的孤儿数量与字节量级(零读数);update верб(#10171 前)与 delete 动词(#10240 前)两类存量各占多少。裁决格式:回「A」/「B」;A ⇒ 晋级 pm:queue 派 services 席做 report-only 清点,清点读数回贴本卡后第二步再裁。


    Generated by Claude Code

  5. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 22, 2026
  6. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    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-invoked os subcommand 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 same ref_* 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

  7. removed
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Aug 23, 2026
  8. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Retriage (triage seat, 2026-08-23 round, session session_01U8eA4js4F6L7yvUksoCaWE): standing grade maintained — pm:queue stands, pm:retriage cleared 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:

    • Decision 2 (report-only vs tombstoning): ruled — report-only; ⛔ writes nothing, tombstones nothing.
    • Decision 1 (where it runs): delegated within ADR-0057 §3.3's constraint (report-only is compatible; dev picks the harness).
    • Decision 3 (orphan vs hook bypass): pinned — same ref_* ownership re-verification as the reap guard, never a weaker question.
    • The destructive step 2 (tombstoning backfill) is explicitly deferred: it returns to the decision inbox on this card when the count/byte numbers land. No irreversible action is inside the dispatched scope.

    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

  9. claude commented on Aug 23, 2026

    @claude
    Contributor

    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

  10. claude commented on Aug 23, 2026

    @claude
    Contributor

    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

  11. claude commented on Aug 23, 2026

    @claude
    Contributor

    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

  12. removed
    bugSomething isn't working
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions