Skip to content

docs: propose the S3 versioning design (per-key version tree) - #49

Merged
frrist merged 7 commits into
mainfrom
design/s3-versioning
Aug 10, 2026
Merged

docs: propose the S3 versioning design (per-key version tree)#49
frrist merged 7 commits into
mainfrom
design/s3-versioning

Conversation

@frrist

@frrist frrist commented Jul 29, 2026

Copy link
Copy Markdown
Member

The implementable spec for S3 bucket versioning, built on the per-key version-tree leaf proposed in the PR #2 architecture review (@hannahhoward's design-comment), which supersedes architecture.md §3's composite-key invertedVersionId encoding.

Covers the data model (ObjectLeaf / prev sub-MST keyed by inverted seq), the token grammar, the write rule per versioning state, read/delete/list semantics pinned against the versitygw conformance suite, reference-index interplay, and a file-by-file implementation map.

Design doc only — the implementation lands in a follow-up PR once this is ratified. (The design has been validated end-to-end by a prototype in this worktree: unit suite plus the upstream versioning conformance categories against the live smelt stack.)

@frrist frrist self-assigned this Jul 29, 2026

@alanshaw alanshaw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I need to understand properly how this all works before I read any more. I'm not sure if I'm missing something?

Comment thread docs/s3-versioning.md Outdated
- **`seq`** — a per-bucket monotonic ordinal. Its only job is *ordering* (recency). Internal;
never leaves the server.
- **`version_id`** — a string, the S3 client handle (`x-amz-version-id`). Its job is *identity*.
It is opaque, and for suspended/unversioned writes it is the literal string `"null"`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ewww

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it's AWS's rule: S3 fixes the null version's id to the literal string null, and the conformance suite asserts the exact echo (Versioning_PutObject_suspended_null_versionId_obj). The consolation in this design is containment: the null version costs one string compare plus one uint64 on the leaf (NullSeq), where the composite-key scheme let it leak into the key encoding.

