Concurrency and Locking
This page is the authoritative current-state reference for Prikk’s local concurrency and locking model. It explains what the current lock files protect, how active-session and ref publication writes are serialized, how ref compare-and-swap checks fail, and where stale-lock recovery remains manual.
For physical paths and authority boundaries, see repository layout and authority. For local persistence and crash-recovery behavior, see durability and crash recovery. For verification and doctor diagnostics, see integrity and recovery diagnostics. For trust and signing boundaries, see the trust and threat model and the security and signing setup guide.
Core Caveats
- Prikk is early implementation software and is not a production Git replacement.
- Current locks are local lock files. They are not distributed locks, remote coordination, hosted-forge locks, or filesystem leases.
- There is no global repository lock today.
- Lock conflicts and stale-baseline ref publication conflicts both surface as
LockConflict, but they have different causes and operator responses. - Stale lock cleanup after a crash is manual today, through
prikk unlock— not automatic, not a doctor repair, and not gated on the tool’s own PID check, which is advisory only. There is no lock timeout or automatic lock stealing. - The active-session model uses one default active WAL and active ref metadata. It is not a multi-active-session model.
- Durability and recovery claims are supported by current unit and integration tests, not by a completed crash-matrix or fuzzing campaign.
- Repository mutation is exercised by project gates on Linux, macOS, and Windows (DC-87 Stage 2). Windows’ anchoring guarantee is weaker than Linux/macOS in one stated way — see platform support for the exact gap and which of the eight remaining durability guarantees (G5 retired in DC-98) are held, weaker, or documented no-ops there. Read-only commands are CI-gated on macOS and Windows too — see platform support.
.prikk/is not a stable repository format and there is no stable migration policy yet.
Lock Files and Scope
Prikk currently uses six lock types: the active-session lock, one per-ref lock, and four container locks (RFC 102 Stage 6 Step 2) — one per compacting container plus the ref log:
active/default/active.lock
refs/locks/<ref-name-storage-key>.lock
refs/containers/pointer-index.lock
refs/containers/log.lock
refs/containers/received-index.lock
trust/policy.lock
There is no temporary-candidate mechanism: ref publication writes into a shared, append-only pointer container directly, and an append-only record has no candidate value to stage before becoming durable.
The lock primitive is the same for every lock kind above. The store creates the lock file with
exclusive file creation. If the file already exists, acquisition fails with LockConflict. When
acquisition succeeds, the file body records the current process id, lock kind, and a note that stale
lock stealing is not implemented — see Stale Locks and Manual Cleanup
below for what that note no longer fully describes. The lock file and parent directory are
required-synced before acquisition succeeds. A post-create sync failure returns failure and
deliberately retains the lock as an actionable stale-lock state.
Lock release is best-effort file removal when the lock guard is dropped. If a process exits normally, that usually removes the lock. If a process dies while holding the lock, the file can remain and later commands fail closed instead of guessing whether the repository is safe to mutate.
The four container locks are acquired by whichever operation is touching that container — the writer
(ref publication, trust add/remove, bundle import) or prikk compact/prikk compact --plan-only — and
held for that operation’s whole critical section, never just the final write. Multi-container
operations (ref publication touches the pointer-index and ref-log locks together; trust add touches
only the policy lock, since the key container is not lockable) acquire their whole set through one
internal helper that sorts it into a single fixed order first, so no call site can express an inverted
acquisition order.
These lock files are local synchronization state. They are not history, trust evidence, publication evidence, or object identity.
Active Session Locking
The default active session stores pending Patch envelopes before seal:
active/default/queue.wal
active/default/ref-name
active/default/active.lock
The active lock protects writes to this active-session state. Current command paths acquire
active.lock before mutating or sealing the default active WAL:
- worktree patch authoring holds the active lock across the active-WAL emptiness/ref-owner guard, patch authoring boundary, and final WAL append;
- rollback-draft append acquires the active lock before appending rollback-draft state;
- the active-session append helper acquires the active lock before appending a signed Patch envelope;
- doctor WAL-tail repair acquires the active lock before its final publication guard and holds it through verification, truncation, and the post-repair report;
- seal acquires the active lock before replaying the WAL, checking active ref metadata, publishing the ref, and draining active state after successful publication.
The active WAL is paired with active ref metadata. A non-empty active WAL must have valid metadata identifying the local branch ref that owns those pending records. Missing, malformed, or mismatched metadata fails closed; seal does not guess the publication target.
Current worktree authoring is single active-commit-before-seal for the default active WAL. A second commit before seal either loses the active lock or, after the first commit releases the lock, sees the non-empty WAL and fails with guidance to seal first.
Ref Publication Locking and CAS
Ref publication uses a ref-specific lock and repeated expected-current checks. These are related but distinct mechanisms:
- the per-ref lock serializes Prikk publications and signer-backed completion for the same ref;
- the expected-current checks reject stale-baseline publication when the caller’s expected previous RefState no longer matches the current pointer.
A lock conflict such as active lock already exists or ref lock already exists means another process
may still be holding a local lock, or a stale lock file may remain after a crash. A conflict such as
ref CAS mismatch means the ref’s current pointer did not match the publication’s expected previous
RefState. That is not fixed by deleting a lock file; the caller must re-read the current ref state and
rebuild or retry the publication from the new baseline.
Current ref publication is scoped to one ref:
- Validate the publication inputs.
- Acquire
refs/locks/<ref-name-storage-key>.lock. - Persist the signed RefState object into its container.
- Validate pointer/log state, including the empty state required for unborn-ref creation.
- Check the current ref pointer against
expected_previous_ref_state_id. - Append and required-sync the new pointer entry to the shared pointer-index container — the publication commit point. An append-only record has no candidate value to stage first, so this one durable append is both the check-then-write step and the promotion step the pre-container design needed two for.
- Append and required-sync exactly one signed RefUpdate record to the shared log container.
- Confirm pointer/log agreement before active state is removed.
Those checks prevent silent overwrite when the on-disk ref pointer has moved away from the caller’s expected baseline. They are not a global repository transaction, a distributed consensus protocol, or a proof that every crash point has been exhaustively tested.
Seal takes active.lock first and then enters ref publication, which acquires the ref lock. Current
code does not acquire those locks in the reverse order.
Interrupted Publication Locking
The pointer-index append is the publication commit point. If interruption leaves the pointer exactly
one transition ahead of the log, only signer-backed seal retry may finish publication. It takes the
active lock and the same ref-specific lock, revalidates retained WAL, RefState, Block, sequence,
old/new ids, and maintainer trust, then appends the exact deterministic RefUpdate. A structurally
incomplete final log frame may be truncated only by that path after the complete prefix verifies; the
shared log container has no pre-append refusal on an existing incomplete tail (unlike the pointer-first
check above), since a torn tail belonging to one ref never enters any other ref’s own filtered
subsequence and so cannot block a different ref’s publish.
Doctor diagnoses interrupted publication but does not sign, append, promote, or reconstruct a missing pointer. The former format-1 missing-pointer repair is refused in 0.18.0. The sole bounded legacy mutation is signer-backed seal completion of one exact format-1 log-ahead transition with matching retained active state.
Container Locking and Compaction
Four containers — the ref-pointer index, the ref log, the received-ref index, and the trust policy
container — each have their own lock, held for the whole critical section by whichever operation is
touching that container: an ordinary writer (ref publication, trust add/remove, bundle import) or
prikk compact/prikk compact --plan-only. This excludes a compaction run and an ordinary write from
interleaving; it is not about protecting the container’s content the way CAS protects a ref’s
baseline, but about protecting which physical slot is currently authoritative while it is being
read, written, or switched. For what a container lock actually protects against and how compaction
itself works, see repository layout — Compaction.
Ref publication acquires the ref-pointer-index and ref-log container locks together, in that order, in
addition to (not instead of) the per-ref lock above. Trust add/remove acquires the trust-policy
container lock in addition to active.lock. Bundle import acquires only the received-ref-index
container lock — it previously acquired no lock at all for this write, which is what surfaced the
container-locking work in the first place.
Object Container Writes Are Not Among the Four Locked Containers
The content-addressed object containers (write_object_to_container, index.rs) are not one of
the four containers above — there is no LockableContainer variant for them, and no dedicated lock
file protects a write into one. An object write is safe under concurrency only when something else
already holds a lock across it:
seal(crates/prikk-cli/src/seal.rs) acquiresActiveLockbefore persisting the WAL’s Patch envelopes and building a Block, and holds it for the whole operation — object writes there are incidentally serialized against any other session by the same lock that serializes everything elsesealdoes.import_bundle(bundle.rs) does not. Its object-writing loop runs before any lock is acquired at all; the received-ref-index container lock above is taken only afterward, to protect the received-pointer write, and covers none of the object writes that already happened.
This matters because write_object_to_container’s own write path (index.rs) reads the target
container’s current length, then appends at that offset and records it in the index — a
read-then-append that is not atomic across two unsynchronized writers. Content-addressed
idempotency (RFC 102 Stage 3’s same-id-same-bytes no-op, preserving the old publish_immutable
contract) makes writing the same object twice from two racing writers safe, but does not cover
two different concurrent unprotected appends into the same container computing offsets against
the same stale length.
Known and accepted, not fixed here. This predates DC-98 — it was previously recorded only as a
comment pointing at FINDINGS.md and a DurabilityContract::publish_immutable row, both since
removed, leaving it unregistered anywhere. Re-registered here in its own terms rather than by
reference to either. The mechanism that would close it (a dedicated object-container lock, or
extending import_bundle to hold ActiveLock across its object writes) is out of scope for this
page to design; it is deferred, tracked follow-up scope, not a defect this document is claiming is
fixed.
Stale Locks and Manual Cleanup
If a process dies while holding any lock — active.lock, a ref lock, or one of the four container
locks — the lock file can remain. Current Prikk does not steal stale locks, expire them, or use doctor
to clear them, and this is deliberate, not merely unimplemented: automatically clearing a lock whose
process turns out to still be running would let two writers hold the same container simultaneously,
the exact race locking exists to prevent.
prikk unlock is the supported recovery path. A bare invocation lists every currently held lock, its
recorded process id, and a best-effort advisory on whether that process still appears to be running
(checked with kill(pid, 0) on Linux and macOS; unknown on other platforms). This check is
asymmetric on purpose: a positive result — the process still appears to be running — is reliable
evidence to refuse, because the check actually found it. A negative or unknown result is not
evidence the lock is safe to clear, since PID reuse after a reboot or PID-namespace isolation inside a
container can both make a genuinely running process appear absent. prikk unlock --lock <path> clears
one specific lock, after printing its details, and requires typing yes at an interactive prompt
unless --yes is passed for scripting — the tool never decides a lock is stale on its own; it reports
what it can check and lets the operator supply the fact it cannot.
Manual cleanup remains an operator decision, now made through a supported command rather than deleting
the file directly. It is only safe after confirming that no Prikk process is still writing the
repository. If the active WAL is non-empty, preserve the repository state and use verify / doctor
diagnostics before deciding whether clearing any lock is appropriate. Do not clear a lock to work
around a ref CAS mismatch; that error means the publication baseline is stale, not that a lock file
is blocking progress.
Concurrent Operations Supported Today
The current model is conservative:
- one writer can hold the default active-session lock;
- one writer can hold the lock for a specific ref;
- different ref locks are separate files, so current storage code does not serialize all refs through a single global lock — but ref publication to any ref also acquires the shared ref-pointer-index and ref-log container locks, so two publications to different refs still serialize against each other on those, even though their per-ref locks differ;
- one writer or one
prikk compactrun can hold each of the four container locks; - the default active WAL still serializes public command flows that author then seal active state;
- read-only verification, doctor analysis, history inspection, checkout planning, merge evidence, and merge planning do not create these lock files, though they still read mutable repository state.
This does not mean Prikk supports multi-user concurrent repository mutation, branch transactions, remote synchronization, or race-free behavior under arbitrary concurrent filesystem modification.
Deferred and Not Promised
Still deferred: multi-active sessions, distributed locking, remote sync, hosted-forge lock semantics,
branch transactions, lock expiry, automatic stale-lock recovery, broad active-session recovery,
complete crash-matrix testing, filesystem fault injection, fuzzing for WAL/ref-log recovery, macOS and
Windows filesystem validation, stable repository-format migration, backup/restore tooling, and
production-readiness claims. A best-effort, advisory PID check now exists (prikk unlock, see
Stale Locks and Manual Cleanup above) — what remains deferred is
automatic recovery, not the check itself; the tool still requires an explicit operator decision for
every lock it clears.
Claim-to-Source Anchors
| Claim | Source anchors |
|---|---|
| Active and ref locks use exclusive anchored file creation, required-sync the lock file and parent directory, retain a stale lock on acquisition-sync failure, and attempt best-effort removal on drop. | lock.rs, DC-37 |
Existing lock files fail closed as LockConflict, and current locks have no stale-lock stealing. | lock.rs, durability and crash recovery |
Active-session append holds active.lock before appending to the active WAL. | active.rs, wal.rs |
Worktree patch authoring holds active.lock across the active-WAL guard and final WAL append, enforcing the current seal-before-second-commit behavior. | node_authoring.rs, DC-15 |
Rollback-draft append acquires active.lock before appending rollback-draft state. | rollback_draft.rs, DC-10 |
Seal acquires active.lock, validates active ref metadata, publishes through the ref store, then drains active state after successful publication. | seal.rs, refs.rs, durability and crash recovery |
| Non-empty active WALs require valid active ref metadata; missing or malformed metadata is an integrity issue. | active.rs, verify.rs, integrity and recovery diagnostics |
| Ref publication uses a per-ref lock, expected-current checks, signed RefState persistence, a durable pointer-index append as the commit point, then exactly one signed RefUpdate append to the shared log container. | refs/publication.rs, refs/pointer_index.rs, refs/container.rs, DC-38 |
Ref CAS mismatch returns LockConflict and is distinct from an existing lock-file conflict. | refs.rs, lock.rs |
| Unborn ref publication is allowed only when the pointer is absent and the ref log is empty with no trailing partial bytes. | refs.rs, seal.rs, DC-13 |
| Doctor refuses format-1 missing-pointer reconstruction; exact interrupted publication completion requires signer-backed seal under the active and ref locks. | seal.rs, doctor.rs, DC-38 |
| Doctor repairs are opt-in and do not clear unsafe active sessions or define stale-lock cleanup. | doctor.rs, integrity and recovery diagnostics, DC-29 |
Four container locks (ref-pointer index, ref log, received-ref index, trust policy) are acquired by writers and prikk compact alike, sorted into one fixed order by a single acquisition helper before any lock is taken. | lock.rs, compact.rs |
prikk unlock lists every held lock with an advisory (not authoritative) liveness check of its recorded process id, and clears one named lock only after explicit confirmation or --yes. | unlock.rs, prikk-cli/src/unlock.rs |
| Repository path and durability claims for mutation remain limited by current test evidence and gates exercised on Linux, macOS, and Windows (DC-87 Stage 2); read-only commands are CI-gated cross-platform as of DC-71. | durability and crash recovery, path and worktree safety, platform support, DC-28, DC-32 |
Provenance
This reference implements DC-33 as a documentation-only extension of the DC-24 current-state reference series. It adds no code, schema, CLI behavior, lock behavior, commit behavior, seal behavior, verification behavior, doctor behavior, trust behavior, repository behavior, release semantics, or repository-format stability guarantee.