The affirmation store’s write face
The affirmation store is the only component that writes into a case. This page describes the writing half of it — what a case looks like on disk, what one write does, and which of its behaviours are guarantees rather than conveniences. The reading half, which presents a case as a record source, has its own page: The affirmation store’s read face.
One store, one root
A store is constructed over a write root and owns everything below it:
from pathlib import Path
from affirmatrix.case import AffirmationStore
store = AffirmationStore(root=Path("case"))
store.initialize()
The root is always a parameter and never a default. Relocating the whole store — for a dry run, a comparison, or an inspection in continuous integration — is passing a different path and nothing else, and the store never learns which of the two it is writing. That is what keeps the write policy of ADR-0008 (writes land in place; an output directory relocates the root) a decision made above this component rather than inside it.
Nothing is read or created when the store is constructed. The ordinary case is a
root that does not exist yet, and bringing it into being is the job: every write
first creates the layout and seeds whatever self-describing files are missing.
initialize() is that step on its own, for a case with no records in it yet.
What a case looks like
case/
context.jsonld
schema/*.json
nodes/{requirements,implementations,test_specifications,test_outcomes,waivers}.jsonld
edges/{refines,verifies,implements,confirms,witnesses,excuses,calls}.jsonld
events/review_events.jsonld
proofs/{snapshotId}/*.jsonld
The mapping from a record kind to the document that holds it is written out explicitly rather than derived from the kind’s spelling, and the suite checks its keys against the taxonomy. A derived rule would let a newly declared kind acquire a home by accident; this way, giving it one is a deliberate edit.
Documents are created when something is written to them. A kind nothing produces yet — a waiver, a call — has a name and a schema but no empty file.
The case stores no test evidence. The documents test_outcomes, confirms,
witnesses and excuses stay in the layout and keep their schemas, because
a case written before this rule holds them and the read face must still read
them. The store never writes to them: see the next section but one.
A case describes itself
The package carries the schemas and the shared JSON-LD context as data, and a fresh root is seeded with them. Thereafter the store validates against the copy in the case, not the copy in the package, so the tool and someone auditing the directory with ordinary tooling reach the same verdict by construction.
A write never touches a schema file the case already has — it neither
compares nor refreshes it. Replacing a case’s schemas is a store act the tool
takes only on request, through case refresh, which reports what differed.
Not writing over it also means a write touches nothing the write was not about,
which matters because the review surface for the whole store is a maintainer
reading the diff.
Instance documents reference the context relatively (../context.jsonld), so
a case is readable offline and survives being moved as a whole.
One write, one document per kind
The unit of atomicity is the whole collection document. A write is batch-shaped for that reason — one call rewrites one document per kind it touches:
store.write_nodes(node_records)
store.write_edges(edge_records)
store.write_records(node_records, edge_records)
store.append_review_events(events)
write_records writes nodes and edges together. It validates both batches and
checks the demotions before it rewrites the first document, so one refusal
leaves every document as it was (SEG-SREQ-212). Calling write_nodes
and then write_edges cannot promise that, because the nodes would already
be written when the edges are refused. case sync uses write_records.
A rewrite reads what the document already holds, replaces the entries this call produced, keeps every other entry exactly as it was, sorts by identifier and renames the result into place. Three properties follow, and each is load bearing:
Nothing disappears by omission (SEG-SREQ-023). A producer that yields a short stream — because a source was unreadable, or a filter was too narrow — rewrites a smaller set of records, not a smaller case. Removal is a separate operation that names what it removes, and refuses a record the case does not hold, because “retired” and “never written” are different and somebody is relying on which.
A document is a function of what it holds, not of the order a producer streamed it in. Two runs over an unchanged graph leave byte-identical files, so a diff shows real change only.
A record appears when its document does (SEG-SREQ-022). The new contents are written to a temporary file in the target’s own directory, flushed to the device, and renamed over the target. The directory matters: a rename is atomic only within one filesystem, and a temporary file elsewhere would degrade it into a copy and reintroduce the half-written document. On any failure the temporary file is removed, so an interrupted run leaves neither a partial document nor a stray file.
No test evidence in the case
A write that names a test outcome node, or an edge of kind Confirms,
Witnesses or Excuses, is refused before any byte is written
(SEG-SREQ-227). The refusal names the record and says that test
evidence is built from run bundles when a verdict is made. This holds for every
write method, and a batch that mixes an evidence record with others is refused
whole: the other records are not written. Waiver nodes are not evidence in this
sense and are written as before.
The store can still remove such records. case sync reads what a case from
before this rule holds and removes it with the ordinary removal operations,
after it has written the derived stream (SEG-SREQ-228; see
Command line and configuration).
Affirmations are appended, never rewritten
Review events are added after the ones already recorded and are never re-numbered (SEG-SREQ-033). A review event carries nothing that identifies it — the same edge may be affirmed more than once — so its identity is its position in the case’s sequence of events, which is available precisely because events are only ever appended.
An edge’s state and the hash it was affirmed against change only when that edge is named in a write. Writing a different edge, or any node document, leaves an affirmed edge exactly as it was.
Affirmed standing is never lost by omission (SEG-SREQ-051). A record that would replace an incumbent carrying its affirmed hash with one carrying none is a demotion, and the write is refused unless a demotion request names that edge:
from affirmatrix.records import EdgeReference
store.write_edges(
current_edges,
demote={EdgeReference(kind="Implements", from_id=..., to_id=...)},
)
The record stream is never itself the request — a current stream, every edge pending, cannot reset what the case says was affirmed — and naming is per edge, never a flag over the write. A name that licenses nothing is refused too, and either disagreement refuses the whole batch with nothing written, as validation refusals already do. Replacing an affirmed hash with a different one, or moving an edge to an outdated state that keeps its hash, is not a demotion: the guard watches the loss of affirmed standing, not its movement.
Validation, and what it is against
Every entry is validated before any document is opened (SEG-SREQ-019) — the whole batch, not record by record, so a batch containing one invalid record writes nothing at all. A refusal names the record, the schema and the field, because a batch is the usual size of a write and “something was invalid” is no help.
The schemas are draft 2020-12, one per record kind. The node schemas pin the
content-hash field names each kind carries, and beside every digest they require
its source location — repository, path and span locator, per hash
(SEG-SREQ-050) — with the locator’s format pinned per kind: a requirement is
located by its need identifier, an implementation or test specification by
symbol and span role, a test outcome by its identity within the run artifact,
and any of them by file where the digest covers a whole file. A location is
a reference for transient fetch, never integrity data — no part of it enters a
hash preimage, so moving content is a reviewable diff, not a suspicion event.
The edge schemas compose from shared definitions and state, as the record types
do, that a pending edge has no hash it was affirmed against and an active one
must have. Stating the rule in both places is deliberate: the record type guards
the producer, the schema guards the document somebody reads without our code.
Every node schema also declares one optional property, seg:extractedFrom
(ADR-0016): an object from a repository name to a revision, each value 40 or 64
lowercase hexadecimal characters. The store writes the property only when the
record carries at least one revision. A record with none gets no property and
never an empty object (SEG-SREQ-326), so a record written before the
property reads back as written (SEG-SREQ-305). A value of another shape
fails validation, and so the whole write is refused (SEG-SREQ-325).
Nothing in the property enters a hash, and the store runs no git: it keeps the
map as the caller gives it.
Every schema forbids the properties it does not declare, and every digest field is pinned to sixty-four lowercase hexadecimal characters. That is the structural half of “covered content is never persisted” (SEG-SREQ-018): there is no field for content to travel in, and no digest field it could be smuggled through. The other half is the API, which takes record types and never text.
A case’s schemas are read at the moment of the write rather than cached for the process. They are data on disk that anything may have changed since the last write, and a validator held over from an earlier one would be judging records against rules the case no longer states.
Every path stays under the root
Each path the store opens is assembled in one place and passes one check (SEG-SREQ-021): segment rules that refuse an empty name, a parent reference, an embedded separator, a leading dot, and any character that is not valid in a file name on both POSIX and Windows filesystems — control characters included; then resolution of both the candidate and the root, requiring the first to lie under the second. The resolution is what catches the case no lexical rule can see, where a directory along the path is a symbolic link out of the case. The character rule is defense in depth for a published case being checkable out on either platform; the primary guard is the proof generator’s own minting rule (SEG-SREQ-052).
The only caller-supplied path segment the store ever takes is the snapshot identifier of an evidence package, which is why the rule is written for it.
Identifiers
Absolute IRIs are minted at serialization time and only there (ADR-0007). A node
is {base}/node/{localId}; an edge is {base}/edge/{kind}/{from}/{to}, with
every segment percent-encoded. There is no kind segment on a node identifier,
and there cannot be: an edge record carries its endpoints’ identifiers and not
their kinds, and a broken edge’s endpoint is by definition no longer in the graph
to be asked — yet such an edge must still be persistable.
Minted identifiers are opaque keys. An edge record writes its endpoints as fields of their own, and nothing reads them back out of an identifier.
What is not here yet
The evidence package’s document types. The write path under
proofs/{snapshotId}/ is complete — validated, atomic, root-confined — but
the four document types and their schemas arrive with the generator that
computes them. Until then the store refuses every proof document kind, because
a document with no schema cannot be shown to be valid, and writing it anyway
would put an unvalidated file in a case that claims all of them are.
Version control. The store runs no git (ADR-0008, ADR-0010). It writes files. A maintainer reviews the working tree and records it, and that commit is what turns a proposed affirmation into an affirmed one (ADR-0009). The command line can read the history of those commits, and never writes it (ADR-0015).