ADR-119
make remote job admission durable before transport
accepted · 2026-08-22 · L4 · cites 1
0ADR-119: make remote job admission durable before transport #
Context #
ADR-117 requires every effecting remote request to carry an idempotency key,
binds that key to the authenticated request digest before execution, and names
paused as a real first-version status. It also makes SSE a projection of a
job event journal rather than a second execution path. None of those contracts
can be added honestly as route-local memory: a restart would forget the key,
could create a second runnable job, lose paused, and reset event cursors.
The shared execution service and HTTP boundary are separate work. The state
plane must therefore stand alone without importing nika-runtime,
nika-cli, transport, authentication, or workflow discovery. Its filesystem
authority must follow the descriptor-rooted precedent established by
nika-fs::OwnedDir and exercised by nika-arm under ADR-118.
Decision #
Create nika-serve as an L4 workspace member whose first surface is a durable,
transport-free JobStore.
1. One descriptor-rooted store #
JobStore::open admits one existing operator-owned root through
nika_fs::OwnedDir and creates a contained jobs directory. After that
admission, all reads, locks, temporary writes, renames, and directory syncs are
relative to held descriptors. Caller data is never used as a child pathname.
The store keeps an explicit initialized.json marker beside one versioned JSON
snapshot in state.json. Under the first kernel lease, a pristine store with
neither file persists an empty snapshot and then the marker before opening
succeeds. Once the marker exists, a missing or renamed-away state.json is
corruption rather than an empty store. A state file without its marker also
refuses, so an interrupted first initialization cannot be silently adopted.
The witness is scoped to the admitted jobs directory. An actor able to remove
that directory, or both marker and snapshot together, has already crossed the
operator-owned storage boundary; the next open cannot distinguish that event
from intentional provisioning of a new store. W10 operations must protect and
back up the root. W05 detects partial loss and corruption, not total estate
destruction by an actor with host filesystem authority.
Every mutation writes and syncs a temporary regular file, renames it descriptor-relatively, then syncs the held directory. A missing, malformed, truncated, unsupported, or invariant-breaking initialized snapshot refuses at startup and before every later operation; there is no partial recovery or lossy default.
2. Concurrency precedes idempotency #
Every operation holds both an in-process mutex and a kernel advisory exclusive
lease. Under that lease it reloads the durable snapshot before deciding. This
makes two threads, independently opened store instances, or processes observe
one key-binding decision rather than race independent in-memory maps. The
focused proof opens separate JobStore values on one root, verifies their
nonblocking leases contend on the same kernel lock, then races their admission
calls.
That per-operation lease is not server liveness. A claimed
ServerIncarnation therefore owns a separate nonblocking server.lock lease
for its entire lifetime. A second process cannot claim a generation or settle
jobs while the first server capability remains alive. Generation allocation is
persisted, and settlement consumes that generation even when it finds no
running jobs; replaying the capability cannot interrupt a job started later.
An IdempotencyKey contains bounded visible ASCII and is stored as data. A
RequestDigest accepts only canonical lowercase hexadecimal for 32 digest
bytes; uppercase or mixed-case input is rejected rather than normalized. The
first pair creates an opaque random JobId; the same pair returns that record;
the same key with a different digest returns Conflict without mutation.
3. Status and events are persisted contracts #
The status vocabulary is exactly queued | running | interrupted | paused | succeeded | failed. The public legal edges are:
queued -> running | failedrunning -> paused | succeeded | failedpaused -> running | failedTerminal states do not reopen. interrupted is also terminal and has no public
incoming edge: only the crate-internal startup settlement may assign it after
the higher layer establishes a new exclusive server incarnation. The public
settlement method requires a leased ServerIncarnation capability that
external callers cannot construct. An illegal edge refuses before writing.
Every legal status change uses transition_with_events: the new status and at
least one event become visible through one snapshot replacement or neither
does. Startup settlement follows the same invariant internally by appending a
chained interrupted event carrying current/prior incarnation generations in
the one replacement that sets the status. paused survives restart exactly as recorded; plain opening preserves
running until that settlement authority acts.
Replaying an interrupted request returns the existing job, so a restart cannot
create a second runnable job for the same admitted request.
Each JobEvent receives a contiguous per-job sequence starting at one.
append_events assigns and persists those numbers under the same lease;
events_after returns at most a validated EventPageLimit from the suffix
strictly after a caller cursor. Payloads are capped at 64 KiB encoded, append
batches at 64 events, pages at 256 events, and the complete snapshot at 4 MiB.
All four limits refuse before durable mutation.
Every event also stores previous_hash and hash. The SHA-256 preimage is
domain-separated by nika.job-event.chain, versioned, and canonically encodes
the job id, request digest, sequence, previous hash, and complete JSON payload.
The chain is unkeyed: it is an internal-consistency check, not a MAC and not a
signature. Every preimage input is data the snapshot itself carries, so any
writer able to rewrite state.json can recompute every link. Head and count
duplication plus link validation therefore detect accidental or non-coherent
corruption — partial writes, inconsistent truncation, bit flips, and edits by a
writer that does not recompute the chain. They do not detect a coherent
rewrite: an actor holding the snapshot can delete, reorder, graft, or edit
payloads — including flipping an approval decision from deny to allow — and
emit a chain that validates. The journal, count, and head occupy one rewrite
domain.
Approval one-shot history therefore belongs to a separate authority, not to
the job snapshot. JobStore::open_with_approval_history accepts an
ApprovalHistory implementation whose retention domain cannot be coherently
rolled back by the actor that can rewrite state.json. On every load, the
store requires every journaled approval digest to exist in that authority. On
append, it asks the authority to atomically record the complete digest batch
only if every digest is globally unused, then writes the prepared snapshot.
The authority may be ahead after a refused snapshot write or rollback; that
fail-closed burn is intentional. JobStore::open has no such authority, so it
refuses both approval appends and snapshots that already contain approvals.
An approval_decided payload still requires a canonical digest, and the
event chain places that digest at a job and position inside a self-consistent
journal. That placement is not authentication: the chain does not attest that a
recorded decision is the one a human made, and the snapshot writer can rewrite
the payload beside it. ApprovalHistory anchors exactly one property — one-shot
retention and reuse refusal for a digest — outside the snapshot's rewrite
domain; it authenticates neither the decision payload nor the journal. A
coherent approval-tail rewrite can therefore reopen or restate a decision, while
a burned digest still cannot be spent twice as long as the authority retains it.
A second ordinary file controlled by the same snapshot-rewrite actor does not
satisfy the ApprovalHistory contract. W06 must supply the runtime adapter and
durable anchor before binding its listener; the retention boundary of that
anchor is a deployment responsibility this ADR does not assign to a wave.
A cursor above the latest durable sequence returns typed
CursorBeyondLatest instead of pretending an unknown future position is an
empty suffix. This is the durable resume substrate required by future SSE, not
an SSE implementation.
4. The first boundary is intentionally smaller than Serve #
W05 adds no listener, HTTP route, authentication, source lookup,
ExecutionService integration, cancellation, artifact path, retry worker, or
trace scan. nika-serve remains a workspace WIP until the later admission wave
closes the full twelve-gate ledger.
Consequences #
Positive #
- Process restart cannot erase an idempotency binding,
paused, or an event resume cursor. - Empty first initialization is durable, while later
state.jsonloss fails closed instead of manufacturing a pristine store. - Conflict and transition verdicts are decided from validated durable state while both local and kernel exclusion are held.
- Visible root replacement and planted symlinks cannot redirect admitted I/O.
- W06 can consume one typed state API without coupling execution to HTTP.
- A live server lease excludes a second incarnation across processes.
- Status and lifecycle events cannot split across two durable writes.
- Approval decisions enter the validated job chain only after their digest is burned in a separately anchored, monotonic history, so a replayed digest refuses even though the chain itself is rewritable.
Negative #
- The initial snapshot rewrites the complete job-state document per mutation; a measured scale signal is required before introducing sharding or a second persistence form.
- Advisory locking requires every compliant writer to use
JobStore; raw external edits are detected as corruption only when they break a validated invariant. An edit that recomputes the chain breaks none. - The job snapshot and approval authority are not one atomic persistence domain. The approval digest is burned first, so a later snapshot-write failure can leave an authority-only record. This sacrifices retry availability to preserve one-shot safety; it never exposes a status without its event or vice versa.
- The event chain checks internal consistency against its stored head and links but is not a MAC, a signature, or a rollback witness. A writer able to rewrite the snapshot can construct any coherent history — shorter, reordered, grafted, or with edited payloads — and recompute valid links; approval reuse still refuses only while the separate authority survives.
- After a new exclusive server incarnation is established, its startup path
atomically settles every ownerless
runningjob asinterruptedbefore exposure. PlainJobStore::opendoes not make that liveness judgment, so a concurrent handle cannot interrupt a live owner. The terminal ambiguity prevents automatic replay; explicit retry still waits for typed execution settlement authority.
Neutral #
- Event payloads are JSON values because the state plane sequences opaque interface events; later route schemas decide their public wire shapes.
JobStoredebug rendering is deliberately opaque and never delegates to the descriptor holder's path-bearing representation.- The store tracks the workspace version and is not published independently.
Conflictis an admission verdict, not a storage error.JobStoreError::Ioretains onlystd::io::ErrorKind; path-bearing source context is erased at the public state-plane boundary. The W06 HTTP adapter must still map every variant to its own bounded response class rather than exposeDisplay.
Required evidence #
- Restart plus replay returns the original
JobId. - Conflicting key reuse refuses without mutation.
- Independently opened stores contend on one kernel lease, and concurrent duplicate admissions create exactly one runnable record.
- Illegal lifecycle edges preserve the prior durable status.
- Truncated, deleted, renamed-away, and unknown-future initialized state refuses at startup without rewrite.
- Rendering or chaining a typed I/O refusal cannot disclose the durable root.
- Public Serde construction cannot forge an invalid job id, idempotency key, or request digest.
pausedsurvives restart, while a lifetime-held server lease and persisted one-shot generation settle ownerlessrunningas terminalinterruptedbefore exposure without interrupting a later live owner.- Root symlinks, a planted
jobschild, and visible-root replacement cannot redirect state. - Event ids remain contiguous, bounded
events_afterpages resume strictly after their cursor, and a cursor above the latest sequence returns a typed error. - Digest boundary cases reject uppercase, mixed-case, wrong-length, and non-hexadecimal forms.
- Status and lifecycle events persist atomically; eventless and oversized transition events leave both unchanged.
- Hash-chain validation rejects non-recomputed payload modification, interior deletion, permutation, and cross-job graft; approval decisions require a chained digest. No evidence claims detection of a rewrite that recomputes the chain, which the unkeyed construction cannot provide.
- A coordinated approval-tail rollback that consistently rewrites the event list, count, and head cannot release the digest: subsequent reuse is refused by the separate monotonic authority.
- Debug output carries no durable-root sentinel, and payload, batch, snapshot, and page overflows refuse without durable mutation or rewrite.
- Focused library tests, all-target clippy, and crate formatting pass.
Alternatives considered #
Keep jobs in route-local memory #
Rejected. Restart would erase idempotency, status, and resume cursors, making ADR-117's replay contract false.
Add HTTP and persistence in one wave #
Rejected. It would combine storage semantics, authentication order, path confinement, execution composition, and transport behavior in one trust-boundary change before the shared execution authority is integrated.
Use caller keys or job ids as filesystem names #
Rejected. It would turn wire input into traversal surface and make directory layout part of the public contract. Keys and ids remain data in one held store.
Recover malformed state best-effort #
Rejected. Dropping a damaged key binding or event suffix can authorize a duplicate execution. Corruption therefore fails closed.
Put another head or approval set beside `state.json` #
Rejected. A sidecar that the same actor can roll back together with the snapshot only relocates the false claim. Approval one-shot history must be owned by an authority with an independent retention boundary; otherwise the serve adapter remains fail-closed.
Related #
- ADR-117 — authenticates and confines the future network projection.
- ADR-118 — admits the descriptor-rooted ARM custody precedent.
docs/crate-specs/nika-serve.md— W05 public API and gate ledger.
read at v0.114.0 · the decision record ships with the engine