Skip to content

fix: name the cause when the archive root is not writable - #91

Merged
hdkiller merged 1 commit into
developfrom
codex/root-not-writable
Sep 10, 2026
Merged

hdkiller merged 1 commit into
developfrom
codex/root-not-writable

Conversation

@hdkiller

@hdkiller hdkiller commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On an archive root that cannot be written to, every writing command failed
with write.interrupted (documented retryable) carrying stage: "lock",
a value the contract's enumerated stage set does not contain. Nothing was
interrupted there and no retry can succeed, including archive repair-permissions, which takes the same single-writer lock.

What changed

  • New refusal archive.not_writable (archive bucket, exit 4, not
    retryable). A lock file that cannot be created because the root refuses to
    be written to is refused by name. The human line says the archive root
    itself has to become writable and that archive repair-permissions cannot
    help when the root is read-only. Details are bucket, scope (archive)
    and archive_path (.); no path, filename, or user value is echoed. Both
    PermissionDenied and ReadOnlyFilesystem map to it, because a read-only
    mount and withheld write permission are the same condition for a caller.
    No existing read-only precondition or usage.archive_read_only existed to
    reuse.
  • write.interrupted keeps every genuinely torn write. Its other failure
    from the lock now reports the documented stage record_write, which the
    contract already gave to the archive root.
  • The stage set is complete and closed. The contract now enumerates all
    five values in prose beside the scope paragraph: the three write stages
    plus the deletion's own phases delete and purge, which case delete
    already emitted undocumented. The lock file joins the record_write row in
    both documents' tables. The object store now reports object_write rather
    than the undocumented object, and the rebuildable index reports
    record_write rather than the undocumented cache_write (the contract
    already names a cached file under record_write). platform.no_directory_fsync
    is documented as carrying a write stage, or a deletion phase where a
    deletion asked for the flush, which is what it already did.
  • Enforcement. error::stages holds the constants, WRITES, ALL,
    is_documented, and is_write; publish_refusal, link_refusal, and the
    fsync warning debug_assert their argument, so a test run catches an
    undocumented stage. A new contract test parses the contract's table and its
    enumeration, holds both to the implementation's sets, then reads every
    call in openpapir-core that takes a stage as its last argument (36 sites
    in this tree) and holds each literal to the contract, so a new emitter
    cannot introduce a stage no caller can branch on. The same test reads every
    stage constant, which is how record_replace was found: case update
    rewrites a record, and on a platform without a directory flush its
    platform.no_directory_fsync warning carried that undocumented stage. It
    reports record_write now, which is what the contract already gave a
    record document.
  • Windows behaviour is stated in docs/error-contract.md,
    docs/architecture.md, tests/golden/README.md, and the changelog: a
    directory's read-only attribute there does not stop a file from being
    created in it, so the condition arises from an access-control list or a
    read-only volume; the tests that withhold write access are Unix-only for
    the same reason and skip when the process writes anyway.
  • CHANGELOG under Fixed.

Goldens changed

New case archive.not-writable (archive repair-permissions on a root that
withholds write access), added files only:

  • tests/golden/archive.not-writable/json.json
  • tests/golden/archive.not-writable/json.exit
  • tests/golden/archive.not-writable/human.txt
  • tests/golden/archive.not-writable/human.stderr.txt
  • tests/golden/archive.not-writable/human.exit

No existing golden changed. The world restores the root's mode on drop so the
temporary directory is still removable.

Verification

  • ./scripts/check.sh && cargo build --release --locked && cargo run --locked -p openpapir-cli -- capabilities --json
  • cargo clippy --workspace --all-targets --locked --target x86_64-pc-windows-msvc -- -D warnings
  • cargo +1.88 check --workspace --all-targets --locked
  • cargo llvm-cov --workspace --locked --fail-under-lines 90 (97.25% lines)
  • Rebased onto origin/develop immediately before pushing.

Exceptions to Owned Paths, with reasons

  • crates/openpapir-core/src/records/document.rs: one stage constant renamed to the documented record_write.
  • crates/openpapir-core/src/cache/mod.rs: one constant, the undocumented
    cache_write stage, which had to change for the set to be complete.
  • crates/openpapir-cli/tests/golden.rs and tests/golden_support/mod.rs:
    the new golden case and its setup, which the acceptance criteria ask for.
    The two permission cases moved into their own permission_cases() group
    because archive_cases() would otherwise exceed the clippy line limit.
  • crates/openpapir-cli/tests/concurrency.rs: the end-to-end test of the new
    refusal. It is the file about the single-writer lock, and tests/archive.rs
    is one line under the 1500-line source limit, so adding it there would have
    left no room.

@hdkiller
hdkiller force-pushed the codex/root-not-writable branch from 0c49063 to fa4e8dd Compare September 10, 2026 13:52
A writing command on a root that cannot be written to failed with the
retryable write.interrupted carrying the stage "lock", a value the error
contract does not enumerate. Nothing was interrupted there and no retry
succeeds, so the condition is now archive.not_writable in the archive
bucket at exit 4, not retryable, with a message that says the archive
root itself must become writable and that archive repair-permissions
cannot help, since the repair takes the same lock.

The stage set is closed and now complete: the lock file joins
record_write with the rest of the archive root, the object store reports
object_write rather than the undocumented "object", a rewritten record
and the rebuildable index report record_write rather than the
undocumented "record_replace" and "cache_write", and the deletion's own
phases delete and purge are enumerated beside the three write stages. A
contract test reads every call and every constant in openpapir-core that
names a stage and holds each one to the contract's enumeration.
@hdkiller
hdkiller force-pushed the codex/root-not-writable branch from fa4e8dd to d517455 Compare September 10, 2026 14:08
@hdkiller
hdkiller merged commit 8c51cb0 into develop Sep 10, 2026
14 checks passed
@hdkiller
hdkiller deleted the codex/root-not-writable branch September 10, 2026 14:12
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.

1 participant