ADR 0013: Durable v0.5 project-generation protocol
Status: Accepted Date: 2026-07-24 Build target: v0.5.0
Related: ADR 0012 (domain ownership), ADR 0018 (acknowledged durability and isolation), ADR 0019 (authoritative graph delta journal), ADR 0020 (NTFS write-through namespace durability amendment)
The public acknowledgement boundary, isolation honesty rules, and anomaly
coverage matrix are frozen by
ADR 0018. Authoritative small-write
graph delta runs inside a generation-owned graph/ tree are frozen by
ADR 0019; they remain ordinary
inventory-listed generation bytes and never replace CURRENT as commit
authority. This ADR remains the normative publication protocol; semantic
changes to acknowledgement or recovery authority require an amending ADR rather
than silent edits here. ADR 0020
explicitly amends the Windows filesystem and acknowledgement clauses below.
Context
Section titled “Context”GraphForge currently rewrites fixed Parquet paths with sequential renames. A rename failure can expose a committed prefix, the topology counter is advanced separately from the data it describes, and Windows directory durability is not implemented. That mechanism cannot publish graph, provenance, and knowledge participants as one crash-safe project state.
GraphForge is pre-v1. This protocol is the first supported project format. It does not recognize, migrate, import, or synthesize a generation-zero view of any earlier development layout.
Decision
Section titled “Decision”Scope and filesystem support
Section titled “Scope and filesystem support”The protocol is for a local filesystem, one commit writer, any number of readers, and optionally multiple private optimistic staging attempts. The implementation must preflight these primitives before creating or mutating a project:
- exclusive and shared advisory file locks released by the operating system when a process exits;
- same-directory atomic file creation and replacement;
- file data-and-metadata flush;
- a platform-native namespace durability barrier for every changed entry; and
- stable file identity while an open handle is locked.
The supported implementations are:
- POSIX local filesystems providing
fcntl/flock, same-filesystemrename(2), filefsync(2), and directoryfsync(2); - Windows fixed writable local NTFS volumes providing
LockFileEx, fileFlushFileBuffers, andFILE_FLAG_WRITE_THROUGHstaging handles renamed viaSetFileInformationByHandle/FILE_RENAME_INFO.ReplaceIfExists = FALSEsupplies atomic first creation andTRUEsupplies atomic replacement. ReFS is unsupported/unproven.
The platform-native namespace durability barrier is POSIX directory fsync(2)
or the NTFS write-through handle rename specified by ADR 0020. Windows
directory FlushFileBuffers and ReplaceFileW are not durability authority.
The implementation identifies the filesystem containing the project parent and
runs a create-lock-flush-replace-flush probe in a private sibling under that
parent. The project parent may be a persistent volume mounted beneath a
different container or VM root filesystem. The project root and its durable
contents must remain on that one admitted filesystem. Network, userspace,
removable, or unknown project filesystems are rejected unless explicitly added
to the tested allowlist. Missing semantics return
GF_UNSUPPORTED_FILESYSTEM before the project root or CURRENT is changed.
There is no best-effort mode.
On-disk layout
Section titled “On-disk layout”All machine paths use lowercase ASCII letters, digits, hyphens, and underscores. UUIDs are lowercase canonical hyphenated strings. SHA-256 values are 64 lowercase hexadecimal characters. No caller-controlled name enters a path.
project/├── FORMAT├── CURRENT├── locks/│ ├── writer.lock│ └── transactions/│ └── <transaction-uuid>.lock├── transactions/│ └── <transaction-uuid>.json├── attempts/│ └── <transaction-uuid>/<request-sha256>/│ └── participants/├── generations/│ └── <generation-uuid>/│ ├── lease.lock│ ├── manifest.json│ ├── graph/ # optional generation-owned graph workspace│ │ └── deltas/ # optional authoritative delta runs (ADR 0019)│ └── participants/│ └── <capability-id>/<record-family-id>.<parquet|arrow|json>├── cache/ # derived, fingerprint-keyed, never authoritative└── trash/ └── <generation-uuid>/When a generation includes authoritative graph delta runs, those runs live only
under that generation’s graph/deltas/ tree and are listed in the graph/files
inventory with exact digests. Replay reconstructs graph state from the verified
base plus ordered runs; recovery never elects a run by directory scan (ADR 0019).
FORMAT is immutable and contains exactly:
graphforge-project/v1\nCURRENT is the sole commit authority. It is canonical UTF-8 JSON followed by
one LF, with keys in this exact order and no insignificant whitespace:
{"format":"graphforge-project","format_version":1,"generation_uuid":"019...","generation_manifest_sha256":"<64 lowercase hex>"}manifest.json is canonical JSON followed by one LF. Its fixed top-level key
order is:
formatformat_versiongeneration_uuidparent_generation_uuidtransaction_uuidcapabilitiesparticipantscapabilities are sorted by stable capability ID. participants are sorted
by (capability_id, record_family_id, relative_path). Every participant entry
contains, in order:
capability_idcapability_versionrecord_family_idrecord_versionrelative_pathencodingbyte_lengthrow_countschema_fingerprintcontent_sha256Paths are normalized relative paths beneath that generation’s participants/
directory. Absolute paths, .., symlinks, junctions, reparse points, hard
links, or a resolved path outside the generation are invalid. content_sha256
is over exact persisted bytes. generation_manifest_sha256 is the integrity
digest over the exact bytes of manifest.json, including its final LF.
Semantic record, schema, descriptor, projection, and result bytes follow the
canonical fingerprint v1
contract; the manifest field
order and raw integrity digests above remain frozen by this ADR.
Every generation is a complete immutable project snapshot. An unchanged
participant may be copied from the pinned parent while staging, but the
published generation never depends on mutable data outside its own directory.
Derived caches may remain under root cache/ only when they are keyed by the
canonical source fingerprint and their absence or corruption cannot change
results.
Durable mutable-topology rewrite transaction
Section titled “Durable mutable-topology rewrite transaction”The graph workspace still contains mutable topology, property, and search files
while a complete project generation is being constructed. A rewrite of more
than one such file uses one authenticated, generation-last transaction. This
transaction is subordinate to project-generation publication: its
topology/generation.json record selects one internally consistent graph
workspace state, but only CURRENT can publish that workspace as a project
generation.
The transaction retains the admitted project-root directory identity and an
open, exclusively locked .graphforge-rewrite.lock handle. After locking it
revalidates the named lock, its single-link invariant, the admitted root, and
every opened descendant directory. The lock is held from recovery and
prior-generation selection through installation, the generation-authority
switch, namespace barriers, and journal removal. Prior and next topology/search
generations are therefore derived inside the same critical section; standalone
counter changes use this authority rather than racing a rewrite.
Each destination is a canonical UTF-8 project-relative path containing only normal components. Caller paths, absolute paths, traversal, duplicate destinations or temporaries, and the rewrite journal/lock names are rejected. Before intent publication, each staged file is flushed and bound to:
- its exact byte length and SHA-256 digest;
- its retained parent-directory volume and file identity; and
- its temporary basename, volume, and file identity.
The authenticated journal also binds the project-root identity, transaction identifier, exact prior and next generation pair, entry class, and a checksum over every recovery control. It is limited to 16,384 entries and 8 MiB; the encoded generation authority is limited to 4 KiB. Work above those limits fails before authority changes.
Intent publication has two durable states. preparing permits only
identity-matched temporary cleanup if retaining all staged names was
interrupted. Once every staged file has been deliberately retained, durable
means recovery must roll forward. Recovery authenticates the journal and root,
accepts only the exact prior or next generation pair, then installs and verifies
all data destinations descriptor-relatively. A destination already containing
the authenticated bytes is an idempotent completed install; otherwise the
exact authenticated temporary must still exist. Missing, substituted,
truncated, cross-root, or ambiguous state fails closed and preserves evidence;
the named rewrite lock additionally requires one link.
Exactly one entry is the generation authority and its destination is
topology/generation.json. Its bounded JSON bytes must encode the journal’s
exact next topology/search pair. It is installed only after every data entry and
after the retained root and rewrite-lock identities are revalidated. Directory
namespace barriers follow each installation. The journal is removed and the
root namespace made durable only after the authority switch. Thus a crash
before durable intent leaves the prior generation; a crash at or after durable
intent deterministically rolls forward; and repeated recovery neither
duplicates rows nor elects state by scanning files.
An auxiliary storage participant may join the transaction with one typed receipt. The receipt names one exact staged data destination and binds its schema kind/version, byte length, and SHA-256 digest; recovery verifies that binding before installation. The #931 authenticated UUID-to-surrogate index must use this hook so its manifest/run receipt and topology shards advance under the same generation-last authority. Auxiliary metadata alone, an unlisted receipt, or a digest that differs from the staged entry is not participation and fails closed.
Container creation
Section titled “Container creation”An absent path, or an explicitly supplied empty directory, may become a new
container. Before the final root, FORMAT, generations/, CURRENT, or any
project lock is created or changed, GraphForge creates or opens a deterministic
parent-sibling lifecycle lock, flushes its file and parent namespace, and takes
its exclusive kernel lock. The lock name is a domain-separated SHA-256 over the
canonical parent and final target component; it contains no caller text. The
lock file is a persistent rendezvous inode and is never unlinked after unlock.
While holding that lock GraphForge performs the filesystem preflight. Only a
successful preflight may create an absent final root. Creation flushes the new
root entry, writes and flushes FORMAT, and completes platform-native namespace
barriers for each created entry bottom-up. A process death before CURRENT
may leave only the persistent sibling lock and a bounded empty or resumable
uninitialized root; the next admitted opener validates that exact shape and
resumes it. CURRENT does not exist until one complete first generation is
durable. Concurrent first openers therefore initialize exactly once and all
select the same committed generation.
Existing projects pass through the same lifecycle admission without changing
the selected CURRENT generation. The retained parent/root identities are
revalidated before project-lock mutation. Optimistic stagers may release the
lifecycle lock after retaining those identities, but must reacquire admission
and prove the same root identity before taking the commit writer lock. The
lifecycle lock remains ordered before project writer/checkpoint/recovery locks.
Such a container is uninitialized, not generation zero. Project APIs return
GF_PROJECT_UNINITIALIZED until the first complete generation is published.
An existing non-empty directory without the exact FORMAT bytes returns
GF_UNSUPPORTED_PROJECT_FORMAT before mutation. A different or future format
version returns the same code.
Locks and ownership
Section titled “Locks and ownership”locks/writer.lock is held exclusively for the complete transaction in the
default single-writer protocol, including cleanup after publication. An
optimistic attempt instead holds its transaction-scoped kernel lock while it
prepares and validates in attempts/; it acquires writer.lock only for base
comparison, promotion, publication, and cleanup. The kernel locks are
authoritative.
Metadata written after acquisition may contain an owner UUID, process ID,
hostname hash, operation, and acquisition time for diagnostics only. PID,
hostname, file age, heartbeat age, or wall-clock time never permits lock
stealing. A dead process is proved only when the operating system releases its
lock.
Only one live attempt may own a transaction UUID. Different transaction UUIDs may stage concurrently. Transaction lock files carry no authority once their OS lock is released and are never reclaimed using PID, age, or heartbeat heuristics.
The default writer-lock acquisition is non-blocking and returns
GF_WRITER_BUSY.
Callers may supply a finite timeout measured by a monotonic clock; expiry
returns the same code without mutation. Infinite waits are not exposed by a
public API. A validated optimistic attempt is the exception inside the storage
protocol: it waits for the short commit lock, compares its pinned parent with
CURRENT, and returns GF_WRITE_CONFLICT when another commit won. This wait is
not a caller-configurable lock API and cannot expose partial state.
Each generation has lease.lock. A reader:
- reads and validates
CURRENT; - opens that generation’s
lease.lockwithout following links; - acquires a shared OS lock;
- rereads
CURRENTand verifies that the generation directory has not moved; - validates the manifest digest and requested participants; and
- retains the lock and resolved generation path for the snapshot lifetime.
If step 4 observes a concurrent cleanup, the reader releases the handle and
retries from step 1. It never follows a changed CURRENT after pinning. A new
reader resolves the new generation; an existing reader continues to see its
pinned generation.
Cleanup must acquire the same lease.lock exclusively and then recheck
reachability before moving a generation. OS lock release, not a timeout, proves
that a crashed reader is gone.
Publication state machine
Section titled “Publication state machine”The journal state names are:
PREPARING -> STAGED -> VALIDATED -> DURABLE -> PUBLISHED | | | | +-----------+----------+----------+----> ABORTEDThe journal aids cleanup and diagnostics but never selects a generation. Journal replacement itself uses write, file flush, atomic replace, and the platform-native namespace durability barrier. Its failure can leave an earlier valid journal state without changing commit authority.
The default writer performs these ordered operations while holding
writer.lock. An optimistic writer performs steps 1 through 4 under its
transaction lock, then acquires writer.lock, resolves CURRENT again, and
continues only when it still names the pinned parent:
- Resolve parent. Pin and fully validate
CURRENT, if present. An absentCURRENTis allowed only for an uninitialized v1 container. - PREPARING. Create a transaction UUID and a private attempt directory;
persist a
PREPARINGjournal containing only IDs, phases, and relative participant names. - STAGED. Write every participant to its final path in the private
generation. Close the format writer, flush each file, verify byte length and
SHA-256 from a fresh read, then persist the
STAGEDjournal. - VALIDATED. Run domain-local validation, build the combined parent-plus-
staged UUID index, run composite cross-domain validation, and persist the
VALIDATEDjournal. No validation may read throughCURRENTagain. - DURABLE. Write and flush
lease.lock, write and flushmanifest.json, reread and verify every participant and the manifest, complete platform-native namespace durability barriers from leaves upward, promote an optimistic attempt by atomic rename intogenerations/<generation-uuid>/, complete the barriers for both changed parents andgenerations/, then persist theDURABLEjournal. - Publish. Write the exact new
CURRENTbytes to a sibling private file, flush it, atomically replaceCURRENT(or atomically create it for the first generation), and complete the project-root platform-native namespace durability barrier. - PUBLISHED. Persist the
PUBLISHEDjournal. The writer may now release the parent reader lease, perform conservative cleanup, and releasewriter.lock.
The successful atomic replacement/creation of CURRENT in step 6 is the sole
linearization point. Before it, every reader resolves the parent. After it,
every new reader resolves the new generation. A journal state, directory scan,
newer UUID, timestamp, or highest counter can never override CURRENT.
If the optimistic base comparison finds a different committed parent, the
attempt is marked ABORTED, its private directory is removed, and
GF_WRITE_CONFLICT is returned without promoting a generation. Domain-level
rebase policy belongs above this storage protocol.
The project-root platform-native namespace durability barrier is required to
make the pointer replacement durable against power loss. On POSIX this is the
root directory fsync(2). On Windows NTFS it is the CURRENT rename through
the flushed FILE_FLAG_WRITE_THROUGH staging handle; no directory-handle flush
is claimed. If the process stops before the barrier completes, reopen uses
whichever complete CURRENT the filesystem presents and never infers intent
from the journal.
Validation and failure behavior
Section titled “Validation and failure behavior”Publication is fail-closed:
- a write, flush, checksum, validation, lock, or replacement failure before the linearization point leaves the parent authoritative;
- failure after the linearization point cannot roll back the visible generation;
- after any replacement API error, the writer rereads and validates
CURRENTwhile still holdingwriter.lock; the returned error includescommitted: falseorcommitted: true, never an unresolved third state; - all error envelopes contain a stable code, transaction UUID, generation UUID, phase, and safe cause class, never participant values.
CURRENT must parse exactly, name an existing generation, and match that
generation’s manifest digest. A malformed pointer, missing generation, digest
mismatch, symlink, or invalid requested participant returns
GF_PROJECT_CORRUPT. Recovery does not scan for a plausible replacement and
does not fall back to a previous generation.
Domain-local and cross-domain reference validation follow ADR 0012. References
to graph, provenance, or knowledge rows staged in the same transaction resolve
against the combined pinned-parent and staged indexes. A dangling or ambiguous
reference fails before DURABLE.
Recovery
Section titled “Recovery”Open always resolves authority first:
- verify the exact
FORMATbytes; - read and validate
CURRENT, or returnGF_PROJECT_UNINITIALIZEDwhen it is absent; - acquire the selected generation’s shared lease;
- verify the manifest digest and only the capabilities requested by the API.
With the writer lock held, recovery classifies journals mechanically:
Before classifying a journal, recovery also acquires its transaction lock
without waiting. A busy transaction lock proves that the private attempt is
live, so recovery leaves that journal and attempt untouched. Because recovery
already owns writer.lock, that attempt cannot cross the commit boundary
during classification.
| Observed state | Classification | Action |
|---|---|---|
CURRENT names the journal generation with matching digest |
committed | finish/repair journal to PUBLISHED; retain generation |
CURRENT names another valid generation |
uncommitted | mark ABORTED; generation is cleanup-eligible |
no CURRENT in an uninitialized v1 container |
uncommitted | mark ABORTED; generation is cleanup-eligible |
CURRENT is invalid or references invalid bytes |
corrupt | return GF_PROJECT_CORRUPT; do not guess or delete evidence |
journal is missing, truncated, or stale while CURRENT is valid |
advisory damage | reconstruct safe cleanup facts from CURRENT; never change authority |
Recovery is idempotent. Repeating it produces the same selected generation and does not rewrite participant or manifest bytes.
Retention and garbage collection
Section titled “Retention and garbage collection”Graph inventory participants may use the compact version-2 authenticated root.
Its immutable payloads and canonical Patricia/radix manifest nodes live in the project
content-addressed object store, while the generation stores only the root
digest and logical totals. Publication resolves the entire bounded manifest
and verifies all payload digests before the atomic CURRENT transition.
Interrupted installation therefore leaves the prior generation authoritative;
an acknowledged transition survives reopen without copying unchanged objects.
Object collection runs only after generation reachability is established. It marks roots from every remaining generation, including checkpoint-retained and live-lease-retained generations, and defers while a publication attempt or object-installation lease is active. Marking and validation finish before any object deletion. Malformed, cyclic, oversized, duplicate-reference, traversal, and wrong-digest manifests fail closed and do not grant deletion authority.
Manifest node version 2 compresses every maximal shared lowercase-hex SHA-256
path prefix. A branch consumes its compressed prefix and one child nibble and
has at least two children; unary branches are noncanonical. A leaf consumes the
complete remaining digest and retains the canonically path-ordered collision
bucket. The empty inventory alone uses one empty root branch. Consequently a
nonempty inventory with F distinct path digests has at most 2F - 1 manifest
nodes (the empty inventory has one), and resolution work is linear in the live
inventory. Old node-v1, mixed-version, malformed-prefix, wrong-route, cyclic,
duplicate-reference, and noncanonical trees fail closed. Migration from an
expanded graph-files-v1 inventory writes node-v2 objects only.
The participant descriptor is paired exactly with its payload: record version
1 admits only an expanded v1 inventory and record version 2 admits only this
Patricia root. Checkpoint revert retains the same representation, uses the CAS
publication lease for v2, and never synthesizes a generation graph tree. Revert
acquires the CAS lifecycle guard before the project writer and checkpoint locks,
matching retention’s global CAS -> writer -> checkpoint order.
CAS publication, reads, materialization, and collection retain directory and
file capabilities and address children relative to those capabilities. The
named project, object root, digest buckets, lifecycle lock, and complete object
closure are revalidated immediately before CURRENT, which is replaced
relative to the retained project directory. This coordinates concurrent
GraphForge processes and fails closed when namespace substitution is detected.
The operational boundary does not claim atomic protection against an actively
malicious same-identity process racing the final Unix namespace syscall;
deployments requiring that adversarial isolation must use OS ownership or
sandbox boundaries. Identity-conditional Unix cleanup likewise runs only under
GraphForge’s cooperative exclusive lifecycle guard.
The current generation is always reachable. The default retention set also
contains its two most recent valid ancestors, determined only from verified
parent_generation_uuid links. A configured larger finite retention count may
add ancestors but may not remove the current generation.
For every other generation, cleanup:
- acquires
writer.lock; - resolves and validates
CURRENT; - acquires the candidate
lease.lockexclusively without waiting; - recomputes reachability;
- atomically moves the candidate into
trash/; - completes platform-native namespace barriers for
generations/andtrash/; and - deletes the trash entry and completes the
trash/barrier.
A busy lease skips the candidate. A crash before the move leaves it intact; a
crash after the move makes it invisible to readers and eligible for deletion
on the next recovery. Cleanup never changes CURRENT. Unknown directories,
symlinks, or invalid manifests are quarantined and reported, not traversed or
deleted automatically.
Named failpoints and required outcomes
Section titled “Named failpoints and required outcomes”Each failpoint fires once and terminates the writer as if the process stopped. The names are public test vocabulary:
| Failpoint | Required authority on reopen |
|---|---|
project.after_format_fsync |
no installed project, or uninitialized v1 container |
project.after_container_dir_fsync |
uninitialized v1 container |
project.after_writer_lock |
parent / uninitialized |
project.after_journal_preparing |
parent / uninitialized |
project.after_participant_write |
parent / uninitialized |
project.after_participant_fsync |
parent / uninitialized |
project.after_participant_dir_fsync |
parent / uninitialized |
project.after_journal_staged |
parent / uninitialized |
project.after_domain_validation |
parent / uninitialized |
project.after_composite_validation |
parent / uninitialized |
project.after_journal_validated |
parent / uninitialized |
project.after_manifest_write |
parent / uninitialized |
project.after_manifest_fsync |
parent / uninitialized |
project.after_generation_dir_fsync |
parent / uninitialized |
project.after_optimistic_commit_lock |
parent / uninitialized |
project.after_optimistic_promotion |
parent / uninitialized |
project.after_journal_durable |
parent / uninitialized |
project.after_current_temp_write |
parent / uninitialized |
project.after_current_temp_fsync |
parent / uninitialized |
project.before_current_replace |
parent / uninitialized |
project.after_current_replace |
new generation |
project.after_root_fsync |
new generation |
project.after_journal_published |
new generation |
project.after_gc_move |
current unchanged; candidate absent from generations |
project.after_gc_delete |
current unchanged; candidate absent from generations |
The implementation may parameterize participant failpoints with an index, but
the stable base name and outcome do not change. Each pre-publication failpoint
also has an .error variant that makes that operation return its platform
error instead of terminating; it must return committed: false. The
project.after_current_replace.error variant must return committed: true.
Tests must exercise every row and every error variant for first publication and
replacement publication on each supported operating system. Tests assert
selected UUID, manifest digest, participant checksums, and row visibility; they
do not accept log inspection or timing as evidence.
Observability and privacy
Section titled “Observability and privacy”Structured events may include operation, phase, transaction/generation/parent UUIDs, lock-owner UUID, capability and record-family IDs, counts, duration, safe filesystem class, and recovery classification. They must not include graph properties, assertion/evidence/reasoning text, vector contents, credentials, user-controlled absolute paths, hostnames, or raw lock metadata.
New files inherit a project-private permission policy. A project open rejects symlinks and reparse points at every machine-owned component, opens files without following links where the platform supports it, and revalidates file identity after locking to prevent path substitution.
Consequences
Section titled “Consequences”- One pointer replacement publishes every project participant together.
- A failed multi-file write cannot expose a committed prefix.
- Readers have repeatable snapshots without blocking publication.
- Crashed readers and writers need no wall-clock stale-owner heuristic.
- Recovery is deterministic because it never elects a generation.
- Windows NTFS and POSIX implementations must meet their documented platform-native barriers or fail before mutation; ReFS is unsupported.
- Multi-file topology rewrites and standalone topology/search generation changes share the authenticated generation-last rewrite transaction; they do not expose a committed prefix or a counter that names partial bytes.
The cost is duplicate immutable snapshot data until later content-addressed deduplication. Correctness and a finite recovery proof take precedence.
Rejected alternatives
Section titled “Rejected alternatives”| Alternative | Reason |
|---|---|
| Sequentially rename participant files | Exposes a committed prefix after interruption. |
| Treat a topology counter as commit authority | Does not identify a complete cross-domain snapshot. |
| Pick the newest valid directory during recovery | Makes a directory scan an implicit election protocol. |
| Steal locks by PID, age, or heartbeat | PID reuse and clock behavior cannot prove the owner is dead. |
| Delete old generations after a fixed delay | Timing cannot prove that a reader released its snapshot. |
| Keep one mutable generation directory | Cannot give old and new readers stable concurrent views. |
| Accept unknown/network filesystems best-effort | Their replacement and flush semantics are not sufficient evidence. |
| Add a pre-v1 importer or generation zero | GraphForge has no backward-compatibility requirement before v1. |
Required verification
Section titled “Required verification”- implements the state machine and stable error envelope.
- implements every named failpoint for first and later publication.
- repeats the crash/reopen matrix through Rust, Python, and Node package surfaces and proves graph/knowledge isolation.
- CI runs the platform matrix on Linux, macOS, and Windows local filesystems.