Skip to content

Design: storage location and Asset storage backends (#311) - #313

Merged
Fooftilly merged 49 commits into
masterfrom
claude/kind-hypatia-psyqdp
Sep 30, 2026
Merged

Fooftilly merged 49 commits into
masterfrom
claude/kind-hypatia-psyqdp

Conversation

@Fooftilly

@Fooftilly Fooftilly commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Design gate for #311, under the #310 target architecture and coordinated with #60. Documentation only: no runtime, schema, API/OpenAPI, frontend, Storybook or dependency changes. It does not touch the frontend migration (#230/#290/#302/#303).

What's added

  • docs/storage-architecture.md: the storage contract, written from an audit of current master (6ed603f, schema 17). The audit covers StorageConfig/paths, bind_storage rebinding, work_pdf_replace, work_deletion/pending_pdf_cleanup, backup_restore, processing, the text and research indexes, and portraits.
  • docs/work-identity-model.md §9 gets a one-paragraph pointer to the new doc.

Key decisions (§0 of the doc)

  • A physical path is never identity. Domain records hold an Asset ID (AS-…, from [Roadmap] Work Identity, Editions, Versions & Deduplication #60). The Asset holds a storage key (namespace, name). [Roadmap] Work Identity, Editions, Versions & Deduplication #60's existing storage_locator/source_locator basenames already are key names, so no migration is needed.
  • One asset-objects namespace holds every managed Asset slot, whatever its media type. In layout 1 it maps to the existing pdfs/, and no directories are renamed.
  • The selected root lives in a bootstrap config file, outside every root, never in DB rows.
  • Precedence: --storage-root > PRKS_STORAGE > config file > default. The first source that is set wins. An invalid chosen root is an error, never a fallthrough. Testing mode never reads the config file or the platform default.
  • Platform defaults: XDG, macOS Application Support, or %LOCALAPPDATA% for packaged builds. A source checkout keeps data/. Legacy unmarked roots are discovered before any new default is created. No library is ever moved automatically.
  • Root marker prks-root.json (storage_root_id, layout_version, state ∈ active|fenced|staging|retired). The ID identifies the library's file store and moves with it. At most one root per ID is ever bindable.
  • Relocation: fence the source → copy → verify → commit → activate the destination (lease first) → retire the source → rebuild derived data → retain the old copy.
    • Where the commit happens: a config-file root commits by an atomic bootstrap replace. A CLI/env root commits in the destination marker (relocation.phase: committed), because that bootstrap file can be ephemeral in containers.
    • Crash safety: startup recovery picks one journal by root source. Revert, finalize and abort have explicit preconditions and orderings, and every crash point has one resolution.
    • Docker: moves use the same relocate/finalize flow; a raw volume copy is not a supported move.
  • PostgreSQL: choosing or moving the data root never moves the DB. Backup stays one archive per library.
  • A minimal StorageBackend (put_new, replace, open_read, stat, delete, verify, iter_keys), with mandatory semantics separated from local-only ones. The S3 replace keeps its must-exist precondition via If-Match.
  • Multi-process: one server process per root, enforced by a non-expiring OS advisory lock (not a heartbeat lease) until the key locks become cross-process.

Phases (§13)

  • A: foundation, backend-only, no behavior change. It keeps /data/for_processing.
  • B: route managed files through the backend, and make processing_files.abs_path derived.
  • C: [Roadmap] Work Identity, Editions, Versions & Deduplication #60 Slice D coordination.
  • D: selectable root, diagnostics and Settings. The UI comes after the frontend migration.
  • E: relocation.
  • F: optional object storage.

Only C waits for #60. The UI and OpenAPI parts wait for the frontend migration (§14).

Validation

  • scripts/check_invariants.py passes.
  • tests.test_agent_guidance_current passes.
  • All in-document anchors resolve.
  • Every table has consistent columns.
  • CI is green except github-advanced-security. That check's Copilot agent fails before analysis with a rejected-model error, which is unrelated to this docs-only diff.

Refs #311, #310, #60. Does not close #311.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

Summary by CodeRabbit

  • Documentation
    • Added a proposed storage architecture covering storage roots, physical storage, root selection, relocation and recovery, backend guarantees, path safety, backups, and operational constraints.
    • Documented how storage roots relate to databases and outlined implementation phases, deferred questions, and conformance checks.
    • Clarified that the work-identity model covers Asset identity, while physical storage details are documented separately.

Design gate for #311 under #310: data-root terminology, storage keys vs
Asset identity (#60), data classification and layout mapping, bootstrap
configuration precedence, platform defaults, root marker and validation,
copy-verify-commit relocation protocol with crash recovery, PostgreSQL
separation, a minimal StorageBackend interface, path-sensitive system
audit, multi-process semantics and implementation phases.

Documentation only; no runtime, schema, API or frontend changes. Adds a
one-paragraph pointer from the #60 design's storage section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE
greptile-apps[bot]

This comment was marked as off-topic.

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

💤 Files selected but had no reviewable changes (1)
  • docs/storage-architecture.md
⚙️ Run configuration

Configuration used: Repository: Fooftilly/PRKS/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 90a5271e-d1c9-442f-976e-172b9ce0ae2e

📥 Commits

Reviewing files that changed from the base of the PR and between cea5eee and fa3f005.

📒 Files selected for processing (1)
  • docs/storage-architecture.md

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a proposed storage architecture design and links it to the Asset identity model. The documents describe storage ownership, root configuration and relocation, backend operations, PostgreSQL placement, and planned implementation phases. No runtime, schema, API, UI, or dependency changes are included.

Changes

Storage architecture design

Layer / File(s) Summary
Storage ownership and data model
docs/storage-architecture.md, docs/work-identity-model.md
The design documents the boundary between Asset identity and physical storage, classifies stored data, and links the Asset identity section to the storage architecture document.
Root configuration and validation
docs/storage-architecture.md
The design specifies root configuration precedence, platform defaults, root markers, adoption, and validation rules.
Relocation and database placement
docs/storage-architecture.md
The design describes relocation and its recovery behavior. It distinguishes file-root movement from PostgreSQL database placement.
Backend contract and system operations
docs/storage-architecture.md
The design defines storage backend operations and describes their use by storage-dependent systems and multi-process coordination.
Implementation phases and open design items
docs/storage-architecture.md
The document lists implementation phases, deferred work, rejected alternatives, open design questions, and conformance statements.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Other

Merge Risk: 🔵 Low · up to c24d7

This documentation-only change has no runtime impact. The bindability guarantee should be limited to the period after the P2 fence, so implementers do not misread the relocation protocol. A small wording fix is enough.

Architecture Summary

Architecture risk: 🔵 Low · up to c24d7

The change affects 1 system.

Changed systems: docs

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — docs (service) was modified; 2 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in docs/work-identity-model.md: Adds a reference to storage-architecture.md for physical Asset storage, storage keys, the backend interface, and relocation; identifies this section as covering the Asset side of that boundary.
  • observed — Modified behavior in docs/storage-architecture.md: Introduces the document’s proposed-design status, ownership split between #60 and #311, contents, and decisions covering storage-key identity, namespaces, local backend, root configuration and precedence, platform defaults, root identity, relocation commits, PostgreSQL placement, backend guarantees, and multi-process coordination.
  • observed — Modified behavior in docs/storage-architecture.md: Audits current storage configuration, paths, rebinding, backup classification, safety primitives, path leakage, and configuration debt; distinguishes preserved semantics and SQLite-era implementation details from proposed changes.
  • observed — Modified behavior in docs/storage-architecture.md: Defines storage terminology and the boundary with #60: Assets retain identity while storage keys locate bytes; assigns attribute ownership and specifies how #60 should consume keys, serve Asset content, route byte operations, handle cleanup, and represent web snapshots.
🚥 Pre-merge checks | ✅ 8
✅ Passed checks (8 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the documented design gate for directly linked issue #311. docs/storage-architecture.md defines a configurable local root, platform defaults, root precedence and validation, canoni…
Out of Scope Changes check ✅ Passed The changes are limited to docs/storage-architecture.md and a related pointer in docs/work-identity-model.md. Both changes support #311 and clarify its boundary with #60. The PR adds no unrelated …
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Prks Engineering Invariants ✅ Passed The authoritative PR diff changes only docs/storage-architecture.md and adds a pointer in docs/work-identity-model.md; it changes no runtime, schema, API, or entry-point code. The new document exp…
Ui Design Contract ✅ Passed The authoritative PR diff changes only docs/storage-architecture.md and adds a documentation pointer in docs/work-identity-model.md. It contains no frontend, UI, Storybook, or user-visible interac…
Offline And Sync Coherence ✅ Passed PASS. The authoritative PR diff changes only docs/storage-architecture.md and a pointer in docs/work-identity-model.md; it does not change frontend offline code, the service worker, persistence co…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: a design for storage location and Asset storage backends. It matches the documentation-only changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Design storage location and Asset storage backend contracts

📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Define how Asset storage keys map to physical objects without making paths part of identity.
• Specify root selection, validation, relocation, recovery, and future PostgreSQL separation.
• Document a minimal backend contract and link it from the Asset identity design.
Diagram

graph TD
  A["Asset record"] --> K["Storage key"] --> B["Storage backend"] --> L["Local filesystem"] --> R[("Data root")]
  C["Bootstrap config"] --> R
  B --> O["Object storage"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Two-pass incremental relocation
  • ➕ Shortens the period during which library mutations are blocked for large moves.
  • ➖ Requires reliable change tracking and more complex reconciliation and crash recovery.

Recommendation: Keep the proposed copy-and-verify relocation as the first implementation: its single commit point and intact old root make recovery easier to reason about. Consider incremental copying only if move duration becomes a practical problem.

Files changed (2) +1053 / -0

Documentation (2) +1053 / -0
storage-architecture.mdPropose the storage location and backend architecture +1048/-0

Propose the storage location and backend architecture

• Audits current path-sensitive behavior and defines storage keys, data classification, root configuration and validation, and a minimal backend contract. Specifies relocation and crash recovery, PostgreSQL boundaries, multi-process requirements, and phased implementation without changing runtime behavior.

docs/storage-architecture.md

work-identity-model.mdLink Asset lifecycle design to the storage contract +5/-0

Link Asset lifecycle design to the storage contract

• Adds a pointer from the Asset storage section to the new design and clarifies that Asset identity remains owned by this document.

docs/work-identity-model.md

@github-actions github-actions Bot deleted a comment from qodo-code-review Bot Sep 29, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 039f7d92a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (FIRST_PASS)

Design-only PR for #311 under #310; audit claims checked against master @ 6ed603f (schema 17). Overall the ownership split with #60, layout/namespace choices, relocation protocol, and StorageBackend surface look coherent.

Non-blocking

  1. Phase A acceptance criteria conflict — §13 Phase A both removes the /data/for_processing special case and requires identical derived paths for every existing deployment. Unset-PRKS_STORAGE production today prefers /data/for_processing (see §1.1 / paths.derive_processing_dir); after removal it becomes <repo>/data/for_processing. Compose/PRKS_STORAGE=/data is fine; bare source-checkout production is not path-identical. Carve that change out of the "identical" claim, or keep the special case until a later explicit migration note.

No blocking issues. CodeRabbit left alone.

Comment thread docs/storage-architecture.md Outdated

| Phase | Content | Schema? | API/OpenAPI? | UI? | Waits for |
| --- | --- | --- | --- | --- | --- |
| **A. Contract and foundation** | The root resolver with §5.2 precedence and `(root, source)`; `--storage-root`; normalization (V1); the platform-default table (§6) behind the distribution flag, so a source checkout still gets `data/`; the bootstrap config reader and atomic writer (no UI writes yet); the root marker (adopt on first bind, refuse wrong, retired or foreign roots); startup validation (§7.2, startup subset); the single-process lease; `StorageKey`, `ObjectInfo`, `StorageBackend`, `LocalFilesystemStorage` over the existing primitives, with contract tests shared by any future backend; `backup_storage_inventory` classification of the marker and config (operational, not backed up); remove the `/data/for_processing` special case. **Behavior must be identical for every existing deployment**: the same root is chosen and the same paths are derived; the only new file is the marker. | no | no | no | nothing (backend-only; may start before the migration ends if it touches no frontend file) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: This Phase A cell both says remove the /data/for_processing special case and Behavior must be identical for every existing deployment: … the same paths are derived.

Today, production with unset PRKS_STORAGE sets processing_dir to /data/for_processing (paths.PROCESSING_PROD_PREFERRED / §1.1), not <repo>/data/for_processing. Removing the special case changes that path for bare source-checkout runs even though Compose (PRKS_STORAGE=/data) stays the same.

Please either (a) explicitly exclude the processing-inbox path from the identical-behavior guarantee, or (b) keep the special case in Phase A and retire it in a later phase with a named migration note.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c9a1488, which is being pushed now, with option (b). Phase A keeps the special case, and the identical-paths guarantee covers the processing inbox. Phase D retires it with a discovery rule and a named release-note action (PRKS_FOR_PROCESSING_DIR=/data/for_processing). The rule is in §1.2 and referenced from Phase D.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED in c9a1488: Phase A keeps /data/for_processing unchanged; Phase D retires it with the §1.2 discovery rule and release note. Identical-paths now covers the inbox.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


  • 🪄 Fix CodeRabbit comments on this PR
🤖 Prompt to fix review comments
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
Review comments at @docs/storage-architecture.md:
- Around line 695-698: Update §8.7’s offline relocation sequence so P8 runs only
after the administrator changes the environment or mount to select the new root.
Keep the old root active until that selector change is complete, then finalize
P8.
- Line 540: Narrow the containment exclusions in the V11 row so an initialized
root may contain its own .prks-maintenance directory and may be inside the
bootstrap config directory’s parent layout used by the macOS and Windows
defaults. Continue requiring the bootstrap config file itself to remain outside
the data root, and retain the other V11 exclusions.
- Line 534: Update the V5 row in the storage-architecture table so
`O_CREAT|O_EXCL` does not split the Markdown table into extra cells; escape the
pipe or describe the flags without using a pipe, preserving the existing column
alignment.
- Around line 419-420: Update PRKS storage-root selection to discover and adopt
an existing legacy <repo>/data root before requiring a marker or switching to
the platform default when no CLI or PRKS_STORAGE override is set.

After applying the fix, consider running `coderabbit review --agent` for local
review. Visit https://docs.coderabbit.ai/cli?utm_source=ghpr

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: Fooftilly/PRKS/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: c7fe123f-e3c8-4738-bec1-55c5c79117b5

📥 Commits

Reviewing files that changed from the base of the PR and between 6ed603f and 039f7d9.

📒 Files selected for processing (2)
  • docs/storage-architecture.md
  • docs/work-identity-model.md

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One additional relocation correctness issue beyond the existing reviewer threads.

Comment thread docs/storage-architecture.md Outdated
| --- | --- | --- |
| **P0 Preflight** | Run the full §7.2 checklist on the destination, including V10 space, computed from the actual canonical inventory, and V14 names. Refuse if any relocation or restore journal is already open. | none |
| **P1 Intent** | Mint a `relocation_id`. Create the destination with marker `state: staging, relocation: {id, role: destination}`. Write the config file with `storage.relocation = {id, phase: "copying", from, to}`, with `local_root` still the old root. The config write is atomic. | config (`copying`), destination marker (`staging`) |
| **P2 Quiesce** | Enter the backup scope in the `concurrency` gate: mutations and backups blocked, reads allowed. That is today's backup semantics, and it bounds relocation to the same "reads keep working" user experience. Stop background writers: the cleanup retry, thumbnail and index writers, and processing scans. | none (process-local; §12 for multi-process) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — keep the destination non-bindable until the relocation commit. P4 changes the destination marker from staging to active while local_root still points at the old root and before this process has rebound/acquired the destination as authoritative. A second PRKS instance configured directly to to can therefore bind the copied destination during the P4→P5 window and mutate it, creating a fork before the relocation has even committed. Keep the destination in a non-bindable relocation state through P4. After P5 commits, activation can happen as part of P6/startup recovery (e.g. committed config + matching staging destination is authorized to transition to active). Normal startup must reject a destination marker carrying an uncommitted relocation role even if its generic state would otherwise be active.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c9a1488, which is being pushed now.

  • P4 no longer activates the destination: it stays staging and non-bindable. Activation moved to P6, after the P5 commit, and it is authorized by the committed config for the same relocation_id.
  • A new binding rule in §7.1 says ordinary startup never binds a staging root or one carrying an uncommitted relocation role. Only committed-config recovery or storage finalize can activate it.
  • Recovery for copying/verified now only discards destinations that are still staging.

Generated by Claude Code

- Destination stays `staging` (non-bindable) until the commit; startup
  recovery or `storage finalize` activates it.
- Source root is retired durably inside the mutation scope before
  mutations resume; failure keeps PRKS in maintenance mode.
- `storage_root_id` identifies the file store and is carried by
  relocation, so the PostgreSQL pairing survives a move; at most one
  root per ID is bindable.
- Offline admin flow: relocate -> change selector -> finalize -> start;
  old root stays active until the selector changes.
- Default resolution discovers unmarked legacy roots before creating a
  new default.
- V11 exempts the root's own maintenance dir and allows the root inside
  the config directory (macOS/Windows defaults); only the config file
  must stay outside.
- Phase A keeps the /data/for_processing special case; Phase D retires
  it with a discovery rule and release note.
- Fix V5 table cell split by an unescaped pipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

Copy link
Copy Markdown
Owner Author

The failing github-advanced-security check is not caused by this PR. The Copilot autofind agent aborted with CAPIError: 400 The requested model is not supported before analyzing anything; the configured agent model isn't accepted by the Copilot API. This PR changes only Markdown. There's no fix I can port from here; it needs the workflow's model setting updated. Every other check (CodeQL, Ruff, Pyright, Vue, E2E gate, SonarCloud, CodeFactor) is green.


Generated by Claude Code

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Delta since 039f7d92: relocation stays staging until commit, source retirement before mutations resume, carried storage_root_id, offline finalize flow, legacy default discovery, V11/V5 cleanups, and Phase A keeps /data/for_processing.

Prior

  1. Phase A identical-paths vs remove /data/for_processing — FIXED (kept in A; retire in D with discovery + release note).

Non-blocking

  1. Source stays bindable after P5 until P7 — S8 / §7.1 claim at most one bindable root per storage_root_id, but P1–P6 leave the source as ordinary active with relocation: null. After commit (and after a crash before P7), another process can still bind the old path via CLI/PRKS_STORAGE and fork the library. Close the gap when the move commits (mark source non-bindable, or make bind refuse a root that is from in a committed relocation journal).

CodeRabbit / Codex / Qodo / Greptile left alone.

library's files" wherever they live, so the database pairing (§9) stays valid
without being touched. Two directories may hold the same ID only as one
`active` root plus `staging` or `retired` copies. The binding rule above
guarantees that **at most one root per ID is ever bindable**. "Open another

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: S8 / this paragraph say at most one root per storage_root_id is ever bindable, but the protocol does not enforce that until P7.

  • P1–P6 never touch the source marker, so it remains state: active with relocation: null.
  • The binding rule only refuses non-active / uncommitted relocation roles, so that source still binds.
  • After P5 (config → new root), and especially after a crash before P7, a second process pointed at the old path (--storage-root / PRKS_STORAGE) can open the still-active source and fork the library under the same ID.

Happy-path P7-before-release and recovery-in-maintenance-mode protect this process; they do not make the old path unbindable to another installation. Mark the source non-bindable at commit (or teach bind to consult the relocation journal’s from when phase is committed / source_retired).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that gap was real. Fixed in eb0f823, which is being pushed now.

  • P2 now fences the source. After quiescing, and before any copying, the old marker is durably set to state: fenced with relocation: {id, role: source}. Only the marker changes; the old library's data is still never written before commit. If the fence can't be written, the move is refused.
  • Binding rule (§7.1). A fenced source can go back to active only through startup recovery whose config still selects it with phase copying/verified/failed for the same relocation_id, or through an explicit storage abort. Otherwise it ends retired (P7). A second installation pointed at the old path through --storage-root/PRKS_STORAGE has no such proof, so it refuses and names the relocation. That holds both after P5 and after a crash before P7.
  • Recovery and revert. Pre-commit recovery lifts the fence. "Revert move" restores fenced/retired to active.
  • Offline flow (§8.7). storage relocate now leaves both ends unbindable. The administrator then either changes the selector and runs storage finalize, or runs storage abort to reopen the old root. A stopped sequence always resolves with one explicit command.
  • S8/S9 now state it: no root with a given ID is bindable while a move is unresolved.

Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED in eb0f8238 / 3b863f4e: P2 durably fences the source before copy; after P5 no unfence proof exists, so P6 activates the destination with the source already revoked. At most one bindable root per storage_root_id holds across the commit.

The source root stayed an ordinary active root from P1 until P7, so a
second process pointed at the old path could bind it after the commit
(or after a crash before P7) and fork the library. P2 now durably marks
the source `fenced`; only startup recovery with this relocation's config
record, P7 (retire), revert, or the offline `storage abort`/`finalize`
commands may change it. The offline flow now leaves both ends unbindable
until the administrator finalizes or aborts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous blockers are mostly resolved, but I found three remaining design contradictions on the revised head.

Comment thread docs/storage-architecture.md Outdated
| **P3 Copy** | The database: a `sqlite3` backup-API snapshot, never a file copy, written to the destination under a temporary name and then renamed. Each canonical namespace (`asset-objects`, `portraits`, and the inbox if it is under the root): stream every object into a staging name, hash it while copying, fsync the file, rename it to its key name, and fsync the directory. Derived data, logs and maintenance are **not** copied. Links are refused, as in §7.3. | per-object progress in `<dest>/.prks-maintenance/relocation/<id>/manifest.json`: key, size, sha256. Resumable, but a restart may also discard it and begin P3 again. |
| **P4 Verify** | Re-read every destination object, and compare size and SHA-256 against the P3 manifest, which was computed from the source. Run `PRAGMA integrity_check` and the schema-version check on the destination database. Audit the catalogue against the destination (`audit_managed_pdfs`): every referenced key present. Keys missing in the source are reported, not fatal, because availability is observed (#60 §9.2). The destination marker **stays `staging`**, so the destination is not bindable (§7.1). Write config `phase: "verified"`. | config (`verified`) |
| **P5 Commit** | **One atomic config replace:** `local_root = to`, `relocation.phase = "committed"`. This is the only switch. | config (`committed`) |
| **P6 Activate and rebind** | Still under the P2 scope: set the destination marker to `state: active` (keeping `relocation.role: destination` for diagnostics). This is authorized by the committed config for the same `relocation_id`. Then `bind_storage(new config)`; the existing rollback-on-failure applies. | destination marker (`active`) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — P6/P7 still violate the new “at most one bindable root per storage_root_id” invariant. P6 makes the destination active while the source is still active; P7 retires the source afterwards. That creates a real interval with two bindable roots carrying the same ID, and if P7 cannot write the old root the overlap can last indefinitely. The explicit “old copy is gone or disconnected” acknowledgement is also unsafe: a disconnected source can later reappear still marked active, at which point another PRKS instance can bind the stale copy and fork the library. Keep the destination non-bindable while it is mounted/rebound in maintenance mode, retire the source durably first, then transition the destination to active and admit mutations. If the source cannot be durably retired, do not claim the no-fork guarantee or allow a simple acknowledgement to resume normal mutation unless there is some other durable revocation/generation mechanism that makes the stale source refuse future binding.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b863f4, which is being pushed now. It builds on eb0f823, which added a durable source fence at P2.

  • Ordering. The source is durably fenced at P2, before any copying, and the move is refused if that write fails. A fenced root binds only with proof that its move did not commit, and that proof cannot exist after P5. By P6 the source is therefore already revoked. P6 then activates the destination, binds, and releases the scope. Before P6 neither root is bindable; from P6 only the destination is. There is no two-bindable interval.
  • P7 is no longer a safety step. It only relabels fenced → retired for clearer messages and for later cleanup, and it is retried at later starts.
  • The "old copy is gone" acknowledgement is removed. A disconnected source that reappears is still fenced and still refuses to bind, so the fence is the durable revocation mechanism you asked for.
  • Recovery in the committed phase no longer needs maintenance mode. "Revert move" is limited to a failed P6.

Generated by Claude Code

Comment thread docs/storage-architecture.md Outdated
| V8 | **File fsync succeeds.** Directory fsync is best-effort, consistent with `fs_durability`: an unsupported result is recorded and shown, not fatal. | once | ✓ | error for files; diagnostic for directories |
| V9 | **SQLite-era locking.** The database's filesystem supports the byte-range locks SQLite needs. That filesystem is where the main DB lives now, and where the WAL-mode derived indexes always live. In practice: refuse a filesystem type that is known to be network or FUSE-sync when it can be detected with certainty, and otherwise warn (§7.4). | ✓ (warn) | ✓ | error when certain, else warning |
| V10 | **Free space.** Report free bytes. For a choice or relocation, require at least (canonical bytes to copy) × 1.1 + 1 GiB of headroom, and warn below a configurable floor on startup. The existing `_free_bytes` probe is reused. | warn | ✓ | error for relocation; warning otherwise |
| V11 | **Nesting and collision.** The candidate must not equal, contain or be contained by another active PRKS root (walk parents for `prks-root.json`, and do a bounded scan for markers below), the current root (except as the relocation source), the repository checkout (in production), the application install directory, a configured backup location, or the filesystem root or the user's home directory itself. It must not **contain** the bootstrap config file. It may sit inside the config directory (the §6 macOS and Windows default). It must not lie inside another root's `.prks-maintenance/`. The candidate's **own** `.prks-maintenance/` is expected, and it is exempt from every containment test here. | ✓ | ✓ | error |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — V11 rejects the supported source-checkout default. §6 and Phase A explicitly preserve <repo>/data as the source-checkout default, and current non-testing source runs are mode="production". V11 then says a production candidate may not be contained by the repository checkout, so startup validation would reject <repo>/data on the very deployment whose behavior Phase A promises to preserve. Narrow this rule to packaged/installed deployments (or otherwise explicitly exempt the declared source-checkout development default) while still preventing packaged user data from living under the application install tree.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b863f4, which is being pushed now. V11 now refuses a root inside the repository checkout only when the distribution is packaged. For a source checkout, the declared development default <repo>/data is exempt, and any other location inside the checkout is still refused. The application-install-directory rule is unchanged, so packaged user data still can't live under the install tree.


Generated by Claude Code

Comment thread docs/storage-architecture.md Outdated
first probes the **known earlier default locations**, in a fixed order,
before it creates anything:
1. any earlier platform-default location;
2. for a source checkout, `<repo>/data`, and `/data` when it is a directory.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — /data is not a historical source-checkout root default and should not be auto-adopted just because it exists. Current defaulted_storage_root() uses <repo>/data when PRKS_STORAGE is unset; /data is only the preferred processing inbox path in that mode. A source checkout that happens to see /data/prks_data.db or /data/pdfs (for example from another/containerized PRKS deployment) would therefore silently adopt a library that was never this resolver's prior default. Restrict legacy probing to locations that were actually prior root defaults for that distribution/source, unless there is an explicit migration marker or operator choice tying /data to this installation.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b863f4, which is being pushed now. Legacy discovery for a source checkout now probes only <repo>/data, the only prior root default. /data is never probed: today it is only a preferred processing inbox, and a /data library belongs to a container or another deployment unless an operator selects it explicitly (PRKS_STORAGE/--storage-root).


Generated by Claude Code

…obes

- P6 now relies on the durable P2 source fence: the destination becomes
  active only after the source is already unbindable, so no interval has
  two bindable roots. P7 retirement is cosmetic and retried; the unsafe
  "old copy is gone" acknowledgement path is removed.
- V11 exempts the declared source-checkout default <repo>/data and only
  refuses repository-contained roots for packaged distributions.
- Legacy default discovery probes only prior root defaults; /data (a
  processing-inbox preference, not a root default) is never auto-adopted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fencing model fixes the source-side fork window. One success-path marker-state contradiction remains.

| `copying` or `verified` | old | Old root authoritative. If its marker is `fenced` with this `relocation_id`, lift the fence back to `active`, then bind it normally. Mark the relocation `failed` in config. The destination is staging: delete it automatically **only** when its marker carries this `relocation_id`, `state: staging` and `role: destination`, and it contains nothing but PRKS components; otherwise leave it and report it. The user may retry. |
| `committed` | new | New root authoritative. Activate the destination if its marker is still `staging` with the same `relocation_id` (P6), bind it in **maintenance mode**, and perform P7. Mutations are admitted only after P7 completes or the user acknowledges it. |
| `source_retired` | new | Bind normally and redo P8 and P9 idempotently. |
| `retained` | new | Normal operation. Diagnostics show the retained old copy. |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — the successful destination never clears its relocation role, so later startup contradicts §7.1. P6 sets the destination to active while explicitly keeping relocation.role: destination; P7/P8/P9 do not clear that marker field. But §7.1 says ordinary startup binds only an active root that carries no relocation role. After the config reaches source_retired or retained, this row says to bind normally, which the binding rule would refuse. Define a durable point that clears the destination relocation marker once the source is retired (or explicitly make source_retired/retained config proof authorize this active destination and eventually clear it). Also cover crash recovery between source retirement and clearing the destination role so the successful move cannot strand itself on the next restart.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in c946f42, which is being pushed now.

  • P6 now activates the destination in one atomic marker replace: state: active, relocation: null, plus an informational moved_from: {id, peer_hint}. moved_from is documented in §7.1 as diagnostic only; it is not a relocation role and binding ignores it.
  • Activating and clearing the role are the same write, so there is no crash window between them. After it lands, the destination is an ordinary bindable root at every later start, in committed, source_retired or retained.
  • Recovery in the committed phase performs that single write if the destination is still staging. If the destination is already active with no role, the write already landed, and PRKS just binds.
  • storage finalize uses the same write.
  • Source retirement (P7) never touches the destination, so a crash between P6 and P7 leaves an active destination and a fenced source, both consistent.

Generated by Claude Code

P6 kept `relocation.role: destination` on the activated destination,
which the §7.1 binding rule refuses, so a successful move would strand
itself on the next start. Activation now writes `state: active`,
`relocation: null` and an informational `moved_from` in one atomic
marker replace, leaving no crash window; recovery and `storage finalize`
use the same write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two remaining state-machine inconsistencies on the newest head.

Comment thread docs/storage-architecture.md Outdated
### 8.2 The protocol

```
P0 preflight ─► P1 intent ─► P2 quiesce ─► P3 copy ─► P4 verify ─► P5 COMMIT ─► P6 activate+rebind ─► P7 retire source ─► release ─► P8 rebuild ─► P9 retain ─► (later) P10 cleanup

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — the protocol diagram still has the old release boundary. The revised P6 row now says Release the scope; mutations resume at the end of P6 and makes P7 cosmetic/non-blocking, but this diagram and the mutations blocked line still place release after P7. That leaves two conflicting definitions of when writes may resume and what a P7 failure means. Update the diagram/timeline to make P6 the release boundary everywhere, or change the row back; there should be one authoritative sequence for the crash tests to implement.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5aa37fa, which is being pushed now.

  • The diagram now matches the P6 row. Step P2 is labelled "quiesce+fence", P6 is "activate+rebind+RELEASE", and P7 is marked non-blocking. The "mutations blocked" line now ends at P6.
  • One stated boundary. A sentence under the diagram says the release boundary is the end of P6 and nowhere else, and that a P7 failure blocks nothing.
  • Nothing else contradicts it. I searched the doc for other P7 release or scope references and found none. The §12 table already says "nothing else writes during P2–P6".

Generated by Claude Code

The timeline still placed the mutation release after P7. Make the end of
P6 the single release boundary in the diagram and state it once below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Delta since c9a1488c: P2 source fence (eb0f8238); P2-as-revocation, V11 packaged-only checkout rule, and legacy probe without /data (3b863f4e).

Prior

  1. Source stays ordinary active / bindable through P1–P6 — FIXED (durable fenced at P2; after P5 no proof exists to unfence, so P6 activates only one bindable root).

Non-blocking

  1. §8.2 protocol diagram stale vs P6 — the ASCII flow still places release after P7 and draws mutations blocked through P7, but P6 now releases the scope and resumes mutations. Align the diagram (and the mutations bar) with P6.

Already on PR (not re-filed)

Destination keeps relocation.role: destination after P6 while §7.1 ordinary bind requires no relocation role — still open at tip: #313 (comment)

CodeRabbit / Codex left alone.

Comment thread docs/storage-architecture.md Outdated
Comment on lines +664 to +668
P0 preflight ─► P1 intent ─► P2 quiesce ─► P3 copy ─► P4 verify ─► P5 COMMIT ─► P6 activate+rebind ─► P7 retire source ─► release ─► P8 rebuild ─► P9 retain ─► (later) P10 cleanup
old root authoritative ──────────────────────────────────────────┤ new root authoritative ────────────────────────────────────────────►
old canonical data never written; destination stays `staging` ───┤
old marker `fenced` (not bindable by any other process) from P2 ──────────────────────────────────┤ `retired`
mutations blocked (P2) ────────────────────────────────────────────────────────────────────────────────────┤

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: §8.2 diagram is out of date with the P6 text in this tip.

  • Line 664 still sequences P7 retire source ─► release.
  • Line 668 draws mutations blocked through that same point.

P6 now says Release the scope; mutations resume after activate+rebind, and the post-commit guarantee says mutations resume at the end of P6 (P7 is cosmetic). Move release to after P6 and end the mutations bar there so the picture matches the table.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both points are already fixed on the current head. This review appears to have been made against 3b863f4.


Generated by Claude Code

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One remaining P1 concurrency edge in the finalized P6 sequence.

Comment thread docs/storage-architecture.md Outdated
| **P2 Quiesce** | Enter the backup scope in the `concurrency` gate: mutations and backups blocked, reads allowed. That is today's backup semantics, and it bounds relocation to the same "reads keep working" user experience. Stop background writers: the cleanup retry, thumbnail and index writers, and processing scans. Then **fence the source**: durably set the old marker to `state: fenced, relocation: {id, role: source, peer_hint: <dest>}`. From here on no other process can bind the old path (§7.1). If the fence cannot be written, the move is refused before any copying. | source marker (`fenced`); the gate itself is process-local (§12 for multi-process) |
| **P3 Copy** | The database: a `sqlite3` backup-API snapshot, never a file copy, written to the destination under a temporary name and then renamed. Each canonical namespace (`asset-objects`, `portraits`, and the inbox if it is under the root): stream every object into a staging name, hash it while copying, fsync the file, rename it to its key name, and fsync the directory. Derived data, logs and maintenance are **not** copied. Links are refused, as in §7.3. | per-object progress in `<dest>/.prks-maintenance/relocation/<id>/manifest.json`: key, size, sha256. Resumable, but a restart may also discard it and begin P3 again. |
| **P4 Verify** | Re-read every destination object, and compare size and SHA-256 against the P3 manifest, which was computed from the source. Run `PRAGMA integrity_check` and the schema-version check on the destination database. Audit the catalogue against the destination (`audit_managed_pdfs`): every referenced key present. Keys missing in the source are reported, not fatal, because availability is observed (#60 §9.2). The destination marker **stays `staging`**, so the destination is not bindable (§7.1). Write config `phase: "verified"`. | config (`verified`) |
| **P5 Commit** | **One atomic config replace:** `local_root = to`, `relocation.phase = "committed"`. This is the only switch. | config (`committed`) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — acquire/hold the destination process lease before making P6 normally bindable. P6 currently atomically changes the destination to ordinary active/no-role and only then calls bind_storage(new config). Phase A’s single-process protection is a per-root active_process lease, but this row does not say that P6 acquires that lease in the same activation write or before clearing the relocation role. In the gap, another PRKS process pointed at the destination can bind/acquire the lease and mutate it. If this process’s bind_storage() then fails, the documented revert move path is no longer safe because the destination may already contain accepted mutations. Keep the destination non-generically-bindable until the current process has successfully bound and owns the destination lease, or include ownership of that lease in the atomic P6 activation and define how it is released on bind failure. The revert guarantee should be stated in terms of no process having admitted mutations, not only this process.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b8fcf18. P6 is now two ordered sub-steps.

  • P6(a): bind while the destination is still staging. §7.1 now allows that only for a holder of the committed relocation record, meaning this process or startup recovery. P6(a) takes the destination's active_process lease, then calls bind_storage. If either step fails, the lease is released and the destination stays staging.
  • P6(b): only after P6(a) succeeds, one atomic marker replace writes active, relocation: null, moved_from, and the lease this process already holds. The destination becomes generically bindable in the same write that records its owner, so no other process can slip in.
  • Revert is offered only while the destination is still staging. Its safety is stated as no process having admitted a mutation since P2: the source has been fenced throughout, the destination was never generically bindable, and this process never released its scope.
  • Recovery in the committed phase and storage finalize follow the same order. Finalize holds the lease during the write and releases it on exit.

Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED in b8fcf18: P6 is now (a) take lease + bind_storage while destination is still staging, then (b) one atomic activation write that records that lease as owner. Generic bindability and lease ownership land together. A separate new finding covers the §8.3 revert prose that still equates staging with P6(a) failure.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5aa37fa21f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md
…3 replace (#311)

- P6 binds the still-staging destination and takes its active_process lease (P6a) before the single activation write that records the lease (P6b); revert is offered only while the destination is staging.
- P4 runs a stable final rescan of an in-root processing inbox; P10 deletes old inbox files only when an identical copy exists in the destination.
- Raw volume copies are not a supported move; containers use relocate/finalize.
- S3 replace keeps the must-exist precondition via HeadObject + If-Match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Delta since 5aa37fa: P6(a)/(b) lease-before-activation, P4 inbox final pass, P10 identical-inbox rule, Docker relocate/finalize, S3 HeadObject+If-Match replace.

Prior: source-bindable / sticky destination role / §8.2 diagram / Phase A identical-paths remain FIXED. Lease-before-P6 intent is addressed by the new split (see thread). Codex inbox/Docker/S3 threads already FIXED-acked — left alone.

New

  1. Blocking — §8.3 revert equates staging with "P6(a) failed", but successful P6(a) leaves the destination staging until P6(b); the P6(a)-ok / P6(b)-fail window needs explicit unbind / lease-release before lifting the source fence.

Comment thread docs/storage-architecture.md Outdated
back to the old root. Automatic fallback is refused because, once any mutation
commits in the new root, the old root is a stale snapshot from P2 and binding
it would fork the library. While the destination is still `staging`, which
means P6(a) failed and P6(b) never ran, the user may explicitly "revert move".

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — §8.3 revert contradicts the new P6(a)/(b) split.

This sentence says still-staging "means P6(a) failed and P6(b) never ran". That is false under the revised P6 row: P6(a) success intentionally leaves the destination staging; only P6(b) writes active.

So the reachable failure P6(a) ok → P6(b) fails is still staging, and in that window this process may already hold the destination active_process lease and a successful bind_storage binding. The revert steps below only flip config back to from and lift the source fence — they do not require unbind, lease release, or keeping the P2 scope until that cleanup finishes.

Please:

  1. Drop the "means P6(a) failed" equivalence (revert while staging, whether or not P6(a) bound).
  2. Spell the post-P6(a) revert path: release lease → unbind destination → then restore config / unfence source (scope held until that is done).
  3. Keep the claim that revert is unsafe once P6(b) has written active.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in c8d784a.

  1. The false equivalence is gone. Revert is offered whenever the destination is still staging. That covers a failed P6(a), and a successful P6(a) followed by a failed P6(b).

  2. The revert path is spelled out, with the P2 scope held throughout:

    1. Unbind the destination through bind_storage's rollback path.
    2. Release its active_process lease.
    3. Atomically set the config back to from with phase failed.
    4. Lift the source fence.
    5. Only then release the scope.

    For a crash partway through: while the config still says committed, recovery completes the move, because P6 is idempotent on a staging destination. Once the config says failed/from, recovery lifts the fence. A stale lease expires by its heartbeat rule.

  3. Revert is still refused once P6(b) has written active.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED in c8d784a/dd23e1c/8594a56: revert is offered while destination is still staging (P6(a) fail or P6(a)-ok/P6(b)-fail); order is unbind (keep lease) → config failed/from → unfence → release lease → release scope; failed recovery lifts a leftover fence.

Revert no longer equates a staging destination with P6(a) failing: after P6(a) succeeds the destination is still staging while this process holds its lease and binding. Revert now runs unbind -> release lease -> config back to from (failed) -> unfence source -> release scope, with crash recovery defined for each point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c8d784a7ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Delta since 5aa37fa: P6(a)/(b) lease-before-activation, P4 inbox final pass, P10 identical-inbox rule, Docker relocate/finalize, S3 HeadObject+If-Match replace (b8fcf18); revert after successful P6(a) spelled out (c8d784a).

Prior: source-bindable / sticky destination role / §8.2 diagram / Phase A identical-paths remain FIXED. Prior §8.3 staging≡P6(a)-failed blocker FIXED in c8d784a (already thread-acked). Codex inbox/Docker/S3 and Claude V11/destination-role LEFT_ALONE.

New

  1. Blocking — New revert crash prose says failed+local_root=from recovery lifts the source fence, but the §8.3 failed table row still only says "normal operation" and never unfences. A crash after revert step 3 and before step 4 leaves the selected old root fenced and unbindable.

Comment thread docs/storage-architecture.md Outdated
| `committed` | new | New root authoritative. The source is already `fenced` (P2). If the destination marker is still `staging` with the same `relocation_id`, perform P6 as specified: (a) take the lease and bind the `staging` root under the committed record, then (b) write the single activation. If the marker is already `active` with no role, P6(b) already landed, and PRKS binds normally. Attempt P7, P8 and P9 idempotently. A P7 that cannot reach the old root is retried at later starts and blocks nothing. |
| `source_retired` | new | Bind normally and redo P8 and P9 idempotently. |
| `retained` | new | Normal operation. Diagnostics show the retained old copy. |
| `failed` | old | Normal operation, plus a notice with a "discard failed move" action. |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — failed recovery never lifts a matching fence, but the new revert crash text requires it.

Revert steps 3→4 are not atomic: config becomes {local_root: from, phase: failed} while the source marker can still be fenced. The prose just below claims that once config says failed with local_root = from, "recovery lifts the fence." This table row does not: it only says "Normal operation, plus a notice…".

Under §7.1, ordinary bind of a fenced root is refused without unfence proof. So a crash between steps 3 and 4 strands startup on the configured old root with no matrix path that makes it bindable (unlike copying/verified, which explicitly lift the fence).

Align the failed row with the revert claim: if the old marker is fenced for this relocation_id, lift it to active before binding (same shape as the copying/verified row). Keep "discard failed move" for leftover staging cleanup only.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in 8594a56.

  • Unfencing before bind. If the old marker is still fenced with this relocation_id, the failed row now lifts it back to active before binding. That is the same step as in the copying/verified row. It covers a crash between revert's config write and its unfence, which after dd23e1c are steps 2 and 3.
  • "Discard failed move" is now scoped to cleaning up a leftover staging destination only.

Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED in 8594a56: the failed row now lifts a matching fenced marker before binding, same shape as copying/verified.

…ing OS lock (#311)

- Revert keeps the destination lease until the config durably selects the source and the source fence is lifted, so no other process can activate the staging destination from a still-committed record.
- The active_process lease is an exclusive OS advisory lock on .prks-maintenance/root.lock held while bound and released only by the kernel; the marker field is diagnostic. Resolves open question 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Reviewed ff982fd. The dedicated relocation mode and P6 reader-drain barrier fix the hot-rebind race cleanly, and GitHub's test merge with current master is conflict-free. One post-P6 bootstrap-config race remains.

Comment thread docs/storage-architecture.md Outdated
| **P4 Verify** | Re-read every destination object, and compare size and SHA-256 against the P3 manifest, which was computed from the source. Run `PRAGMA integrity_check` and the schema-version check on the destination database. Audit the catalogue against the destination (`audit_managed_pdfs`): every referenced key present. Keys missing in the source are reported, not fatal, because availability is observed (#60 §9.2). **The inbox needs a stable final pass**, because users and external tools can write it and PRKS cannot quiesce them. When `for_processing/` is under the root, rescan the source inbox and compare each file's name, size and `st_mtime_ns` with the manifest. Copy and hash any new or changed file, then rescan. Repeat until two consecutive scans agree with the manifest. If it does not settle within a bounded number of passes, refuse the move and ask the user to pause whatever is writing the inbox. The destination marker **stays `staging`**, so the destination is not bindable (§7.1). Write config `phase: "verified"`. | config (`verified`) |
| **P5 Commit** | **One atomic config replace:** `local_root = to`, `relocation.phase = "committed"`. For a config-file root this is the only switch. A CLI- or environment-selected root commits in its destination marker instead (§8.7, S9). | config (`committed`) |
| **P6 Activate and rebind** | Still under the move scope. The source has been durably `fenced` since P2 (the move was refused if that write failed). A `fenced` root is unbindable without proof that its move did not commit, and after P5 that proof cannot exist. So the source is **already revoked**. P6 runs in two sub-steps, in this order:<br><br>First **upgrade the move scope to the exclusive rebind barrier** (Gate admission, above): new reads wait and in-flight reads drain, so no reader can observe a half-swapped binding. The barrier is held through (b), or through revert.<br><br>(a) **Bind first, while the destination is still `staging`.** A `staging` root is bindable only by a holder of this relocation's **committed proof** (§7.1): either the bootstrap config's `committed` record, for a config-file root, or the destination marker's `relocation.phase: committed`, for the offline flow. That holder is this process, startup recovery, or `storage finalize`. Hold the destination's `active_process` lease (§12): it was taken at P1, or it is taken now by startup recovery. Then call `bind_storage(new config)`; the existing rollback-on-failure applies. The lease can fail to be taken only because another holder of the committed proof, such as a concurrent startup recovery, already owns it and is completing the move. In that case this process stops and does **not** offer revert. If `bind_storage` fails, **keep the lease**. It is released only by revert (step 5, after the config says `failed` and the source is unfenced), or by the kernel when this process exits. In the second case, §8.3 `committed` recovery correctly completes the move. Releasing the lease while the committed proof still stands would let another process activate the destination, while this process could still revert to the source.<br><br>(b) **Only after (a) succeeds**, activate. Authorized by the same committed proof for this `relocation_id`, write **one atomic marker replace**: `state: active`, `relocation: null`, `moved_from: {id, peer_hint}` with `peer_hint` **copied from the destination's `relocation.peer_hint`** (required since P1), and the `active_process` lease this process already holds. `moved_from` is not a relocation role and binding ignores it, but it is operational: startup uses its `peer_hint` to retry P7 (§7.1). The destination becomes generically bindable in the same write that records this process as its owner, so there is no instant at which another process could bind it or take its lease. No crash can leave an `active` destination that still carries a role. On every later start, the destination is an ordinary bindable root, whatever the config phase says.<br><br>**Then the handler returns, releasing the barrier and the move scope; reads and mutations resume.** At no instant are two roots with this ID bindable: before P6 neither is, and from P6 only the destination is. | destination marker (`active`) |
| **P7 Retire source** | Change the old marker from `fenced` to `state: retired`, keeping `relocation: {id, role: source, peer_hint: <new root>}`. Write config `phase: "source_retired"`. This is **not a safety step**: it turns "a move is in progress" into "this library moved to X" for clearer messages and for P10 cleanup. If the old root cannot be written, for example because a disk was removed, it stays `fenced`, and PRKS retries the change at later starts. A disconnected source that reappears is still `fenced` and still refuses to bind. **No acknowledgement path exists or is needed**: revocation is the durable P2 fence, not P7. | old marker (`retired`), config (`source_retired`) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — P7/P9 background config writes can race a later root-selection/config command after the move scope is released. P6 returns and explicitly re-admits mutations, but P7 still writes relocation.phase = source_retired and P9 writes retained. Phase D also defines choose / open another library as commands that write the same bootstrap config. Atomic replace makes each write durable, but it does not stop a stale P7/P9 read-modify-write from overwriting a newer local_root/relocation record after another command has been admitted. Define serialization/compare-and-set for bootstrap config writers: e.g. every relocation phase advance must CAS the expected (local_root = to, relocation.id = this id, expected phase) and become a no-op/error if that state changed; and/or root-selection commands must refuse while this relocation is unresolved. A process-wide config-write lock alone is not sufficient unless the state is re-read and validated under it. Add a race test where open another library starts immediately after P6 while P7/P9 are delayed, and prove the old move cannot overwrite the newer selection.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in 9f3e92b, using both of your options.

§5.1: every bootstrap config write is a guarded compare-and-set. This covers every writer:

  • the relocation steps, revert and startup recovery;
  • the offline mirror;
  • choose and open another library;
  • P10.

Each writer runs the same sequence:

  1. Take a cross-process OS advisory lock on <config file>.lock.
  2. Re-read the file and check the writer's expected state. The doc states that a lock alone is not enough.
  3. Write atomically only if that check holds.

What each writer expects.

  • Relocation steps expect exactly (local_root, relocation.id, phase) as their predecessor left it:

    • P7: (to, id, committed);
    • P9: (to, id, committed | source_retired);
    • P10: (to, id, retained).

    On a mismatch the step writes nothing and logs "superseded".

  • User commands (choose, open another library, a new move's P1) expect the state the user confirmed. On a mismatch they fail with a conflict.

Root selection refuses while any move is unresolved, from preparing through source_retired, until P9 writes retained or the move is failed.

  • No indefinite block. P9 does not wait on a P7 that couldn't reach the old root, so an unreachable disk can't block selection forever.
  • Late P7 retries. A retry at a later start changes only the source marker. Its config write is a guarded no-op once the phase has moved on.

Other edits. The P7, P9 and P10 rows now name their expected state. Phase A's writer is now the guarded compare-and-set writer.

Phase E race test. It starts open another library immediately after P6, with P7 and P9 delayed, and proves:

  • the selection is refused while the move is unresolved;
  • a forced stale P7/P9 write after a newer selection leaves that newer selection intact.

Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f3e92b221

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Reviewed dbec6e2. The four Codex findings it targets are directionally fixed, but two correctness gaps remain in the new hot-rebind and inbox-final-pass text.

| **Open another library** (rebind) | config → another existing, active, marker-bearing root | no | same, with a confirmation that names it a different library; with PostgreSQL, only if the database pairing matches (§9) |
| **Move this library** (relocate) | canonical bytes are copied to a new root, then config → new root | yes | same; for `cli`/`env` sources, only through the offline CLI (§8.7) |

**Hot rebind (choose, open another library).** When a library is already

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — hot rebind still needs a transaction boundary across config update + bind, not just a per-write config lock. §5.1 releases <config>.lock after each guarded write. Here step (2) writes local_root = target, then step (3) calls bind_storage(). In that gap another process using the same bootstrap config can legitimately run choose / open another library, re-read the now-current target, and CAS the config to a third root. This process can then finish binding the first target and release the old lease, leaving its live binding on A while the bootstrap config says B. The failure rollback has the same issue: if its guarded restore is superseded, the text still says to rebind the old root. Either hold the config lock (or a durable transient rebind journal/operation lock) through lease acquisition, config switch, bind_storage, and old-lease release/rollback, or define fail-closed behavior whenever the rollback CAS no longer owns the selection. Add a two-process hot-rebind race test; per-write CAS alone does not serialize the whole rebind transaction.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in 9c090f6, using both options.

Hot rebind is now one transaction under <config file>.lock, held from the first re-read until the transaction ends. The steps are:

  1. Re-read and check the confirmed state.
  2. Take the target's root.lock with a non-blocking try-lock. A busy target fails the command, so it never waits while holding the config lock, and it cannot deadlock against a relocation that holds root locks and is waiting for the config lock.
  3. Switch the config.
  4. Call bind_storage().
  5. Release the old root's lease, then the config lock.

Rollback runs under the same lock, so its restore cannot be superseded.

Fail closed. If the rollback cannot complete (the config can't be written, or the old root no longer binds), the process:

  • calls unbind_storage();
  • reports a configuration error;
  • keeps no lease it isn't bound to.

It never keeps serving a root the config does not name. A crash releases both locks, and the next start binds whatever the config names.

Related edits.

  • §5.1 names hot rebind as the one multi-step writer that holds the lock across its transaction.
  • Phase D lists a two-process hot-rebind race test: two concurrent rebinds to different targets, where each process ends bound to the root the config names or fails closed.

Generated by Claude Code

Comment thread docs/storage-architecture.md Outdated
| **P1 Intent** | Mint a `relocation_id`. **First** write the config file with `storage.relocation = {id, phase: "preparing", from, to}`, with `local_root` still the old root, so that any destination this move creates is already discoverable. **Then** create the destination, whose complete **P1-owned set** is exactly: the root directory; `.prks-maintenance/`; `.prks-maintenance/root.lock`, the destination lock (§12), taken here; an empty `.prks-maintenance/relocation/<id>/`; and the marker `prks-root.json`, plus any `.prks-` temporary left by the marker's atomic write. The marker is `state: staging, storage_root_id: <source's ID>, relocation: {id, role: destination, peer_hint: <source root>}`. The `peer_hint` is required from here until activation. The ID is carried, not minted (§7.1). Then advance the config to `phase: "copying"`. Each config write is atomic. | config (`preparing`, then `copying`), destination marker (`staging`) |
| **P2 Quiesce** | Rely on the move scope held since request admission (Gate admission, above); P2 acquires no gate scope itself. Until P6 that scope blocks mutations, backups and restores and allows reads, which is today's backup semantics, and it bounds relocation to the same "reads keep working" user experience. Stop background writers: the cleanup retry, thumbnail and index writers, and processing scans. Then **fence the source**: durably set the old marker to `state: fenced, relocation: {id, role: source, peer_hint: <dest>}`. From here on no other process can bind the old path (§7.1). If the fence cannot be written, the move is refused before any copying. | source marker (`fenced`); the gate itself is process-local (§12 for multi-process) |
| **P3 Copy** | The database: a `sqlite3` backup-API snapshot, never a file copy, written to the destination under a temporary name and then renamed. Each canonical namespace (`asset-objects`, `portraits`, and the inbox if it is under the root): stream every object into a staging name, hash it while copying, fsync the file, rename it to its key name, and fsync the directory. Derived data, logs and maintenance are **not** copied. Links are refused, as in §7.3. | per-object progress in `<dest>/.prks-maintenance/relocation/<id>/manifest.json`: key, size, sha256, and, for inbox entries, the source `st_mtime_ns` observed when the copy was hashed (updated whenever the final pass recopies the entry). Resumable, but a restart may also discard it and begin P3 again. |
| **P4 Verify** | Re-read every destination object, and compare size and SHA-256 against the P3 manifest, which was computed from the source. Run `PRAGMA integrity_check` and the schema-version check on the destination database. Audit the catalogue against the destination (`audit_managed_pdfs`): every referenced key present. Keys missing in the source are reported, not fatal, because availability is observed (#60 §9.2). **The inbox needs a stable final pass**, because users and external tools can write it and PRKS cannot quiesce them. When `for_processing/` is under the root, rescan the source inbox and compare each file's name, size and `st_mtime_ns` with the manifest's recorded source values. Reconcile all three kinds of difference: copy and hash any **new** file; recopy any **changed** file and update its manifest size, hash and mtime; and for any manifest name **no longer present** in the source (deleted or renamed away), delete the destination copy and its manifest entry, so a removed file is never resurrected for later import. Then rescan. Repeat until two consecutive scans agree with the manifest exactly, as the same set of names with the same attributes. If it does not settle within a bounded number of passes, refuse the move and ask the user to pause whatever is writing the inbox. The destination marker **stays `staging`**, so the destination is not bindable (§7.1). Write config `phase: "verified"`. | config (`verified`) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — (name, size, st_mtime_ns) is not sufficient proof that the inbox bytes match the manifest hash. The final pass now fixes additions/deletions and records mtimes, but after a copy it decides stability by rescanning only metadata. An external writer can modify a file in place while it is being hashed/copied, or preserve/reset its mtime, and end with the same size + st_mtime_ns; the destination hash can then describe torn/old bytes while two metadata scans still 'agree'. Since the inbox is explicitly outside PRKS's write quiescence, the stable boundary must include content proof: e.g. hash the source in the final stable pass and require that hash to equal the manifest/destination hash, with fstat/stat before and after the read and retry if the file changed while hashing. The convergence test should include a same-size rewrite (and ideally preserved mtime) rather than only add/modify/delete cases.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in 9c090f6.

P4 now states that metadata agreement is necessary but not sufficient. The pass that ends the loop is a content pass. For every inbox file it will:

  1. open it and fstat it;
  2. hash the full source bytes;
  3. fstat it again.

A file is stable only if the two fstat results are identical and the source hash equals the manifest hash, which P4 has already matched against the re-read destination copy. Any mismatch recopies the file and starts another pass. A pass is accepted only when the metadata scan agrees exactly and every file passes the content check in that same pass.

A write after P4 is outside the move, and P10 already covers it: it deletes an old inbox file only if the destination holds an identical SHA-256.

Test. The Phase E inbox test now includes:

  • a same-size in-place rewrite with a preserved st_mtime_ns;
  • a rewrite during hashing.

Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dbec6e253e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Reviewed 082a09b. The config-lock transaction fixes the cross-process hot-rebind race; the content-hash pass fixes same-size/mtime-preserving in-place rewrites; and finalize now correctly requires verified:true before a first commit. One narrower pathname-replacement race remains in the inbox final pass.

Comment thread docs/storage-architecture.md Outdated
| **P1 Intent** | Mint a `relocation_id`. **First** write the config file with `storage.relocation = {id, phase: "preparing", from, to}`, with `local_root` still the old root, so that any destination this move creates is already discoverable. **Then** create the destination, whose complete **P1-owned set** is exactly: the root directory; `.prks-maintenance/`; `.prks-maintenance/root.lock`, the destination lock (§12), taken here; an empty `.prks-maintenance/relocation/<id>/`; and the marker `prks-root.json`, plus any `.prks-` temporary left by the marker's atomic write. The marker is `state: staging, storage_root_id: <source's ID>, relocation: {id, role: destination, peer_hint: <source root>}`. The `peer_hint` is required from here until activation. The ID is carried, not minted (§7.1). Then advance the config to `phase: "copying"`. Each config write is atomic. | config (`preparing`, then `copying`), destination marker (`staging`) |
| **P2 Quiesce** | Rely on the move scope held since request admission (Gate admission, above); P2 acquires no gate scope itself. Until P6 that scope blocks mutations, backups and restores and allows reads, which is today's backup semantics, and it bounds relocation to the same "reads keep working" user experience. Stop background writers: the cleanup retry, thumbnail and index writers, and processing scans. Then **fence the source**: durably set the old marker to `state: fenced, relocation: {id, role: source, peer_hint: <dest>}`. From here on no other process can bind the old path (§7.1). If the fence cannot be written, the move is refused before any copying. | source marker (`fenced`); the gate itself is process-local (§12 for multi-process) |
| **P3 Copy** | The database: a `sqlite3` backup-API snapshot, never a file copy, written to the destination under a temporary name and then renamed. Each canonical namespace (`asset-objects`, `portraits`, and the inbox if it is under the root): stream every object into a staging name, hash it while copying, fsync the file, rename it to its key name, and fsync the directory. Derived data, logs and maintenance are **not** copied. Links are refused, as in §7.3. | per-object progress in `<dest>/.prks-maintenance/relocation/<id>/manifest.json`: key, size, sha256, and, for inbox entries, the source `st_mtime_ns` observed when the copy was hashed (updated whenever the final pass recopies the entry). Resumable, but a restart may also discard it and begin P3 again. |
| **P4 Verify** | Re-read every destination object, and compare size and SHA-256 against the P3 manifest, which was computed from the source. Run `PRAGMA integrity_check` and the schema-version check on the destination database. Audit the catalogue against the destination (`audit_managed_pdfs`): every referenced key present. Keys missing in the source are reported, not fatal, because availability is observed (#60 §9.2). **The inbox needs a stable final pass**, because users and external tools can write it and PRKS cannot quiesce them. When `for_processing/` is under the root, rescan the source inbox and compare each file's name, size and `st_mtime_ns` with the manifest's recorded source values. Reconcile all three kinds of difference: copy and hash any **new** file; recopy any **changed** file and update its manifest size, hash and mtime; and for any manifest name **no longer present** in the source (deleted or renamed away), delete the destination copy and its manifest entry, so a removed file is never resurrected for later import. Then rescan. Metadata agreement is necessary but **not sufficient**: an external writer can rewrite a file in place with the same size and a preserved or reset `st_mtime_ns`, or change it while it is being hashed. So the pass that ends the loop is a **content pass**. For every inbox file, open it, `fstat` it, hash the full source bytes, and `fstat` it again. The file is stable only if the two `fstat` results are identical and the source hash equals the manifest hash, which P4 has already matched against the re-read destination copy. Any mismatch recopies that file, updates its manifest entry and starts another pass. The final pass is accepted only when the metadata scan agrees with the manifest exactly (the same set of names with the same attributes) **and** every file passes the content check in that same pass. If it does not settle within a bounded number of passes, refuse the move and ask the user to pause whatever is writing the inbox. The destination marker **stays `staging`**, so the destination is not bindable (§7.1). Write config `phase: "verified"`. | config (`verified`) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — the final content pass must also prove the pathname still names the file descriptor that was hashed. open → fstat → hash → fstat detects mutation of the opened file, but not an atomic replacement/rename of the source pathname while that fd remains open. Example: the metadata scan sees x.pdf; the content pass opens the old inode; an external tool renames a new x.pdf over it; both fstats on the old fd are identical and its hash matches the manifest, so the pass can accept even though the source path already names different bytes. This is exactly the kind of external inbox mutation the protocol says it tolerates. After hashing, stat/open the pathname again and prove it is still the same file identity as the hashed handle (POSIX device+inode; appropriate stable file ID on Windows) plus the expected attributes, or do a final namespace rescan after all content checks and restart if any entry identity changed. Add an atomic-replace/rename-during-hash case to the inbox test. Writes that occur only after the accepted P4 boundary can remain covered by the retained-source/P10 policy.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in af34f8e, using both of your options.

  • Per-file identity check. After hashing, the content pass lstats the pathname again. It must still name the hashed handle's file (POSIX device+inode; on Windows, volume serial number + file ID) with the same size and st_mtime_ns. Together with the two fstats and the hash match, this is required for a file to count as stable.
  • Closing namespace rescan. After all content checks, a rescan must find the same names with the same file identities and attributes. Any change restarts the loop.
  • Boundary stated. Writes after the accepted P4 boundary fall to the retained source and the P10 rule.
  • Test. The Phase E inbox test adds an atomic replace or rename over the pathname while its old handle is being hashed.

Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af34f8eb8a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed077c113e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated
…dows lock order (#311)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXpWepg8QGywzA791TrrUE

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb5ff85f69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated
Comment thread docs/storage-architecture.md Outdated

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Delta since af34f8eb: abort rerun after unfence + P1-only source bindable (ed077c11); P10 explicit / ctime residual / abort --to / Windows abort order (cb5ff85f); keep existing root IDs + Windows-safe P10 (ad7a3ca6); Phase D ID test (4834a23d).

Prior: pathname-replacement inbox race and the Codex items fixed in this delta (abort resume, P1-only bindable, P10 not background, abort --to, Windows abort/P10 lock order, preserve ID on open) — FIXED.

New (non-blocking): §8.3 in-app destination discard still lacks the Windows LockFileEx-safe delete order that abort/P10 now require under §12.

Comment thread docs/storage-architecture.md Outdated
| Config phase at startup | `local_root` | Resolution |
| --- | --- | --- |
| `preparing` | old | P1 crashed between its two writes. The source was never fenced, so bind it normally. If a destination exists whose marker is `staging` with this `relocation_id` and it contains nothing outside the P1-owned set (see P1; `root.lock` and marker-write temporaries included, and any member may be missing), discard it. Otherwise leave it and report it. Mark the relocation `failed`. |
| `copying` or `verified` | old | Old root authoritative. If its marker is `fenced` with this `relocation_id`, lift the fence back to `active`, then bind it normally. Mark the relocation `failed` in config. The destination is staging: delete it automatically **only** when its marker carries this `relocation_id`, `state: staging` and `role: destination`, and it contains nothing but PRKS components; otherwise leave it and report it. The user may retry. |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (non-blocking) — §8.3 destination discard should use the same Windows lock-release order as offline abort / P10.

§12 requires holding <dest>/.prks-maintenance/root.lock for marker writes and root teardown. Offline abort step 3 and P10 now delete in a Windows-safe order (components except marker/root.lock → close LockFileEx handle → remove marker/root.lock/emptied dirs), because a held lock file cannot be deleted.

The in-app paths that discard a staging destination do not state that order:

  • this copying/verified auto-delete,
  • the preparing row's "discard it",
  • the failed row's "discard failed move".

Abort even says it discards "under the §8.3 deletion rule" and then adds the Windows order at the call site — so the shared rule is still only the what (PRKS-only staging tree), not the how. On Windows, an implementer that holds root.lock per §12 and then deletes the tree will fail or leave residue that Phase E's Windows tests (abort/P10 only) would miss.

Fix: fold the lock-then-close-then-remove sequence into the shared §8.3 deletion procedure (or point these three call sites at abort/P10's order), and extend the Phase E Windows delete test to one in-app discard path.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in eb95662. §12 now defines one shared terminal teardown procedure, and every root removal uses it. The three §8.3 call sites point at it:

  • the preparing "discard it";
  • the copying/verified auto-delete;
  • the failed row's "discard failed move".

abort step 3 and P10 now reference it instead of restating the order.

In-app discard now writes phase: aborted first, like offline abort. This makes the destination durably terminal before the lock is released. The §8.3 auto-delete also accepts a destination already marked aborted by an interrupted teardown.

Test. The Phase E Windows teardown test now covers offline abort, P10 and one in-app discard path.


Generated by Claude Code

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Delta af34f8eb→4834a23: abort rerun after unfence + P1-only source bindable (ed077c1); P10 explicit / ctime + residual inbox / abort --to / Windows abort lock order (cb5ff85); keep existing root IDs on open + Windows-safe P10 (ad7a3ca); open-existing ID test moved to Phase D (4834a23).

Prior

  1. Pathname-replacement inbox race (lstat identity + closing rescan) — still FIXED
  2. Abort rerun after unfence / P1-only source bindable — FIXED in ed077c1
  3. P10 not automatic background; POSIX st_ctime_ns; residual Windows inbox rule; abort --to; Windows abort delete order — FIXED in cb5ff85
  4. Open-existing keeps storage_root_id; P10 Windows lock order — FIXED in ad7a3ca
  5. Earlier FIXED set (bootstrap CAS, hot-rebind transaction, verified-before-finalize, journal/preflight, offline lock-before-P1, etc.) holds.

CodeRabbit / Codex LEFT_ALONE.

New

  1. Non-blocking (P2) — §12 still requires holding root.lock through every marker write / through abort’s “last write”, but §8.7 abort step 3 and P10 now close the lock before removing the marker. Carve out the terminal unlock-then-delete order in §12 (or keep the lock through marker removal another Windows-safe way).

Comment thread docs/storage-architecture.md Outdated
of P4. If it cannot get the source lock, it drops the destination lock
and its lock-only scaffold and refuses, before any marker exists.
- `finalize` and `abort` take both locks before validating anything, and
hold them through their last write (§8.7). The one exception: `finalize`

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — §12 still requires holding root.lock through abort’s last marker write, but §8.7 / P10 now delete the marker after releasing the lock.

This bullet says finalize and abort “hold them through their last write”. Above, “Every marker write is serialized by that root’s lock” / “Only a process holding that root’s root.lock may perform it.”

After cb5ff85 / ad7a3ca, offline abort step 3 and P10 deliberately close the lock handle first, then remove the marker, root.lock, and .prks-maintenance/ (Windows LockFileEx cannot delete a held file). That terminal removal is a marker change performed without holding the lock, so an implementer following §12 literally cannot implement the Windows-safe order.

Please update §12 to match: e.g. while the root is still bindable or mid-move, every marker write stays under root.lock; once the root is durably terminal and non-bindable (phase: aborted, or retired for P10), the holder may release the lock and then remove the marker / lock file / emptied maintenance dir, with the existing refuse rules for finalize / relocate resume / binding covering the gap. Mention P10 in the same carve-out. The Windows ordering tests already implied by Phase E should assert this §12 exception, not only the §8.7/P10 rows.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in eb95662. §12 now has an explicit "Terminal teardown: the one exception" rule, used on every platform:

  1. Under the lock, make the marker durably terminal: phase: aborted on a staging destination, or retired on a P10 source.
  2. Still under the lock, delete everything except the marker and root.lock.
  3. Close the lock handle.
  4. Remove the marker, root.lock and the emptied directories.

Why step 4 may run without the lock. Step 4 is stated as the only marker change allowed without the lock. It is allowed only because the marker is already terminal and non-bindable: binding, finalize and a relocate resume all refuse it. A crash after step 3 leaves either a terminal marker, which a rerun of the same teardown finishes, or a lone root.lock, which is V3 scaffold.

Offline command rule. The "hold both locks through the last write" bullet now names abort's teardown release as an exception, next to the existing finalize unreachable-source one.

Tests. The Phase E Windows tests now assert this §12 exception itself:

  • the lock is released only after the marker is terminal;
  • a crash after the release leaves a state that a rerun finishes.

This is covered for offline abort, P10 and one in-app discard path.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb9566285b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/storage-architecture.md Outdated

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Reviewed current head f482bc7. All four CI gates are green and the recent ID/Windows teardown fixes are coherent. The new P10 residual-file behavior introduces one state-lifetime issue before I would merge.

Comment thread docs/storage-architecture.md Outdated
| **P7 Retire source** | Change the old marker from `fenced` to `state: retired`, keeping `relocation: {id, role: source, peer_hint: <new root>}`. Write config `phase: "source_retired"`, as a guarded compare-and-set expecting `(local_root = to, this relocation_id, committed)` (§5.1). This is **not a safety step**: it turns "a move is in progress" into "this library moved to X" for clearer messages and for P10 cleanup. If the old root cannot be written, for example because a disk was removed, it stays `fenced`, and PRKS retries the change at later starts. A disconnected source that reappears is still `fenced` and still refuses to bind. **No acknowledgement path exists or is needed**: revocation is the durable P2 fence, not P7. | old marker (`retired`), config (`source_retired`) |
| **P8 Rebuild** | Derived data is rebuilt at the new root by the existing mechanisms: the text-index and research-index reconcile, and thumbnails lazily. `retry_pending_pdf_cleanup` now runs against the **new** root only. | none |
| **P9 Retain** | Write config `phase: "retained"`, and keep `from` for diagnostics. Like every config write it is a guarded compare-and-set (§5.1): it expects `(local_root = to, this relocation_id, committed or source_retired)`, and it is a no-op if anything else has since been recorded. It does not wait for a P7 that could not reach the old root. | config |
| **P10 Cleanup** (explicit, later) | Offered only after the new root has completed at least one full start, and recommended after a verified backup. It deletes only PRKS-known components of the retired root (the names in §4.2), never unknown files and never through links, and then the marker. It takes the retired root's `root.lock` and removes it with the §12 **terminal teardown** procedure (the marker is already `retired`), which closes the lock handle before removing the marker and `root.lock`, as Windows requires. **Inbox files are deleted only if an identical copy exists in the destination** (same name and SHA-256). A file that reached the old inbox after P4, or differs from its destination copy, is kept, reported in diagnostics, and offered for import into the new library. **While any such kept file remains, P10 stops short of the teardown's final steps:** it keeps the `retired` marker, `root.lock` and the kept files, releases the lock normally, and leaves the config at `retained`, so diagnostics and the import action can still find the old root and its files. Only when every kept file has been imported or explicitly discarded by the user does a P10 rerun complete the teardown and clear the config. Config: `relocation = null`, guarded like every config write (§5.1): it expects `(local_root = to, this relocation_id, retained)`. | config |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — a retained move with kept P10 inbox files is still allowed to lose the only durable pointer to those files. This row intentionally leaves (relocation.id, from, to, phase: retained) in the singleton bootstrap config so diagnostics/import can find the old root. But §5.1 treats retained as resolved: choose / open another library may replace the config, and P0 also says a retained journal does not block a new relocation. Either action can overwrite this relocation record while the old root still contains protected kept files. The old root remains physically retired, but once the config moves on there is no documented durable index from the current installation back to that path; P10's guarded CAS can no longer run and the promised diagnostics/import action can no longer reliably discover the files. Treat retained-with-residuals as a distinct unresolved state that blocks root selection/new moves until the residuals are imported/discarded, or persist retained-copy records independently of the singleton current-root config (and make diagnostics/P10 enumerate them). Add a test that attempts open another library and a new move while P10 is stopped on a kept inbox file.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in cea5eee, using your first option. retained_residuals is now a distinct unresolved config phase.

What P10 writes. When it stops short on kept inbox files, P10 writes phase: "retained_residuals". This is a guarded CAS that expects (to, id, retained). The final clear then accepts retained or retained_residuals.

What refuses while it stands.

  • §5.1: root selection and a new move's P0 refuse while it stands. The refusal names the kept files and offers import or discard. The record is described as the only durable pointer from the installation to those files.
  • P0 journal check: retained_residuals now counts as unresolved, while failed, retained and cleared still don't.

§8.3 table. It gains a retained_residuals row: normal operation, diagnostics list the kept files, selection and new moves are refused, and a P10 rerun completes the teardown once the files are handled.

CLI/env roots. Their bootstrap file may be ephemeral, so P10 records the same state on the destination marker as moved_from.residuals: true. Diagnostics and the offline commands read it from there.

Test. The Phase E P10 test now also attempts open another library and a new move while P10 is stopped on a kept file, and proves both are refused.


Generated by Claude Code

@github-actions github-actions Bot deleted a comment from chatgpt-codex-connector Bot Sep 30, 2026

@Fooftilly Fooftilly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (LATER_PASS)

Delta f482bc7→cea5eee: P10 residual stop-short is now an unresolved retained_residuals phase (§5.1 / P0 / §8.3 / Phase E).

Prior

  1. Peer P1 — retained with kept P10 inbox overwriteable — FIXED (cea5eee).
  2. Prior FIXED set holds. CodeRabbit / Codex LEFT_ALONE.

New

  1. Non-blocking — CLI/env moved_from.residuals lacks the parallel P0/§8.7 refusal and §7.1 schema update (inline).

| **P7 Retire source** | Change the old marker from `fenced` to `state: retired`, keeping `relocation: {id, role: source, peer_hint: <new root>}`. Write config `phase: "source_retired"`, as a guarded compare-and-set expecting `(local_root = to, this relocation_id, committed)` (§5.1). This is **not a safety step**: it turns "a move is in progress" into "this library moved to X" for clearer messages and for P10 cleanup. If the old root cannot be written, for example because a disk was removed, it stays `fenced`, and PRKS retries the change at later starts. A disconnected source that reappears is still `fenced` and still refuses to bind. **No acknowledgement path exists or is needed**: revocation is the durable P2 fence, not P7. | old marker (`retired`), config (`source_retired`) |
| **P8 Rebuild** | Derived data is rebuilt at the new root by the existing mechanisms: the text-index and research-index reconcile, and thumbnails lazily. `retry_pending_pdf_cleanup` now runs against the **new** root only. | none |
| **P9 Retain** | Write config `phase: "retained"`, and keep `from` for diagnostics. Like every config write it is a guarded compare-and-set (§5.1): it expects `(local_root = to, this relocation_id, committed or source_retired)`, and it is a no-op if anything else has since been recorded. It does not wait for a P7 that could not reach the old root. | config |
| **P10 Cleanup** (explicit, later) | Offered only after the new root has completed at least one full start, and recommended after a verified backup. It deletes only PRKS-known components of the retired root (the names in §4.2), never unknown files and never through links, and then the marker. It takes the retired root's `root.lock` and removes it with the §12 **terminal teardown** procedure (the marker is already `retired`), which closes the lock handle before removing the marker and `root.lock`, as Windows requires. **Inbox files are deleted only if an identical copy exists in the destination** (same name and SHA-256). A file that reached the old inbox after P4, or differs from its destination copy, is kept, reported in diagnostics, and offered for import into the new library. **While any such kept file remains, P10 stops short of the teardown's final steps:** it keeps the `retired` marker, `root.lock` and the kept files, releases the lock normally, and writes config `phase: "retained_residuals"` (a guarded compare-and-set expecting `(local_root = to, this relocation_id, retained)`), so diagnostics and the import action can still find the old root and its files. `retained_residuals` is an **unresolved** state: root selection and a new move refuse while it stands (§5.1), so the record cannot be overwritten. For a CLI- or environment-selected root, whose bootstrap file may be ephemeral, P10 records the same state on the destination marker as `moved_from.residuals: true`, and diagnostics and the offline commands read it from there. Only when every kept file has been imported or explicitly discarded by the user does a P10 rerun complete the teardown and clear the record. Config: `relocation = null`, guarded like every config write (§5.1): it expects `(local_root = to, this relocation_id, retained or retained_residuals)`. | config |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking — CLI/env moved_from.residuals lacks parallel refusal and schema.

P10 records unresolved residual state for CLI/env roots as moved_from.residuals: true and says offline commands read it there, but:

  1. §7.1 still defines moved_from only as {id, peer_hint} (no residuals field).
  2. P0's journal check was updated only for config retained_residuals.
  3. §8.7 offline P6(b) still rewrites moved_from: {id, peer_hint} with no residuals check.

A later offline relocate→finalize can therefore overwrite the flag and drop the only volume-durable pointer to a prior retired root's kept inbox files — the same overwrite class the peer P1 fixed for the bootstrap journal, left underspecified for the marker journal.

Fix: extend §7.1 moved_from with optional residuals; make offline P0 / storage relocate refuse while moved_from.residuals is set (same unresolved semantics as config retained_residuals); require offline P6(b) not to overwrite that flag until residuals are cleared; add a Phase E offline test.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in fa3f005.

  1. §7.1 schema. moved_from is now {id, peer_hint, residuals?}, and residuals: true is defined as the volume-durable equivalent of config retained_residuals. While it is set, every later write of that marker preserves moved_from unchanged. Only the P10 rerun that completes the teardown clears it.
  2. Offline P0 refusal. storage relocate's P0 now refuses while the source marker carries moved_from.residuals, with the same unresolved semantics as the config state. The refusal names the kept files and offers import or discard.
  3. Offline P6(b). It writes moved_from only on the new destination's fresh marker, and that marker never holds a prior flag. With the refusal in step 2, it can never meet an unresolved flag, and the text now says so explicitly.
  4. Test. The Phase E P10 test has an offline variant. It sets moved_from.residuals on a CLI-selected root, then proves storage relocate refuses and that no marker write drops the flag before the teardown completes.

Generated by Claude Code

@github-actions github-actions Bot deleted a comment from chatgpt-codex-connector Bot Sep 30, 2026
@Fooftilly
Fooftilly merged commit 84c90bb into master Sep 30, 2026
19 of 20 checks passed
@Fooftilly
Fooftilly deleted the claude/kind-hypatia-psyqdp branch September 30, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Roadmap] Configurable Data Root & Asset Storage Backends

2 participants