Storage Layout
| Status | Draft, stable from v0.1.0 |
| Version | 1 |
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
- Overview
- Storage backend requirements
- Object addressing
- Session identifiers and paths
- Session manifests
- Snapshots
- Descriptors
- Memory namespaces
- References (links)
- Tombstones (named-object deletion)
- Fork descriptors
- Cross-session writes
- Transactional model
- Concurrency and conflict resolution
- Content encoding and compression
- Cache control
- Branch discovery
- 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:
- Blobs are content-addressable, deduplicated, and immutable. Each blob lives at exactly one key.
- Sessions are append-only logs of references to blobs. Each session has a manifest and zero or more snapshots; each snapshot is a list of descriptors that reference blobs.
- Memory namespaces are append-only logs of named blob references that live outside any single session, intended for cross-session shared state.
- Refs are mutable named pointers — symbolic links from a human-chosen name to a session.
2. Storage backend requirements
Pugmark targets an object-store backend that exposes the following operations. A conformant backend MUST provide:
2.1 Operations
| Operation | Required 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):
If-None-Match: *— atomic create-if-not-exists. The PUT succeeds only when no object currently exists atkey. If an object exists, the PUT fails with a distinguishable “precondition-failed” / “object-exists” error. This is the cornerstone of pugmark’s concurrency control; the entire transactional model (§13) reduces to compose-and-retry on top of this primitive.Checksum-SHA256: <hex>— backend-side verification of the body’s SHA-256 digest. The PUT succeeds only when the SHA-256 of the body received by the backend matches the supplied digest. If they disagree, the PUT fails. Used for content-addressable writes (§3) so that storage backends can detect bit-flips before persisting.
2.3 Per-object metadata
Each object MUST carry:
Content-Type— a MIME type string.Content-Encoding— empty string,gzip, orzstd.Cache-Control— an HTTPCache-Controlheader value, or empty.- User metadata — a map of string keys to string values. Pugmark stores small bits of out-of-band state here (the reserved keys are listed in §6.2 and §18). The backend MUST round-trip user metadata exactly (no case-folding of keys, no value transformation).
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>
- The algorithm prefix
sha256:is included in the key, separating the algorithm name from the digest with a single colon. - The 32-byte SHA-256 digest is lowercase hex, exactly 64 characters.
- Bodies are stored as-is. No envelope, no length prefix. The body bytes that go into the digest computation are the same bytes the backend stores.
Blob writes:
- MUST include
Cache-Control: max-age=<long>, immutable. The exactmax-agevalue is implementation-defined; one year (max-age=31536000) is the recommended default. - MUST include a
Checksum-SHA256matching the digest in the key. The backend MUST reject the write if the body’s actual digest disagrees. - MUST NOT use
If-None-Match: *. Repeated writes of identical content at the same key are explicitly permitted (and unavoidable in practice) — the digest in the key guarantees that overwrites are byte-identical, so they are harmless.
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:
- Fetches the remote body, hashing it as it streams.
- Stores the body at
objects/sha256:<hex>in the local bucket using the canonical write rules above. - 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:
| Bytes | Width | Meaning |
|---|---|---|
| 0..3 | 4 bytes | Big-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..15 | 12 bytes | Cryptographically-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:
<yyyy>: zero-padded to 4 digits. Valid range 1970–9999.<mm>: zero-padded to 2 digits. Valid range 01–12.<dd>: zero-padded to 2 digits. Valid range 01–31.<hh>: zero-padded to 2 digits. Valid range 00–23.
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>
}
| Field | Type | Required | Notes |
|---|---|---|---|
session | hex string | yes | The session ID. MUST match the path. |
parent | snapshot ref | no | Zero value ({"session": "<32 zeros>", "version": 0}) means a root session. A non-zero parent makes this session a fork — see §11. |
name | string | no | If set, a corresponding refs/sessions/<name> link MUST exist pointing at this manifest path (§9). |
agent | string | no | Routing identity for multi-agent deployments. Defaults to "main" when absent or empty. |
handler | string | no | Named-handler routing key (see protocol.md §15). |
metadata | object<string,string> | no | Arbitrary string K/V passed to the handler (see protocol.md §13). |
first-sequence-number | int64 | yes (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:
If-None-Match: *— manifest creation is atomic. Two concurrent attempts to create the same session ID conflict; one wins, the other observes the precondition-failed signal.Cache-ControlandContent-Typeas specified in §16.
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:
| Version | Encoded integer | Filename |
|---|---|---|
| 1 | 0xFFFFFFFF | FFFFFFFF.json |
| 2 | 0xFFFFFFFE | FFFFFFFE.json |
| 100 | 0xFFFFFF9C | FFFFFF9C.json |
| 2^32 − 1 | 0x00000000 | 00000000.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:
Cache-Control: see §16.Content-Type: application/json.Content-Encoding: zstd— the JSON body is always zstd-compressed at rest. Backends serve the body as-is with theContent-Encodingheader set; clients decompress. Encoders SHOULD use streaming zstd; the specific zstd encoder options do not affect interoperability since any conformant zstd decoder accepts any conformant zstd stream.If-None-Match: *— snapshots are atomic-create.
Snapshot metadata (per-object user metadata, not part of the file body):
| Metadata key | Value | Meaning |
|---|---|---|
pugmark-snapshot-session | 32-hex | The session ID this snapshot belongs to. |
pugmark-snapshot-version | decimal uint32 | The snapshot version (the un-reversed integer). |
pugmark-transaction-id | decimal uint64 | A non-zero random ID identifying the transaction that wrote this snapshot. Used to disambiguate self-retries from foreign conflicts (§13.2). |
pugmark-snapshot-type | checkpoint, provisional, or queueing | See §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:
- Issue
LIST(sessions/<id-path>/snapshots/, max-keys=1). - If the listing returns one key, parse its reverse-hex filename back to a version (§6.1). That’s the latest version.
- 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:
| Type | Semantics |
|---|---|
checkpoint | The default. Participates in write-conflict detection (§13.3). Triggers handler invocations when new checkpoints land via cloud notifications. |
provisional | Accumulates 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). |
queueing | Like 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:
- The parent chain, walked recursively through the
parentfield 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. - Snapshots
1..vof this session, in version order. - 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>
}
| Field | Type | Notes |
|---|---|---|
digest | string | sha256:<lowercase-hex> of the body. Required for non-tombstone descriptors. |
size | int64 | Body length in bytes. |
created-at | RFC 3339 | Timestamp 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-type | string | MIME type of the body. |
content-encoding | string | Empty, gzip, or zstd. Applies to the body bytes at rest in objects/. |
cache-control | string | HTTP Cache-Control header value to serve with the body. Empty by default; control and pause messages use immutable. |
schema-url | string | Optional schema URL (e.g. a fully-qualified protobuf message type name). |
name | string | Descriptor name. Conventionally a filename or typed-event name. MAY be empty. |
metadata | object<string,string> | Arbitrary user metadata. |
sequence-number | int64 | Strictly-monotonic counter within the session (and across fork boundaries via the manifest’s first-sequence-number). See §7.3. |
data | base64 string | Inline body, only if size <= 1024. Optional even when small. Base64 alphabet rules match protocol.md §3.1. |
target-session | hex string | For fork descriptors (§11) and pending cross-session writes (§12), the destination session’s ID. Empty/zero for normal descriptors. |
source-session, source-version | hex string, uint32 | For 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. |
fork | bool | True if this descriptor records a pending fork (§11). |
deleted | bool | True 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:
- If a descriptor has
fork-session-idset to a non-zero hex string, that descriptor MUST be interpreted asfork = trueandtarget-session = <id>. - New writes MUST use the
fork/target-sessionform. Thefork-session-idfield is read-only legacy.
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:
session— the writer’s session ID.version— the writer’s snapshot version at the time of the memory write.transaction— the writer’s transaction ID (§13.2).
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:
- Be non-empty.
- MUST NOT contain
/— the/is reserved for the namespace boundary.
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.
9. References (links)
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:
- The path MUST NOT contain
/within the name part —refs/foois valid;refs/foo/baris not. (The session-ref convention in §9.1 usesrefs/sessions/<n>; here the/separates the kind from the name within the ref namespace. Concretely, the rule is that a ref name MAY contain at most one logical segmenting/, between a fixed kind prefix likesessions/and the name; deeper paths are reserved.) - Refs use
Cache-Control: no-cacheand are mutable. - The Link operation MUST be implementable as an unconditional
PUT— there is noIf-None-Match: *. Multiple writers may collide; the conflict-resolution rule (§9.2) tolerates this. - A Rename operation MUST use
If-Match: <prior-target>to guarantee atomicity — the operation succeeds only when the ref still points at the expected prior target.
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:
- Detect the loss by re-reading the ref after its Link attempt.
- Delete its own dangling manifest (the one not pointed to by the ref).
- 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:
- MUST have
nameset to the name being deleted. - MAY have
digestzero (no blob is referenced); this is the conventional shape. - SHOULD NOT have
dataset. - Is committed as a normal descriptor in a new snapshot (typically a checkpoint or provisional).
Reading the session’s state after a tombstone:
- A full scan that yields every descriptor MUST yield tombstones too. Consumers MUST track them to determine if a name is currently live.
- A named-slot lookup that returns “the descriptor for
name” MUST walk the descriptor list, take the last descriptor whosenamematches the query, and return “not found” if that last descriptor hasdeleted = true.
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:
- Stores the body at
objects/sha256:<hex>(§3). - Builds a descriptor with
fork = true,target-session = <child-id>. - 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:
- Create the child manifest with
parentset to the parent’s snapshot, inheritinghandler,agent, and the fork descriptor’s metadata. The write usesIf-None-Match: *; if the child manifest already exists, load it instead. - Check the child’s current snapshot version. If it is already
> 0, skip — the fork was already materialised by an earlier invocation. - 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. - 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:
- Loads the target’s latest snapshot and its descriptors.
- Checks idempotency: if any existing descriptor in the target has
source-session = <writer.session>andsource-version = <writer.version>, skip — this batch has already been delivered. - 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>, andsequence-numberset to extend the target’s sequence. - 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
- The blob is stored once (content-addressed); only descriptors are duplicated across sessions.
(source-session, source-version)is the idempotency key; a writer can safely retry.- The target session sees the write as a provisional snapshot. If the target’s handler is paused, the provisional snapshot wakes it (§6.4).
- Cross-session writes do not cause the writer to pause (cf. fork-only outputs, which do — see
protocol.md§10). The writer keeps running normally. - A cross-session write with
target-session == writer's own sessionMUST be treated as a normal local write — thetarget-sessionfield is dropped at commit.
13. Transactional model
13.1 Atomic primitives
Pugmark relies on exactly two atomic primitives from the storage backend:
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)
- 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:
- Attempts
PUT(key, body, If-None-Match: *)and gets precondition-failed, - Reads the existing object’s
pugmark-transaction-idand 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 type | Existing | Outcome |
|---|---|---|
checkpoint | (none at this version) | success |
checkpoint | checkpoint from same transaction ID | retry: idempotent (treat as success) |
checkpoint | checkpoint from different transaction ID | conflict — writer MUST abort: another writer completed a checkpoint at this version first |
checkpoint | provisional / queueing | merge: rewrite the checkpoint with provisional/queueing descriptors subsumed; bump to the next version |
provisional / queueing | (none at this version) | success |
provisional / queueing | anything at this version | bump 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:
- Manifest creation with explicit session ID — load on precondition-failure.
- Child snapshot 1 commit — treat precondition-failure as success.
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:
- The CLI’s run loop, which runs sequentially within one process.
- The named-session ref check (§9.1): a runtime that finds
refs/sessions/<name>no longer points at its session’s manifest MUST abort the invocation without committing. - Cloud notification handlers, which deliver one notification per snapshot creation. Multiple handler instances MAY receive duplicate notifications; conflict resolution at commit time (§13.3) ensures only one commits and the others observe precondition-failed.
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:
- Two handler instances invoked for the same snapshot: both run the handler, both compute the same outputs (assuming determinism), one commits, the other gets precondition-failed and exits gracefully.
- Two writers pushing provisional snapshots: both succeed at different version numbers.
- Cross-session writes from concurrent senders: each writes a separate provisional snapshot of the target; no conflict.
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 class | At-rest encoding | Reason |
|---|---|---|
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 zstd | Snapshot listings are read often; per-file compression amortises well. |
Manifest files (sessions/<id>/manifest.json) | none | Manifests are small and read once. |
Memory files (memories/<ns>/<rev>.json) | always zstd | Same rationale as snapshots. |
Refs (refs/<name>) | none | Refs 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:
| Class | Cache-Control | Rationale |
|---|---|---|
Object blobs (objects/sha256:...) | max-age=<long>, immutable | Content-addressable; bytes at this key never change. |
Manifests (sessions/<id>/manifest.json) | immutable | Manifests are created once and never modified. |
Snapshots (sessions/<id>/snapshots/<rev>.json) | immutable | Snapshot files are atomic-create-only. |
Memory files (memories/<ns>/<rev>.json) | immutable | Same. |
Refs (refs/<name>) | no-cache | Refs 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:
- The set of top-level prefixes (
objects/,sessions/,memories/,refs/) is closed for v1. - The session path shape (
sessions/year=.../<id>/{manifest.json,snapshots/...}) is closed for v1. - The manifest JSON schema is open for additive fields. New required fields constitute v2.
- The descriptor JSON schema is open for additive fields. Parsers MUST read the legacy
fork-session-idform indefinitely (§7.2). - The reverse-hex snapshot/memory versioning is closed for v1 (it is load-bearing for
O(1)latest lookup). - The set of
pugmark-snapshot-typevalues (checkpoint,provisional,queueing) is closed for v1. - The reserved per-object metadata keys for v1 are:
pugmark-snapshot-session,pugmark-snapshot-version,pugmark-transaction-id,pugmark-snapshot-type, andschema. - The atomic-primitive contract (§13.1) and the consistency contract (§2.5) are the formal compatibility surfaces for new storage backends.
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.