Storage Layout

StatusDraft, stable from v0.1.0
Version1

This document specifies the on-disk layout pugmark uses — i.e. the set of keys it writes to and reads from an object-storage bucket, the format of those objects, and the transactional guarantees that hold across them.

The spec is the authoritative source. Any conformant implementation, in any language, must satisfy the rules below; behavioural differences between an implementation and this document are bugs in the implementation.

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.

The handler-side protocol that drives writes into this layout is defined in a companion document, protocol.md.


Table of Contents

  1. Overview
  2. Storage backend requirements
  3. Object addressing
  4. Session identifiers and paths
  5. Session manifests
  6. Snapshots
  7. Descriptors
  8. Memory namespaces
  9. References (links)
  10. Tombstones (named-object deletion)
  11. Fork descriptors
  12. Cross-session writes
  13. Transactional model
  14. Concurrency and conflict resolution
  15. Content encoding and compression
  16. Cache control
  17. Branch discovery
  18. Stability and versioning

1. Overview

A pugmark bucket is rooted at any directory, S3 prefix, GCS prefix, HTTP URL, or other object-store namespace that satisfies the requirements in §2. Pugmark owns exactly four top-level prefixes within the bucket:

<bucket>/
├── objects/      # content-addressable blobs (§3)
├── sessions/     # session manifests + snapshots (§4–7)
├── memories/     # memory namespaces (§8)
└── refs/         # symbolic names (§9)

Everything else is the user’s. Pugmark does not enumerate or modify keys outside these prefixes.

The model:

2. Storage backend requirements

Pugmark targets an object-store backend that exposes the following operations. A conformant backend MUST provide:

2.1 Operations

OperationRequired behaviour
PUT(key, body, options)Write body at key with caller-supplied options. MUST support the conditional options below.
GET(key)Return the body and metadata of the object at key, or a “not-found” signal if absent.
HEAD(key)Return the metadata of the object at key (size, content-type, content-encoding, cache-control, user metadata) without transferring the body.
LIST(prefix, delimiter?, max-keys?)List object keys (and optionally “common prefixes” up to delimiter) under prefix. MUST return keys in lexicographic ascending order. MUST support an optional cap on the number of results.
DELETE(key)Delete the object at key.

2.2 Conditional write options

PUT MUST accept the following options; the backend MUST evaluate them server-side (or otherwise atomically with respect to concurrent writers):

2.3 Per-object metadata

Each object MUST carry:

2.4 Listing ordering

LIST MUST return keys in lexicographic ascending order over their full UTF-8 byte representation. Pugmark’s snapshot-version discovery (§6.3) depends on this ordering: it lists with max-keys=1 and takes the first key as the answer.

2.5 Consistency

The backend MUST provide strong read-after-write consistency for objects written via If-None-Match: *. Specifically, once a PUT with this precondition succeeds, every subsequent GET/HEAD of the same key on every reader MUST observe the new state (not absent, not stale).

LIST MAY be eventually consistent — a writer that observes a stale listing simply attempts a doomed If-None-Match write and retries. Bounded listing staleness causes bounded retry count.

3. Object addressing

Blob keys have the shape:

objects/sha256:<64-lower-hex>

Blob writes:

3.1 Inline data

When a blob’s body length is at most 1024 bytes, the descriptor that references it (§7) MAY also carry the body inline in its data field. The blob is still written to objects/sha256:<hex> in the canonical way; inlining is purely a read-side optimisation that lets readers decode small bodies without an extra round trip. A handler or other consumer that observes inline data on a descriptor MAY skip the blob fetch entirely.

3.2 Remote-URI ingestion

A writer MAY populate a descriptor by referencing an object already at a remote URI (e.g. s3://other-bucket/path/key) rather than supplying the body inline. To do so the writer:

  1. Fetches the remote body, hashing it as it streams.
  2. Stores the body at objects/sha256:<hex> in the local bucket using the canonical write rules above.
  3. Builds the descriptor referencing the local key.

The body of the original URI never travels through the protocol-layer IPC stream; this is the mechanism behind the uri field on output records in protocol.md §5.

4. Session identifiers and paths

4.1 Session ID

A session ID is a 128-bit value (16 bytes), structured as:

BytesWidthMeaning
0..34 bytesBig-endian unsigned 32-bit integer of hours since the Unix epoch (floor(unix_seconds / 3600)). The 32-bit range supports approximately 490 000 years from 1970.
4..1512 bytesCryptographically-random data, from a cryptographically-secure RNG.

The time prefix means session IDs created in the same hour share a common 4-byte prefix. This is load-bearing: the storage path embeds the time prefix as a Hive-style partition key (§4.2), so per-hour partitions are bounded in size and queryable with table-aware tools.

The textual representation is lowercase 32-character hex. Parsers MUST accept both lowercase and uppercase hex on input and MUST emit lowercase hex on output.

The 16-byte session ID MAY be used as an OpenTelemetry trace ID directly — both are 16 bytes, with identical byte order. Conformant runtimes use the session ID as the trace ID for the root span of every handler invocation (see protocol.md §14.1).

4.2 Session path

The storage path of a session manifest is:

sessions/year=<yyyy>/month=<mm>/day=<dd>/hour=<hh>/<ID-12-byte-upper-hex>/manifest.json

The year=, month=, day=, hour= segments are Hive-style partition keys (key=value). They are derived deterministically from the ID’s first 4 bytes (the hours-since-epoch counter). They are not the wall-clock time at which the session was created on any server; they are a function of the ID alone. Implementations MUST NOT interpret these segments as authoritative timestamps for sorting, retention, or display — the canonical timestamp is the one decoded from the ID’s first 4 bytes.

The trailing <ID-12-byte-upper-hex> segment is the last 12 bytes of the ID, hex-encoded uppercase (24 characters). The first 4 bytes are not duplicated here because they are already encoded into the partition. Given the manifest path, the full ID can be reconstructed by concatenating the partition bytes (converted back to the 4-byte hour counter, big-endian) with the trailing 12-byte segment.

Number formats:

Parsers MUST reject paths whose numeric components fall outside the valid ranges.

4.3 Snapshot path

The storage path of a snapshot is:

sessions/year=<yyyy>/month=<mm>/day=<dd>/hour=<hh>/<ID-12-byte-upper-hex>/snapshots/<reverse-hex>.json

<reverse-hex> is the snapshot version, reverse-encoded as defined in §6.1.

Snapshot version 0 has no snapshot file — it represents the initial state of a session that exists (has a manifest) but has no committed objects.

5. Session manifests

A manifest is a single JSON document at sessions/<id-path>/manifest.json. Schema:

{
  "session":               "<32-hex session ID>",
  "parent":                {"session": "<32-hex>", "version": <uint32>},
  "name":                  "<optional ref name>",
  "agent":                 "<optional agent id>",
  "handler":               "<optional handler name>",
  "metadata":              { "<k>": "<v>", ... },
  "first-sequence-number": <int64>
}
FieldTypeRequiredNotes
sessionhex stringyesThe session ID. MUST match the path.
parentsnapshot refnoZero value ({"session": "<32 zeros>", "version": 0}) means a root session. A non-zero parent makes this session a fork — see §11.
namestringnoIf set, a corresponding refs/sessions/<name> link MUST exist pointing at this manifest path (§9).
agentstringnoRouting identity for multi-agent deployments. Defaults to "main" when absent or empty.
handlerstringnoNamed-handler routing key (see protocol.md §15).
metadataobject<string,string>noArbitrary string K/V passed to the handler (see protocol.md §13).
first-sequence-numberint64yes (zero-valued for root sessions)Used to thread descriptor sequence numbers across fork boundaries (§7.3). 0 for a root session; parent's last sequence number + 1 for a fork.

5.1 Atomic creation

Manifests are written with:

An implementation that creates a session with an explicit ID MUST be idempotent on precondition-failure: if the manifest already exists at that ID, it loads the existing manifest rather than failing the operation. This allows fork materialisation (§11.2) to retry safely.

Manifests MUST NOT be overwritten or deleted in the normal course of operation. The single exception is the dangling-manifest race described in §9.2: when two writers attempt to claim the same refs/sessions/<name> simultaneously, the loser deletes its own dangling manifest before retrying.

6. Snapshots

A snapshot is a single JSON document — an array of descriptors — stored at the snapshot path (§4.3).

6.1 Versioning

Snapshot versions are unsigned 32-bit integers, starting at 1. Version 0 is reserved to mean “no snapshot exists yet” (the session has a manifest but no committed objects); no file is written at version 0.

Snapshots are stored under reverse-encoded version keys to make “the latest snapshot” an O(1) listing. The on-disk filename is:

<8-char-upper-hex>.json

where the integer in the filename is MaxUint32 - (version - 1) — equivalently, the bitwise complement of version - 1 over 32 bits. Examples:

VersionEncoded integerFilename
10xFFFFFFFFFFFFFFFF.json
20xFFFFFFFEFFFFFFFE.json
1000xFFFFFF9CFFFFFF9C.json
2^32 − 10x0000000000000000.json

Because keys list in lexicographic ascending order (§2.4), the latest snapshot is always the first key returned by LIST(snapshots/, max-keys=1). Implementations MUST NOT rely on listing the full set of snapshots to find the latest; that would defeat the design.

6.2 Snapshot file format

Each snapshot file is a JSON array of descriptors (§7), written with these options:

Snapshot metadata (per-object user metadata, not part of the file body):

Metadata keyValueMeaning
pugmark-snapshot-session32-hexThe session ID this snapshot belongs to.
pugmark-snapshot-versiondecimal uint32The snapshot version (the un-reversed integer).
pugmark-transaction-iddecimal uint64A non-zero random ID identifying the transaction that wrote this snapshot. Used to disambiguate self-retries from foreign conflicts (§13.2).
pugmark-snapshot-typecheckpoint, provisional, or queueingSee §6.4.

A snapshot without a pugmark-snapshot-type metadata key MUST be treated as checkpoint for backward compatibility.

6.3 Loading the latest snapshot

The algorithm for finding the latest snapshot of a session is:

  1. Issue LIST(sessions/<id-path>/snapshots/, max-keys=1).
  2. If the listing returns one key, parse its reverse-hex filename back to a version (§6.1). That’s the latest version.
  3. If the listing is empty, the session has no snapshots. Issue HEAD(sessions/<id-path>/manifest.json) to distinguish “session does not exist” (a not-found error) from “session exists with no snapshots” (success → return version 0).

The algorithm for finding the latest checkpoint snapshot is the same but iterates the listing forward, fetching each candidate’s metadata and skipping over snapshots whose pugmark-snapshot-type is provisional or queueing, until either a checkpoint is found or the listing is exhausted. A session with no checkpoints is a valid state — the consumer MUST be able to report “no checkpoint” as a distinct outcome from “session not found”.

6.4 Snapshot types

Three types, distinguished by the pugmark-snapshot-type metadata key:

TypeSemantics
checkpointThe default. Participates in write-conflict detection (§13.3). Triggers handler invocations when new checkpoints land via cloud notifications.
provisionalAccumulates objects between checkpoints. Does not participate in conflict detection (two provisional writes at the same base version do not conflict — the next checkpoint adopts whichever subset of provisional objects it sees). Wakes a paused handler if one is currently paused (see protocol.md §10). Used for cross-session writes (§12).
queueingLike provisional, but does not wake a paused handler. Accumulates silently until a subsequent provisional or checkpoint flushes the accumulated state.

6.5 Snapshot contents

A snapshot file contains only the descriptors that were written at that version — it is a delta, not the full state at that version. The full state observable at snapshot v is the concatenation of:

  1. The parent chain, walked recursively through the parent field of the manifest (and the parent’s manifest, transitively). For each ancestor, only the descriptors up to and including the parent-snapshot version are visible.
  2. Snapshots 1..v of this session, in version order.
  3. Within each snapshot, descriptors in their stored order.

A consumer that wants the full state at a particular version MUST walk parents transparently; a consumer that wants only the delta committed at a particular version reads only that single snapshot file.

7. Descriptors

A descriptor is the in-snapshot reference to a blob. Schema:

{
  "digest":           "sha256:<64-hex>",
  "size":             <int64>,
  "created-at":       "<RFC3339 timestamp>",
  "content-type":     "<MIME>",
  "content-encoding": "<encoding>",
  "cache-control":    "<HTTP Cache-Control>",
  "schema-url":       "<URL>",
  "name":             "<descriptor name>",
  "metadata":         { "<k>": "<v>", ... },
  "sequence-number":  <int64>,
  "data":             "<base64>",
  "target-session":   "<32-hex>",
  "source-session":   "<32-hex>",
  "source-version":   <uint32>,
  "fork":             <bool>,
  "deleted":          <bool>
}
FieldTypeNotes
digeststringsha256:<lowercase-hex> of the body. Required for non-tombstone descriptors.
sizeint64Body length in bytes.
created-atRFC 3339Timestamp at which the runtime saw this descriptor produced. MAY be the backend’s last-modified time for the underlying blob, or a runtime-stamped time at commit. Implementations MUST NOT treat this field as the source of total ordering — use sequence-number instead.
content-typestringMIME type of the body.
content-encodingstringEmpty, gzip, or zstd. Applies to the body bytes at rest in objects/.
cache-controlstringHTTP Cache-Control header value to serve with the body. Empty by default; control and pause messages use immutable.
schema-urlstringOptional schema URL (e.g. a fully-qualified protobuf message type name).
namestringDescriptor name. Conventionally a filename or typed-event name. MAY be empty.
metadataobject<string,string>Arbitrary user metadata.
sequence-numberint64Strictly-monotonic counter within the session (and across fork boundaries via the manifest’s first-sequence-number). See §7.3.
database64 stringInline body, only if size <= 1024. Optional even when small. Base64 alphabet rules match protocol.md §3.1.
target-sessionhex stringFor fork descriptors (§11) and pending cross-session writes (§12), the destination session’s ID. Empty/zero for normal descriptors.
source-session, source-versionhex string, uint32For descriptors written into a target session via cross-session write: the writer’s session/version. Used as an idempotency key (§12). Empty/zero for normal descriptors.
forkboolTrue if this descriptor records a pending fork (§11).
deletedboolTrue if this descriptor is a tombstone for name (§10).

7.1 Inline data field

The data field is the base64 encoding of the body, present when the original body was at most 1024 bytes. The body is also stored at objects/sha256:<digest> regardless; data is purely a fetch-elision optimisation. Reading a descriptor with data set, a consumer MAY skip the storage fetch entirely.

7.2 Backward-compat: fork-session-id

An older snapshot format used a single fork-session-id field instead of {fork: true, target-session: "..."}. Conformant parsers MUST read both forms:

7.3 Sequence numbers

Every descriptor in a snapshot carries a strictly-monotonic sequence-number (int64). The numbering starts at the session’s first-sequence-number for the first descriptor and advances by exactly 1 for each subsequent descriptor across all snapshots of the session.

For a fork, the child’s manifest first-sequence-number is (parent's last sequence number) + 1, set at fork creation. The fork descriptor in the parent’s snapshot carries a sequence number from the parent’s domain; the same descriptor, when copied into the child’s snapshot 1, MUST be renumbered to start a fresh sequence in the child’s domain (starting at the child’s first-sequence-number).

Within a single snapshot file, descriptors MUST appear in strictly-increasing sequence-number order. Across snapshots, the numbering MUST continue from where the previous snapshot left off — there are no gaps and no duplicates.

Sequence numbers are not used for control flow; they exist so external consumers (queries, analytics, full-text indexers) can establish a deterministic total order over descriptors in a session.

8. Memory namespaces

Memory namespaces are append-only logs of named blob references, addressed under the memories/ prefix. Each namespace is a separate log; the log entries do not belong to any session.

8.1 Layout

memories/<namespace>/<reverse-hex>.json

A namespace MAY itself contain / characters — the namespace is everything between memories/ and the last / before the version file. Hierarchical namespacing (memories/user/123/alice/<rev>.json) is permitted.

The <reverse-hex> segment is identical in shape to the snapshot reverse-hex (§6.1): an 8-character uppercase hex of MaxUint32 - (version - 1), with version 1 being the first written file. Version 0 namespaces have no file.

8.2 File format

Each memory file is a JSON array of records. Each record extends the descriptor schema (§7) with a commit envelope:

{
  "digest": "sha256:...",
  "name": "...",
  ...
  "commit": {
    "session":     "<32-hex>",
    "version":     <uint32>,
    "transaction": <uint64>
  }
}

The commit envelope identifies which session-write produced this memory update:

The envelope is used at merge time to detect concurrent writes (§13.4) and at read time to validate that the writer’s session snapshot still exists.

The file as a whole MUST be written with the same options as a snapshot: Cache-Control per §16, Content-Type: application/json, Content-Encoding: zstd, If-None-Match: *.

8.3 Naming

Within a memory file, each record’s name field MUST:

Multiple writes with the same name in a namespace replace earlier entries: the merge logic is “read the latest memory file, drop records whose name matches an incoming write, append the new records, write the next version under If-None-Match”. On precondition-failure (a concurrent writer beat us to the new version), the merge MUST retry against the new latest version.

8.4 Discovery

A consumer that wants to enumerate all namespaces lists memories/ recursively (no delimiter), parses each path into <namespace> and <version>, and groups by <namespace> to find the latest version per namespace. Because of the reverse-hex encoding, the latest version per namespace is the first path observed when iterating the listing in lexicographic order under memories/<namespace>/.

A consumer that wants only the latest version of a single namespace lists memories/<namespace>/ with max-keys=1 and parses the one result.

8.5 Resolution rules

When a handler (or any other reader) requests a memory key as a single path of the form <namespace>/<object-name>, the resolver MUST use longest-prefix match: given the set of mounted namespaces and a request key K, the resolved namespace is the longest mounted namespace N such that K starts with N + "/". The remainder K[len(N)+1:] is the object name.

Refs are mutable named pointers. Key shape:

refs/<name>

The body is a single line of text/plain; charset=utf-8 containing the link’s target (the destination string).

Properties:

9.1 Session ref convention

When a session’s manifest has "name": "<n>", a ref at refs/sessions/<n> MUST exist whose body is exactly the manifest’s storage path (sessions/year=.../<id>/manifest.json).

Consumers resolving a session name look it up via GET(refs/sessions/<n>) and parse the returned path to extract the session ID.

On every handler invocation, the runtime SHOULD re-check this ref: if the ref no longer points at the current session’s manifest path (because the name was reassigned to a different session), the runtime MUST abort the invocation without committing any new snapshot. This prevents two sessions from racing for ownership of a name.

9.2 Refs and atomicity

Because Link is non-atomic (§9), two writers attempting to create the same named session can both succeed at creating their manifests under different IDs but only one can win the refs/sessions/<name> ref. The losing writer MUST:

  1. Detect the loss by re-reading the ref after its Link attempt.
  2. Delete its own dangling manifest (the one not pointed to by the ref).
  3. Reload the winner’s session from the ref.

This is the only circumstance under which a manifest MAY be deleted (cf. §5.1).

10. Tombstones (named-object deletion)

A descriptor with deleted = true is a tombstone. It marks a name as deleted from the session, but the underlying blob is not deleted — it may still be referenced by other sessions, by memory namespaces, or by older descriptors of this session that have not been visibly tombstoned.

A tombstone descriptor:

Reading the session’s state after a tombstone:

This means tombstones implement “named slot” deletion semantics: the last descriptor with a given name wins, and a tombstone reads as “no slot here.” Renaming a name is two operations: a tombstone for the old name and a new descriptor with the new name. There is no built-in atomic rename for in-session descriptors.

11. Fork descriptors

A descriptor with fork = true and target-session = <id> is a fork record. It signals that a child session with the given ID should be created from this snapshot. Forks are two-phase.

11.1 Phase 1: record the fork

The handler emits an output marked as a fork (see protocol.md §11). The runtime:

  1. Stores the body at objects/sha256:<hex> (§3).
  2. Builds a descriptor with fork = true, target-session = <child-id>.
  3. Commits it to the parent’s next snapshot.

The child session does not exist yet at this point. There is no child manifest, no child snapshot.

11.2 Phase 2: materialise the child

On the next invocation of the parent session, before sending Inputs to the handler, the runtime processes any pending forks from the parent’s most-recent snapshot. For each fork descriptor:

  1. Create the child manifest with parent set to the parent’s snapshot, inheriting handler, agent, and the fork descriptor’s metadata. The write uses If-None-Match: *; if the child manifest already exists, load it instead.
  2. Check the child’s current snapshot version. If it is already > 0, skip — the fork was already materialised by an earlier invocation.
  3. Otherwise, build a child descriptor — a copy of the fork descriptor with fork = false, target-session = (zero), sequence-number = (child's first-sequence-number) — and commit it to the child’s snapshot 1 as a checkpoint. A precondition-failed signal on this commit is treated as success — it means another concurrent run already materialised the child.
  4. Filter the fork descriptors out of the parent’s input stream for this invocation, so the parent’s handler does not re-see its own fork commands as live work.

The two-phase design is crash-recoverable: at any point between phase 1 and phase 2 — runtime crash, host kill, function timeout — the next invocation completes the materialisation. Conflict-loss on either write is harmless because the surviving result is the desired outcome.

11.3 Parent-chain traversal

Once materialised, a child session’s manifest parent points to the parent’s snapshot. A full scan of the child’s state MUST walk parent recursively, so a fork’s full state includes the parent chain.

Fork descriptors in parent snapshots that target other sessions (siblings of the fork being traversed) MUST be filtered out so a child does not see its siblings’ forks.

12. Cross-session writes

A descriptor with target-session = <id> and fork = false is a cross-session write. It indicates that the blob referenced by digest should also be appended to another existing session’s log.

12.1 Source-side commit

The writer commits its outputs normally, with target-session set on cross-session-write descriptors. These descriptors are part of the writer’s own snapshot — they record that a cross-session write was requested.

12.2 Target-side commit

After the writer’s snapshot is committed, the runtime groups the cross-session-write descriptors by target session and, for each target:

  1. Loads the target’s latest snapshot and its descriptors.
  2. Checks idempotency: if any existing descriptor in the target has source-session = <writer.session> and source-version = <writer.version>, skip — this batch has already been delivered.
  3. Otherwise, build a new descriptor list by copying each cross-session-write descriptor with target-session = (zero), source-session = <writer.session>, source-version = <writer.version>, and sequence-number set to extend the target’s sequence.
  4. Commit the new descriptors as a provisional snapshot of the target session. A precondition-failed signal at this commit (because another writer landed a snapshot at the same base version) MUST be treated as success rather than retried, because the source-version idempotency check on a subsequent commit will handle the duplicate correctly.

12.3 Properties

13. Transactional model

13.1 Atomic primitives

Pugmark relies on exactly two atomic primitives from the storage backend:

  1. PUT(key, body, If-None-Match: *) — atomic create-if-not-exists. Used for:
    • Manifest creation (§5.1)
    • Snapshot creation (§6.2)
    • Memory file creation (§8.2)
  2. Single-key consistency on GET / HEAD / LIST — a successful read of a key returns the bytes (and metadata) of the most-recently-completed write to that key (§2.5).

Pugmark does not require multi-key transactions. Every “transactional unit” is a single conditional PUT call.

13.2 Transaction IDs

Each snapshot commit generates a non-zero random transaction ID (a 64-bit unsigned integer). The ID is stored as the snapshot’s pugmark-transaction-id metadata.

Transaction IDs serve one purpose: when retrying a commit that may have already partially succeeded (e.g. the original PUT returned success but the response was lost in transit), the retrier can distinguish its own prior attempt (same transaction ID) from a foreign concurrent writer’s snapshot at the same version (different transaction ID).

A writer that:

  1. Attempts PUT(key, body, If-None-Match: *) and gets precondition-failed,
  2. Reads the existing object’s pugmark-transaction-id and finds it equals its own current transaction ID,

MUST treat the commit as having succeeded. This makes a checkpoint commit idempotent on retry.

Transaction IDs MUST be drawn from a cryptographically-secure RNG (or an equivalent stream) such that the collision probability across two independent writers is negligible. The implementation MAY use a ChaCha8 PRNG seeded from a CSPRNG, or any other source of similar quality. Transaction IDs MUST be non-zero so that a zero-valued metadata read is unambiguously “no transaction ID present” (legacy compatibility).

13.3 Snapshot commit ordering

Within a single session, snapshots are versioned consecutively (1, 2, 3, …). Commits at the same base version race via If-None-Match; one wins. The conflict semantics depend on snapshot type:

Writer’s typeExistingOutcome
checkpoint(none at this version)success
checkpointcheckpoint from same transaction IDretry: idempotent (treat as success)
checkpointcheckpoint from different transaction IDconflict — writer MUST abort: another writer completed a checkpoint at this version first
checkpointprovisional / queueingmerge: rewrite the checkpoint with provisional/queueing descriptors subsumed; bump to the next version
provisional / queueing(none at this version)success
provisional / queueinganything at this versionbump to next version and retry (non-checkpoint snapshots don’t conflict with each other)

This produces the invariant: at most one checkpoint exists per snapshot version, but multiple non-checkpoint snapshots may exist at higher versions between any two checkpoints. A subsequent checkpoint subsumes whatever non-checkpoint snapshots accumulated above its base version.

The “merge: rewrite the checkpoint” operation: the new checkpoint snapshot file MUST contain the descriptors the writer originally intended to commit, with the provisional/queueing descriptors at intermediate versions logically subsumed. The writer is responsible for re-reading those intermediate snapshots and combining their descriptors with its own.

Pugmark commits SHOULD cap the number of retries at a finite limit (the reference implementation uses 100). A writer that cannot make progress within the retry budget MUST surface the failure to the caller.

13.4 Memory commit ordering

Memory namespaces use a simpler model: every write reads the latest version, merges, and writes the next version with If-None-Match: *. On precondition-failure, retry against the new latest. There are no “types” — every memory commit is checkpoint-equivalent.

13.5 Cross-session-write ordering

A cross-session write to session B from session A:V is committed as a provisional snapshot of B (§12.2). Multiple cross-session writes from different writers to the same target session use the provisional protocol — they don’t conflict with each other, and a future checkpoint of B will merge them in. Idempotency on retry from the same writer-session-version is provided by the (source-session, source-version) check.

13.6 Fork commit ordering

A fork is a write to the parent (the pending-fork descriptor) followed asynchronously by a write to the child (the child manifest and the child’s snapshot 1). Each individual write uses If-None-Match: *. The two writes are not part of a single transaction; the child can be partly materialised and re-completed on a future invocation (§11.2). Idempotency is provided by:

14. Concurrency and conflict resolution

14.1 Single-writer assumption per session

Pugmark assumes that only one logical writer is making forward progress on a given session at any moment. In practice this is enforced by:

There is no global lock — pugmark relies on optimistic concurrency via If-None-Match and idempotency via transaction IDs.

14.2 Multi-writer scenarios

Concurrent multi-writer scenarios are correctly handled (no data loss, no double-commits) but at the cost of redundant work:

Concurrent non-deterministic handlers (for example two parallel LLM calls producing different text) will both run, and the losing one’s output is discarded. Designers should architect handlers to be deterministic within a single snapshot, or use sub-sessions / forks for explicit parallelism.

14.3 Eventual consistency

A storage backend that provides only eventual consistency on LIST is acceptable. A reader that sees a stale listing may attempt to commit at version V when version V' > V already exists; the If-None-Match precondition then fails and the writer retries against the new latest. Bounded staleness causes a bounded number of retries.

HEAD and GET of a key written via If-None-Match MUST be strongly consistent after success (§2.5); otherwise commit-then-read-back paths would observe stale state.

15. Content encoding and compression

Storage classAt-rest encodingReason
Object blobs (objects/sha256:...)as-supplied by the writer (gzip, zstd, or none)Pre-compressed bodies pass through verbatim; the blob’s Content-Encoding propagates to the descriptor.
Snapshot files (sessions/<id>/snapshots/<rev>.json)always zstdSnapshot listings are read often; per-file compression amortises well.
Manifest files (sessions/<id>/manifest.json)noneManifests are small and read once.
Memory files (memories/<ns>/<rev>.json)always zstdSame rationale as snapshots.
Refs (refs/<name>)noneRefs are a single line of text.

Implementations MUST set the Content-Encoding header on every PUT to match the actual encoding of the body, so that backends with HTTP-style serving (and HTTP clients with Accept-Encoding) can serve and decompress correctly. Readers MUST decompress according to Content-Encoding before interpreting the body.

For blob bodies whose Content-Encoding was lost (legacy data, manually-uploaded objects), readers MAY sniff the first few bytes of the body to detect gzip (magic 0x1F 0x8B) and zstd (magic 0x28 0xB5 0x2F 0xFD) and decode accordingly.

16. Cache control

Implementations MUST set specific Cache-Control headers depending on the storage class:

ClassCache-ControlRationale
Object blobs (objects/sha256:...)max-age=<long>, immutableContent-addressable; bytes at this key never change.
Manifests (sessions/<id>/manifest.json)immutableManifests are created once and never modified.
Snapshots (sessions/<id>/snapshots/<rev>.json)immutableSnapshot files are atomic-create-only.
Memory files (memories/<ns>/<rev>.json)immutableSame.
Refs (refs/<name>)no-cacheRefs are mutable; caches MUST revalidate on every read.

Backends that surface Cache-Control to downstream CDNs or HTTP caches SHOULD honour these values; aggressive caching of mutable refs would break the session-ref-check guarantee (§9.1).

17. Branch discovery

There is no dedicated branches index — pugmark does not maintain a separate file listing the children of a session.

Child sessions are discovered by walking the parent’s snapshots and collecting every descriptor with fork = true and target-session != (zero). This is O(snapshots) per parent; implementations that need to walk deep fork trees repeatedly SHOULD cache the result.

Forks that have been recorded but not yet materialised (§11.1) are visible through this scan — the target-session ID is reported regardless of whether the child manifest yet exists.

18. Stability and versioning

The on-disk layout is version 1. Compatibility rules from v0.1.0 onward:

If a future version of pugmark needs to break the layout in a non-backward-compatible way, sessions and namespaces written by v1 SHALL remain readable; the new version SHALL write to a new top-level prefix or a versioned subdirectory and SHALL NOT intermingle v1 and vNext keys at the same level.