Bucket contract¶
The v1 archive layout: prefixes, object formats, metadata and seals. The three parts share nothing else (0058). See capabilities for what is built. Decided in 0060, 0061 and 0062.
Layout¶
Paths are relative to the installation's prefix. Times are UTC. <profile> and <tenant> contain no /.
| Prefix | Holds | Written by | Locked |
|---|---|---|---|
records/<profile>/<tenant>/<yyyy>/<mm>/<dd>/<hh>/<ULID> |
One object per ingest batch | ingest | Yes, the profile's retention |
catalogue/<app>/<version> |
The application's catalogue at that version | ingest | Yes |
seals/<profile>/<tenant>/<yyyy>/<mm>/<dd>/<hh>.jws |
One seal per profile, tenant and hour | notary | Yes, as the records it covers |
keys/roots.jwks |
The trust anchors, as a JWK Set | the operator | No, versioned |
keys/delegations/<thumbprint>/<ULID>.jws |
Delegations to a signing key | a root | No |
keys/revocations/<ULID>.jws |
Revocations of a signing key | a root | No |
Records¶
| Part | Rule |
|---|---|
<hh> |
The hour of ingest time: when ingest took the batch, not any record's time |
<ULID> |
Generated when the put starts, from the same clock, so keys sort in arrival order within an hour. Never shared. A key whose ULID is from another hour than the key names is not a key of this contract |
| Body | One batch, newline-delimited JSON, zstd-compressed (Content-Encoding: zstd), one record per line |
record |
audit.v1.Record in canonical JSON (RFC 8785 over the protobuf JSON mapping) |
hash |
Lower-case hex SHA-256 of that canonical encoding |
| Lines | A writer embeds canonical bytes as they are. A reader canonicalises record before hashing, so other whitespace still checks. A line is never changed, merged or reordered. Line numbers, from one, address a record in an index |
Tenant with / |
Cannot be written; the writer keeps the record aside with the reason. @platform is valid |
| Retention | Set on the put from the profile (0045, 0056) |
Each line:
{"hash": "<hex sha256>", "record": { ... }}
Every object carries these x-amz-meta- keys:
| Key | Value |
|---|---|
format |
1 |
sha256 |
Hex SHA-256 of the object's stored bytes |
count |
The number of records |
Catalogue¶
catalogue/<app>/<version> is written once with If-None-Match: *. Identical bytes succeed. Different bytes stop the writer.
Seals¶
A seal is a compact JWS.
| Header field | Value |
|---|---|
alg |
ES384 |
typ |
audit-seal+jws |
kid |
The RFC 7638 thumbprint of the signing key |
Payload audit.v1.Seal, proto JSON, proto field names:
| Field | Meaning |
|---|---|
tenant, profile |
What the seal covers |
hour |
Start of the hour covered, RFC 3339, UTC |
count |
Records in scope; zero for an empty hour |
root |
Root of a binary Merkle tree (RFC 6962 §2.1), hex. Empty hour: SHA-256 of the empty string |
first, last |
First and last object key of the hour; empty for an empty hour |
prev |
Hex SHA-256 of the previous seal's bytes for the profile and tenant; empty for the first |
sealed_at |
When made, RFC 3339, whole seconds, never before the hour ends |
meters |
Optional counters for the hour (objects, records, bytes); absent for an empty hour |
| Rule | Detail |
|---|---|
| Encoding | A 64-bit integer (count, each meter) is a decimal string. Every field is written, an empty prev as "". Members are in canonical order (RFC 8785) |
| Verifier | Checks the signature over the payload bytes as they are; never re-encodes |
| Put | Conditional (If-None-Match: *). Locked as long as the records it covers: the latest of the hour's objects, or for an empty hour the seal before it |
| Notary | Reads the hour's objects before signing. Refuses an hour whose objects do not match their metadata (sha256, count, every record hash) and seals nothing past it. The chain stops at a fault and the newest seal's age shows it |
| Timing | Written once the hour has settled (0062). An empty hour still gets a seal, so a missing seal is a fault |
Merkle tree construction¶
Leaves are the hour's records in key order, then line order. A leaf input is the record's 32 raw hash bytes.
| Item | Definition |
|---|---|
| Leaf | SHA-256(0x00 || leaf input) |
| Interior node | SHA-256(0x01 || left || right), over the children's 32-byte hashes |
| Shape | RFC 6962 §2.1: split at the largest power of two smaller than n. Unbalanced when n is not a power of two; no leaf is duplicated |
| Empty hour | The root is SHA-256("") |
Leaf inputs are SHA-256("<i>") for ASCII decimal i from 0:
| n | root |
|---|---|
| 0 | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
| 1 | 13a77175e35eb1d9da91ee14df0d7772cea71289800206e2b45c882ecb06efbf |
| 2 | bbb441530bdded54e6e2bfcdc829819ff39b30768eb9f023071dffc16b410f10 |
| 3 | 8be871f13785b4c81a1700459c76ac2b3ae2caebb7876c376e223c6adff98c47 |
| 5 | 4e23fb40d8876f1299cca9b3c28432a01b11d1a20126606914612892ff2e09a7 |
Inputs and audit paths: internal/merkle/testdata/vectors.json.
Worked example¶
Synthetic seals in internal/seal/testdata: hour 10 has three records (the n = 3 vector); hour 11 is quiet. keys/roots.jwks:
{"keys": [{"kty": "EC", "crv": "P-384", "alg": "ES384", "use": "sig",
"kid": "o2yB9qPIG1yB95LuYok20fSC80yOxCu7VF8rmi5LyGg",
"x": "CGbXeVcyaJ3FX1fp6aFwTmOd2o4Az9onclXvRWez9zb1zHXkRIatexKJnvf6gVXn",
"y": "25YgRZW8Wuk4VGnjnSBJpeLYg_5_0_1Ur3tSC3fBjK8h0Fc8wSDqtztpKL_rrWfC"}]}
seals/security/acme/2026/09/17/10.jws, header and payload decoded:
{"alg": "ES384", "kid": "o2yB9qPIG1yB95LuYok20fSC80yOxCu7VF8rmi5LyGg", "typ": "audit-seal+jws"}
{"count":"3","first":"records/security/acme/2026/09/17/10/01M2QDGD6000000000033XR4JR",
"hour":"2026-09-17T10:00:00Z","last":"records/security/acme/2026/09/17/10/01M2QEBW3000000000034XR5BY",
"meters":{"bytes":"1536","objects":"2","records":"3"},"prev":"","profile":"security",
"root":"8be871f13785b4c81a1700459c76ac2b3ae2caebb7876c376e223c6adff98c47",
"sealed_at":"2026-09-17T11:17:04Z","tenant":"acme"}
.../11.jws, whose prev is the SHA-256 of the first seal's compact serialisation:
{"count":"0","first":"","hour":"2026-09-17T11:00:00Z","last":"","meters":{},
"prev":"dc2b3969dd9edd1197a4bf6a8778189508a9104500a2709e14b20bb4c87f9326",
"profile":"security","root":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"sealed_at":"2026-09-17T12:17:03Z","tenant":"acme"}
Inclusion proof¶
A record's 0-based leaf index and its RFC 6962 audit path (at most ⌈log₂(count)⌉ hashes) recompute the root.
Trust¶
| Rule | Detail |
|---|---|
keys/roots.jwks |
A JWK Set of P-384 public keys: distribution, not trust |
| Trusted keys | Only roots whose thumbprints the verifier's configuration pins. The verifier computes thumbprints from the key, never from kid |
| Valid seal | The signature verifies under the key named by kid, and that key is a pinned root or a delegation chains it to one |
Delegation¶
A root signs a delegation (typ: audit-delegation+jws), payload audit.v1.Delegation:
| Field | Meaning |
|---|---|
iss |
Thumbprint of the root |
sub |
Thumbprint of the delegated key |
jwk |
The delegated public key |
nbf, exp |
Signing window, seconds since the epoch. exp - nbf is at most 25 hours |
scope |
profiles and tenants, each a list of names or * |
| Rule | Detail |
|---|---|
| Valid delegate seal | sealed_at is in the window; profile and tenant are in scope |
| Witness | The bucket's write time, within five minutes of skew. A seal written after the window closed is invalid |
| Over 25 hours | The delegation is invalid |
| Status | Verifying is built; signing is not |
Revocation¶
A root signs a revocation (typ: audit-revocation+jws), payload audit.v1.Revocation:
| Field | Meaning |
|---|---|
iss |
The root |
revokes |
Thumbprint of the revoked key |
revoked_at |
RFC 3339 |
reason |
Optional |
| Rule | Detail |
|---|---|
| Invalid seal | Signed by the revoked key, with sealed_at or the bucket's write time (same skew) at or after revoked_at |
| Root | Revoked only by removing the pin |
| Unpinned signer | A delegation or revocation no pinned root signed is ignored |
Reading the archive¶
| Rule | Detail |
|---|---|
| Follow | List records/<profile>/<tenant>/ after the last handled key, in order, never past a key younger than the settle window |
| Tenants | List records/<profile>/ with a delimiter |
| Source of truth | The listing. A notification is a hint |
| Outside the contract | The dead-letter prefix, verification marks and index tables. They may change without a new version, and no part reads another's |
Conformance¶
internal/bucketcontract is a black-box suite for any S3 API.
| Half | Holds a bucket to | Applied by |
|---|---|---|
| Records and catalogue | Key grammar, envelope, metadata, sha256, every record hash, key uniqueness and order, the catalogue a record names | audit verify |
Seals (CheckSeals) |
Seal key grammar, JWS, payload, signature against pinned roots, delegations and revocations, chain, recomputed count and root, lock, missing seals | audit verify --root |
It runs on the in-memory store and LocalStack S3, with a key-file notary and a KMS ECC_NIST_P384 notary. It rejects:
| Broken case |
|---|
| A key nobody pinned |
| A broken chain |
| A root that is not the hour's |
| An object added to a sealed hour |
| A seal made before its hour ended |
| A stray key |
| A delegation outside its window or scope, or over 25 hours |
| A delegate whose key was revoked |
| A second catalogue put with other bytes |