Skip to content

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.

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.

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:

  1. exclusive and shared advisory file locks released by the operating system when a process exits;
  2. same-directory atomic file creation and replacement;
  3. file data-and-metadata flush;
  4. a platform-native namespace durability barrier for every changed entry; and
  5. stable file identity while an open handle is locked.

The supported implementations are:

  • POSIX local filesystems providing fcntl/flock, same-filesystem rename(2), file fsync(2), and directory fsync(2);
  • Windows fixed writable local NTFS volumes providing LockFileEx, file FlushFileBuffers, and FILE_FLAG_WRITE_THROUGH staging handles renamed via SetFileInformationByHandle / FILE_RENAME_INFO. ReplaceIfExists = FALSE supplies atomic first creation and TRUE supplies 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.

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\n

CURRENT 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:

format
format_version
generation_uuid
parent_generation_uuid
transaction_uuid
capabilities
participants

capabilities are sorted by stable capability ID. participants are sorted by (capability_id, record_family_id, relative_path). Every participant entry contains, in order:

capability_id
capability_version
record_family_id
record_version
relative_path
encoding
byte_length
row_count
schema_fingerprint
content_sha256

Paths 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.

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/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:

  1. reads and validates CURRENT;
  2. opens that generation’s lease.lock without following links;
  3. acquires a shared OS lock;
  4. rereads CURRENT and verifies that the generation directory has not moved;
  5. validates the manifest digest and requested participants; and
  6. 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.

The journal state names are:

PREPARING -> STAGED -> VALIDATED -> DURABLE -> PUBLISHED
| | | |
+-----------+----------+----------+----> ABORTED

The 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:

  1. Resolve parent. Pin and fully validate CURRENT, if present. An absent CURRENT is allowed only for an uninitialized v1 container.
  2. PREPARING. Create a transaction UUID and a private attempt directory; persist a PREPARING journal containing only IDs, phases, and relative participant names.
  3. 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 STAGED journal.
  4. VALIDATED. Run domain-local validation, build the combined parent-plus- staged UUID index, run composite cross-domain validation, and persist the VALIDATED journal. No validation may read through CURRENT again.
  5. DURABLE. Write and flush lease.lock, write and flush manifest.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 into generations/<generation-uuid>/, complete the barriers for both changed parents and generations/, then persist the DURABLE journal.
  6. Publish. Write the exact new CURRENT bytes to a sibling private file, flush it, atomically replace CURRENT (or atomically create it for the first generation), and complete the project-root platform-native namespace durability barrier.
  7. PUBLISHED. Persist the PUBLISHED journal. The writer may now release the parent reader lease, perform conservative cleanup, and release writer.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.

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 CURRENT while still holding writer.lock; the returned error includes committed: false or committed: 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.

Open always resolves authority first:

  1. verify the exact FORMAT bytes;
  2. read and validate CURRENT, or return GF_PROJECT_UNINITIALIZED when it is absent;
  3. acquire the selected generation’s shared lease;
  4. 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.

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:

  1. acquires writer.lock;
  2. resolves and validates CURRENT;
  3. acquires the candidate lease.lock exclusively without waiting;
  4. recomputes reachability;
  5. atomically moves the candidate into trash/;
  6. completes platform-native namespace barriers for generations/ and trash/; and
  7. 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.

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.

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.

  • 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.

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.
  • 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.