Comment thread docs/s3-versioning.md Outdated
type VersionNode struct {
Seq uint64 `cborgen:"s"` // per-bucket ordinal; ordering only, never exposed
VersionID string `cborgen:"v"` // client handle: "null" or a ULID token (§3)
Manifest cid.Cid `cborgen:"m"` // ObjectManifest CID

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just inline this in the current node? Does it need to be separate?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlining the manifest into the leaf would break invariant 5 (a version's identity never changes). The manifest CID is the version's stable handle: an overwrite pushes that same CID into the prev tree, promotion pulls it back, and nothing is rewritten. The reference index (§8) counts per-version claims on the strength of that stability. Inlined, a displaced manifest would need re-serializing as its own block, which mints a new CID, and the full blob list would sit on a block that every read and list fetches. §2.1 now spells this out (606460e).

Comment thread docs/s3-versioning.md
// its manifest.
type VersionNode struct {
Seq uint64 `cborgen:"s"` // per-bucket ordinal; ordering only, never exposed
VersionID string `cborgen:"v"` // client handle: "null" or a ULID token (§3)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Below we say that Version ID comes from the manifest - why is it then repeated here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. That sentence was meant to be scoped to prev-tree entries (their MST value is a bare manifest CID, so for them the id lives only in the manifest), and as written it contradicted this field. The copy here is deliberate: it lets the leaf answer questions without fetching a manifest. The write rule decides retain-or-discard from displaced.VersionID (§5.2), and resolution short-circuits on Current.VersionID / Current.Seq (§6.1). Manifests are immutable, so the copy can't drift. 606460e rescopes §2.2 and adds the rationale to §2.1.

Comment thread docs/s3-versioning.md Outdated
order-preservingly* — no composite-string escaping. Valid under `mst.IsValidKey` (hex is
UTF-8, NUL-free, short).
- **value** — the version's `ObjectManifest` CID. `Seq` is recoverable from the key;
`VersionID` comes from the manifest (§2.3), which every consumer of a prev entry fetches

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not clear on how this works - how do you get a particular version if the version ID is not in the key?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token carries its own position: a numbered version id is a ULID whose low 64 entropy bits are the version's seq (§3). A version-scoped read parses the token, checks Current.Seq, and otherwise seeks prev.Get(revSeqKey(seq)) directly, O(log n), no scan. It then fetches the manifest and confirms the stored VersionID equals the full token; any well-formed ULID parses, so without that check a token this bucket never minted could land on a real seq and serve a version the caller never named (§6.1). versionId=null is the one token with no position inside it, which is what the leaf's NullSeq field is for. The doc buried all of this in §3 and §6: 606460e adds an end-to-end walkthrough at the top of §2 so the mechanism lands before the data structures.

Comment thread docs/s3-versioning.md

```go
Seq uint64 `cborgen:"sq"` // the version's ordinal (== leaf/prev position)
VersionID string `cborgen:"vi"` // "null" or the ULID token; "" only in pre-versioning blocks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are both repeated in VersionNode?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and each side has a job. The manifest is the authoritative, self-contained record: prev entries are bare manifest CIDs (§2.2), so for a noncurrent version the manifest is the only place its id exists. List rendering reads it there, the §6.1 check verifies against it, and promotion (§7.2) rebuilds Current from it. The VersionNode copies exist so leaf-only decisions skip the manifest fetch (see the thread on line 51). Manifests are immutable, so the two can't drift: the copy is a cache, and the manifest stays the record. §2.1 and §2.3 now say this in the doc (606460e).

frrist added a commit that referenced this pull request Aug 5, 2026
… front

Review response for PR #49: add a §2.0 end-to-end lookup walkthrough
(including what the token-verify guard protects against), document why
VersionNode caches two manifest fields and why the manifest stays a
separate block, and scope the §2.2 'id lives in the manifest' claim to
prev-tree entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist

frrist commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Fair, and the doc's ordering caused it: the data structures (§2) landed before the mechanism that motivates them (§3's seq-in-the-token, §6.1's resolution). 606460e restructures:

  • §2.0 (new): an end-to-end lookup walkthrough, including the token-verify guard, so the mechanism comes first.
  • §2.1: why VersionNode caches two manifest fields, and why the manifest stays a separate block.
  • §2.2 / §2.3: the 'id lives in the manifest' claim is now scoped to prev-tree entries, which is all it ever meant.

The short version: the top MST still maps plain object keys, now to a small per-key leaf. The leaf holds the current version inline, so plain reads and lists cost one extra block and never touch history. Old versions live in a per-key sub-MST keyed newest-first by seq. A version id is a ULID with the seq embedded, so version-scoped reads seek straight to their target. Happy to walk through it live if that's faster.

frrist added a commit that referenced this pull request Aug 6, 2026
… front

Review response for PR #49: add a §2.0 end-to-end lookup walkthrough
(including what the token-verify guard protects against), document why
VersionNode caches two manifest fields and why the manifest stays a
separate block, and scope the §2.2 'id lives in the manifest' claim to
prev-tree entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist
frrist force-pushed the design/s3-versioning branch from 8f088b9 to dc8d099 Compare August 6, 2026 00:09
@frrist
frrist requested a review from hannahhoward August 6, 2026 00:32
Comment thread docs/s3-versioning.md
iterates newest-first because its keys are inverted seqs.

**Space** (§8). Each version claims its body digests in `blob_refs`. Removing a version
releases only that version's claims, and the physical bytes go only when the last claim goes,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checking this isn't refering to Piri and removing a blob only when it is removed from all spaces it is allocated in. This is Ingot tracking blobs and actually performing /blob/remove only when no objects (or versions of an object) in the bucket refer to it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that's right. There are two layers of refcounting:

  1. ingot's blob_refs counts claims per (space, digest) across all objects and versions in the bucket, and only at zero does ingot invoke /blob/remove that releases the space's claim on Piri.
  2. piri's own cross-space accounting before physically deleting bytes (here).

I'll add a clairifcation that RemoveBlob in ingot means: "release this space's claim via /blob/remove and not physically delete".

Comment thread docs/s3-versioning.md Outdated
tree and promotion pulls it back; nothing is rewritten, and the reference index (§8) counts
claims per version on the strength of that stability. An inlined manifest would have to be
re-serialized as a standalone block when displaced, which mints a new CID, and it would put the
full blob list on a block that every read and list fetches.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and it would put the full blob list on a block that every read and list fetches.

You need it anyway for those responses. You cannot list objects in a bucket with just the data that is defined in the ObjectLeaf.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this design should obviate this? https://github.com/fil-forge/ingot/pull/49/changes#r3730655300

Comment thread docs/s3-versioning.md Outdated
version's stable identity (invariant 5, §2.4). Displacement pushes that same CID into the prev
tree and promotion pulls it back; nothing is rewritten, and the reference index (§8) counts
claims per version on the strength of that stability. An inlined manifest would have to be
re-serialized as a standalone block when displaced, which mints a new CID, and it would put the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An inlined manifest would have to be re-serialized as a standalone block when displaced, which mints a new CID

I'm just saying for every key read we've gone from 1 block lookup to 2.

i.e.key -> ObjectManifest to key -> ObjectLeaf -> ObjectManifest for ALL buckets and all keys versioned or not. Further, I imagine the majority will not be versioned.

"minting" a CID is deterministic, so unless we're going to mutate the manifest, does it matter if we store a manifest block when we "revise" (aka displace) an object?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair and I think you've already solved this below via the discriminator suggestion. So we'll adopted both together:

  • the top-MST value is now a union.
  • A key holding exactly one version stores the bare ObjectManifest CID so one block per read
  • It upgrades to an ObjectLeaf on its first revision supersession(?), keeping the leaf from then on.
  • Unversioned buckets (the majority case, as you say) never pay the indirection; only keys that actually accumulate versions do, and those need the leaf hop anyway to route the seq lookup.

The price is a two-case decode at each read/write site, which I think is worth it.

Comment thread docs/s3-versioning.md Outdated
in place and reset any persistent dev DB"), there is **no migration**: existing dev buckets are
reset. Readers must **not** type-sniff values: cbor-gen's map decoders skip unknown fields and
zero-fill missing ones, so decoding a manifest block as an `ObjectLeaf` *succeeds* and yields
garbage. The deployment declares the format; nothing detects it from bytes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use a version discriminator on our key values to allow upgrades to this format in the future without duck typing or re-writing all data and continue to support older versions.

e.g.

type ObjectLeaf union {
  | ObjectLeafV0 "/objectleaf/0"
  | ObjectLeafV1 "/objectleaf/1"
} representation keyed

type ObjectLeafV0 struct {
  current VersionNode
  prev Link
  nullSeq int
}

type ObjectLeafV1 struct {
   // completely different to v0
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread docs/s3-versioning.md Outdated
the authoritative record, and the `VersionNode` fields are a two-field cache of it.

**Why `Manifest` is a pointer rather than an inlined manifest:** the manifest CID is the
version's stable identity (invariant 5, §2.4). Displacement pushes that same CID into the prev

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a different word to displacement? "Revision" maybe?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed, though I went with "supersession"/"superseded" rather than "revision": "revised" reads ambiguously about which version it names (the new one or the pushed-down one), while "superseded" can only mean the old current — and architecture.md already uses "superseded" for exactly this. Happy to switch if you feel strongly about "revision".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, sounds fine - it's better than displacement.

Comment thread docs/s3-versioning.md Outdated

**Why `Manifest` is a pointer rather than an inlined manifest:** the manifest CID is the
version's stable identity (invariant 5, §2.4). Displacement pushes that same CID into the prev
tree and promotion pulls it back; nothing is rewritten, and the reference index (§8) counts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "promotion" mean? Is this just retrieving a version? We don't ever actually move previous versions back to current right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do move one back: deleting the current version by versionId makes the newest remaining prev entry current again — S3 semantics; removing the latest version re-exposes the next-newest. "Promotion" is that move, nothing else. I'll update the doc to make this clearer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes of course, thanks.

Comment thread docs/s3-versioning.md Outdated
Comment on lines +283 to +285
### 5.2 Displacement

With `displaced` = the existing `leaf.Current` (if the key exists) and `new` = the incoming

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
### 5.2 Displacement
With `displaced` = the existing `leaf.Current` (if the key exists) and `new` = the incoming
### 5.2 Revisioning
With `revised` = the existing `leaf.Current` (if the key exists) and `new` = the incoming

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied via the rename thread above, with "Supersession" in place of "Revisioning".

frrist added a commit that referenced this pull request Aug 6, 2026
… front

Review response for PR #49: add a §2.0 end-to-end lookup walkthrough
(including what the token-verify guard protects against), document why
VersionNode caches two manifest fields and why the manifest stays a
separate block, and scope the §2.2 'id lives in the manifest' claim to
prev-tree entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist
frrist force-pushed the design/s3-versioning branch from b54b8f9 to 2bc7388 Compare August 6, 2026 17:49

@alanshaw alanshaw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

Comment thread docs/s3-versioning.md Outdated

- **A bare `ObjectManifest`** — the key holds exactly one version. One block serves the whole
read (the manifest carries `Seq`/`VersionID`, §2.3, so even version-scoped requests resolve
against it directly), and most keys never leave this form.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd also envelope this as well like { "/objectmanifest/0": <ObjectManifest fields> } so you gain the ability to upgrade the format of a manifest over time as well.

You can also keep your manifest/leaf types separate like:

type Value struct {
  Manifest   *ObjectManifest   `cborgen:"/objectmanifest/0"`
  ManifestV1 *ObjectManifestV1 `cborgen:"/objectmanifest/1"`
  Leaf       *ObjectLeaf       `cborgen:"/objectleaf/0"`
}

...or, leave it as is and add it when we need it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in full via 43dfdf9. Every value block is now the keyed union, manifests under /objectmanifest/0 and leaves under /objectleaf/0, with the codec generated from essentially the struct you sketched.

frrist added a commit that referenced this pull request Aug 7, 2026
…union

Per review on #49: every catalog value block is now the keyed union — a
manifest stores under "/objectmanifest/0", a leaf under "/objectleaf/0" —
so both arms' formats can be revised independently (a new format takes a
new key; old and new blocks coexist under one reader, no rewrite pass).
Manifest blocks carry the union key everywhere they appear, as a value
block and as a prev-tree entry alike, so a version's identity CID names
one encoding. cbor-gen generates the union codec from omitempty pointer
arms; because its decoder skips unknown map keys silently, thin strict
wrappers supply the loud unknown-key failure the format requires. §10's
compatibility story follows: pre-union bare blocks are rejected, dev
buckets reset, and format revisions from here need no reset at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frrist and others added 7 commits August 10, 2026 13:15
The implementable spec for S3 bucket versioning, built on the per-key
version-tree leaf proposed in the PR #2 architecture review (r3440362078),
which supersedes architecture.md §3's composite-key invertedVersionId
encoding: seq (ordering, internal) and version_id (identity, "null" or a
ULID token) are separate fields, so the null version's replace-in-place
semantics fall out structurally instead of via sentinels.

Covers the data model (ObjectLeaf / prev sub-MST keyed by inverted seq),
the token grammar, the write rule per versioning state, read/delete/list
semantics pinned against the versitygw conformance suite, reference-index
interplay, and a file-by-file implementation map.

Design doc only — the implementation lands in a follow-up PR once this is
ratified. (The design has been validated end-to-end by a prototype in this
worktree: unit suite plus the upstream versioning conformance categories
against the live smelt stack.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… front

Review response for PR #49: add a §2.0 end-to-end lookup walkthrough
(including what the token-verify guard protects against), document why
VersionNode caches two manifest fields and why the manifest stays a
separate block, and scope the §2.2 'id lives in the manifest' claim to
prev-tree entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retitle to 'S3 versioning in Ingot', replace the design-history section
with a 'How versioning works' overview (structure, seq vs version_id,
null version, writes, reads with the token-verify walkthrough, lists,
reference counting), and remove version-relative language throughout so
the doc describes the system as designed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version-scoped DELETE of an unknown id is an idempotent success no-op
(pinned by upstream conformance), token parsing is strict ULID, GC
candidates cover manifests and leaves but not MST interior nodes, and
the implementation map gains the listversions.go / DeleteBucket-guard /
delete-preconditions rows. List sections note the delimiter manifest
cost and version-id-marker validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Point §3's versioned-storage section at docs/s3-versioning.md, replace
the composite-key bullet and manifest diagram with the ObjectLeaf shape,
update the schema comment, and drop the resolved open question and the
versioning entry from the deferred list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopt the review's value-union suggestion: a single-version key stores its
bare ObjectManifest CID (the pre-versioning one-block layout), and a key
gains an ObjectLeaf, wrapped in a "/objectleaf/0" keyed envelope, at its
first supersession. Unversioned keys never pay the leaf indirection, and
the envelope makes the format self-describing, so future revisions take a
new key instead of a data reset.

Also from the review: drop the incorrect claim that re-serializing an
inlined manifest mints a new CID (cbor-gen encoding is deterministic),
restate the pointer-vs-inline tradeoff as leaf weight, rename displacement
to supersession, clarify that RemoveBlob releases the space's claim while
Piri owns physical deletion, and point promotion's first use at §7.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…union

Per review on #49: every catalog value block is now the keyed union — a
manifest stores under "/objectmanifest/0", a leaf under "/objectleaf/0" —
so both arms' formats can be revised independently (a new format takes a
new key; old and new blocks coexist under one reader, no rewrite pass).
Manifest blocks carry the union key everywhere they appear, as a value
block and as a prev-tree entry alike, so a version's identity CID names
one encoding. cbor-gen generates the union codec from omitempty pointer
arms; because its decoder skips unknown map keys silently, thin strict
wrappers supply the loud unknown-key failure the format requires. §10's
compatibility story follows: pre-union bare blocks are rejected, dev
buckets reset, and format revisions from here need no reset at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist
frrist force-pushed the design/s3-versioning branch from 43dfdf9 to 084567d Compare August 10, 2026 20:15
@frrist
frrist merged commit 6a7d9e6 into main Aug 10, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants