Szzt — Content Model & Wire Format Specification
The on-PDS content model and the wire formats of a Szzt
(a.k.a. "Mini-Site" / @minisite) mini-site: the three atproto records that describe a site, the
StrongRef links between them, the CID codecs, the byte encodings, and how a served request path
resolves to a specific block of bytes. A compatible publisher writes these records; a compatible
verifier re-derives every CID from signed state and trusts no source's assertion.
Scope boundaries, covered in the sibling docs:
- How a request is dispatched and served (host demux, RASL-by-CID, tile-mode headers, SSRF
guard, caching):
docs/gateway-conformance.md. - What each package is and how the system is wired together:
docs/architecture.md. - The normative source of truth:
mini-site-spec.md. Section numbers (§3.2,§7.2, …) below refer to it. Code/spec divergences are flagged explicitly under "Code vs. spec notes" at the end.
1. The three records
A mini-site is exactly one pub.mini.site pointer, an append-only series of
pub.mini.deployment versions, and — per deployment — one ing.dasl.masl manifest record,
plus the per-resource raw blobs the manifest references. There is no separate bundle object; the
manifest is stored inline in the ing.dasl.masl record (§4, §7.2).
The canonical read-side shapes are the TypeScript interfaces in
packages/records/src/types.ts. These are the record values as they appear in IPLD-JSON:
cid-link fields as { $link: "<cid>" }, StrongRef cid fields as plain CID strings (not
cid-links), and optional fields omitted (never null).
Two field-level link kinds recur and must not be conflated
(packages/records/src/types.ts CidLink / StrongRef):
| Kind | Shape in the record value | Meaning |
|---|---|---|
cid-link (CidLink) |
{ "$link": "<cid>" } |
An IPLD content link. The dCBOR42 encoder emits it as a tag-42 CID. Used for root, the manifest's cid, prev, and each blob ref's src.ref. |
strong ref (StrongRef) |
{ "uri": "at://…", "cid": "<cid>" } |
A com.atproto.repo.strongRef: an AT-URI plus the referenced record's atproto CID. cid is a plain string (lexicon format: cid), NOT a $link. Used for current and tile. |
1.1 pub.mini.site — stable identity and active pointer (§4.1)
Value type: packages/records/src/types.ts SiteRecordValue. Lexicon key: tid.
| Field | Type | Req. | Purpose |
|---|---|---|---|
$type |
"pub.mini.site" |
yes | Record type. |
current |
StrongRef | yes | Strong ref to the active pub.mini.deployment, pinning one exact version (its uri + atproto CID). This is the only mutable pointer in the model — rollback/update rewrites it. |
title |
string | no | Display title. Informational. |
slug |
string | no | Informational only; naming authority is the gateway's slug index, not this field (§11.1). |
hints |
string[] | no | Site-level mirror hostnames for content-only federation (§13). MAY be empty. |
createdAt |
datetime string | yes | Creation timestamp. |
The site record is the stable identity a slug or per-site origin resolves to; its current StrongRef
is the head of the resolution chain (§2).
1.2 pub.mini.deployment — immutable version (§4.2)
Value type: packages/records/src/types.ts DeploymentRecordValue. Lexicon key: tid. Never
mutated after creation; a new publish/update creates a new deployment record.
| Field | Type | Req. | Purpose |
|---|---|---|---|
$type |
"pub.mini.deployment" |
yes | Record type. |
root |
cid-link | yes | The root MASL CID — the dCBOR42 CID of the inline MASL. Equals the paired manifest record's cid field (§1.3). This is the canonical immutable identity of the content. |
entry |
string | no | Entry-document path (lexicon default /index.html). The verifier checks it resolves to a real resource (packages/verify/src/verify.ts verify(), check "deployment.entry resolves to a resource"). |
tile |
StrongRef | yes | Strong ref to the ing.dasl.masl manifest record, pinning that record's atproto CID (§7.2, §8). |
site |
string | no | rkey of the owning pub.mini.site — a back-reference so a repo's append-only deployments can be grouped into a site's version history (§16). Informational; records predating this field simply carry no site and are excluded from history. Resolution authority remains the pub.mini.site pointer, never this field. |
createdAt |
datetime string | yes | Creation timestamp. |
Note on tile being required: both mini-site-spec.md §4.2 required and
DeploymentRecordValue.tile (packages/records/src/types.ts) require tile. It is the manifest
record's only retrieval address on the PDS — the manifest lives as an ing.dasl.masl record, not a
root-CID-addressable blob — so resolveTarget() (packages/records/src/resolve.ts) cannot reach
the manifest without it.
1.3 ing.dasl.masl — the manifest record, a.k.a. the "tile" record (§4.3)
Value type: packages/records/src/types.ts MaslRecordValue. This is the upstream Web Tiles
record adopted as-is (https://dasl.ing/tiles.html). It carries the site's whole file tree inline.
| Field | Type | Req. | Purpose |
|---|---|---|---|
$type |
"ing.dasl.masl" |
yes | Record type. |
cid |
cid-link | yes | The dCBOR42 CID of the inline MASL (tile below) = the mini-site root CID = the deployment's root. A self-describing field: it names the CID of the very document stored alongside it. |
tile |
MaslDocument |
yes | The inline MASL document — the path→blob map and display fields (§1.3.1). |
prev |
cid-link | no | cid-link to the previous version's manifest CID, for lineage. Omitted on first publish. |
createdAt |
datetime string | yes | Creation timestamp. |
Note the field named tile inside this record holds the inline MaslDocument; that is a different
use of the word "tile" from the deployment's tile StrongRef (which points at this record) and
from the gateway's "tile serve mode" (§1.5).
1.3.1 The inline MASL document (MaslDocument)
Type: packages/core/src/types.ts MaslDocument; assembled by
packages/core/src/masl.ts assembleMasl(). Top level is exactly
{ name, resources, <optional display + routing when set> } — no implementation-defined fields
(§3.3, §3.7).
| Field | Type | Req. | Purpose |
|---|---|---|---|
name |
string | yes | Mini-site name (also the app-manifest name). Must be non-empty (assembleMasl() throws otherwise). |
resources |
map path → entry | yes | The path→blob map. Keys are absolute /-rooted paths; must include a / entry aliasing the entry document. See below. |
short_name, theme_color, background_color, description, categories, icons |
display fields | no | Shared display metadata used to derive both the tile manifest and the synthesized web app manifest (§3.3). Every icons[].src MUST reference a path present in resources (assembleMasl() validates this). |
routing |
Routing |
no | Optional redirect/rewrite/fallback/404 rules (§9.9). Part of the canonical MASL, so it is committed to by the root CID. |
Each resources entry (packages/core/src/types.ts MaslResourceEntry):
| Field | Type | Req. | Purpose |
|---|---|---|---|
src |
BlobRef |
yes | atproto blob ref: { "$type": "blob", "ref": { "$link": "<raw CID>" }, "mimeType": "<sniffed type>", "size": <exact bytes> }. ref.$link is the resource's raw CID (§3). |
content-type |
string | yes | The serving content-type, derived deterministically by the extension→type table (packages/core/src/content-type.ts; §3.4). Unknown extensions → application/octet-stream. |
content-security-policy |
string | no | Publisher CSP; applied in site mode only, overridden in tile serve mode (§9.6). |
sourcemap |
string | no | If present, MUST reference a path within the same resources map (assembleMasl() rejects an out-of-map sourcemap). |
mimeType vs content-type — deliberately different fields (packages/core/src/masl.ts
blobRef(), packages/core/src/types.ts BuiltBlob.blobMimeType): src.mimeType is the
byte-sniffed type a conforming PDS actually stores (recognized binary formats like PNG/PDF/WASM,
else application/octet-stream), because a PDS rejects an applyWrites whose blob-ref mimeType
disagrees with what it sniffed (InvalidMimeType). content-type is the serving type from the
extension table. They differ for every text resource (e.g. the / HTML entry is
mimeType: application/octet-stream, content-type: text/html) and coincide for sniffable binaries.
A verifier must not assume they are equal.
1.4 The StrongRef / cid-link links between records
pub.mini.site ──current (StrongRef: uri+cid)──▶ pub.mini.deployment
│
├─ root (cid-link) ─────────┐ (equal CIDs)
│ ▼
└─ tile (StrongRef: uri+cid) ▶ ing.dasl.masl
│ cid (cid-link) ── equals root ↑
│ tile ─▶ inline MaslDocument
│ resources[path].src.ref
│ (cid-link) ──▶ raw blob CID
▼
per-resource `raw` blobs (getBlob)
Two distinct CIDs pin two distinct things (§7.2, packages/records/src/recompute.ts header comment):
- (a) root / manifest CID — the dCBOR42 CID of the inline MASL. It is the mini-site's canonical
content identity. It appears three places that a verifier cross-checks for equality: deployment
root, theing.dasl.maslrecord'scidfield, and the value you get by re-encoding the inline MASL (packages/records/src/recompute.tsrecomputeRootCid()). - (b) manifest-record CID — the atproto CID of the whole
ing.dasl.maslrecord value. It is pinned by the deployment'stile.cidStrongRef and recomputed bypackages/records/src/recompute.tsrecomputeRecordCid(). It identifies the signed record that carries the content, not the content itself.
Because deployment and manifest records are created in one atomic applyWrites, the
manifest-record CID (b) MUST be precomputed client-side before the commit so the deployment's
tile StrongRef can name it — it cannot be a server round-trip (§7.2 "Strong ref in one commit").
1.5 "tile" is overloaded — two unrelated meanings
The word "tile" appears in two places that are not the same mechanism. Both descend from the upstream Web Tiles framing (a whole mini-site embeddable as a "tile"), but they operate at different layers:
The "tile" record / the deployment's
tilefield — a CONTENT concept. Theing.dasl.maslrecord is called the "tile record" (§4.3), and the deployment field that points at it is namedtile. Here "tile" = the MASL manifest of the site's file tree. It is not a UI tile, an image tile, or a thumbnail. It is the inline path→blob map."tile mode" serving — a SERVING / execution-context concept. The gateway can serve the same verified bytes under a locked-down, sandboxed, non-installable header set ("tile mode") instead of the networked-PWA "site mode". Per-request selection is a leading path prefix
TILE_PREFIX=/.well-known/minisite/tile/(packages/server/src/request.tsparseRequest()/TILE_PREFIX), which is stripped before host/path demux and forcesmode = "tile". Tile mode affects only response headers — the locked set inpackages/gateway/src/headers.tstileHeaders()/TILE_HEADERS(sandbox,manifest-src 'none', CSP override) — never which bytes are served.
How they relate: they don't interact. The manifest record ("tile record") is resolved and
verified identically regardless of serve mode; serve mode only changes the headers wrapped around the
already-resolved, already-verified resource bytes. A request to
…/.well-known/minisite/tile/page.html resolves /page.html through the same ing.dasl.masl
manifest as …/page.html would, then applies tileHeaders() instead of siteHeaders(). Do not
read the deployment's tile StrongRef as anything to do with tile mode; it names the manifest
record and nothing else.
2. The resolution chain
Resolution turns any mini-site target AT-URI into a common
{ site?, deployment?, manifest } context. The implementation is
packages/records/src/resolve.ts resolveTarget(). It fetches raw records through the RepoReader seam
(packages/records/src/types.ts RepoReader.getRecord / getBlob) and performs no integrity
checks itself — every referenced CID is re-derived and compared later by the caller
(@minisite/verify or the gateway). Shape violations throw
packages/records/src/types.ts ResolveError; an authoritatively-absent record throws
RecordNotFoundError.
resolveTarget() dispatches on the target's collection NSID
(packages/records/src/resolve.ts COLLECTION = { site, deployment, masl }):
- Target is
pub.mini.site: fetch it (fetchRecord()), read itscurrentStrongRef (readStrongRef(ref.value, "current", …)), and followcurrent.urito the deployment. The site record is retained in the result assite. - Target is
pub.mini.deployment: use the target URI directly as the deployment URI (no site context —siteis absent from the result, and the site→deployment check is later skipped). - Target is
ing.dasl.masl: fetch it and return{ did, manifest }immediately — a manifest-only resolution with no signed deployment context.
For cases 1 and 2, it then:
- Fetches the deployment record (
fetchRecord(reader, deploymentUri)). - Reads the deployment's
tileStrongRef (readStrongRef(depRef.value, "tile", …)), and - Follows
tile.urito fetch theing.dasl.maslmanifest record (fetchRecord(reader, tile.uri)).
The result (packages/records/src/resolve.ts ResolvedTarget) is
{ did, site?, deployment?, manifest }, each of site / deployment / manifest being a
Resolved<T> = { ref: FetchedRecord, value: T }.
Resolution follows AT-URIs, not CIDs. resolveTarget() walks current.uri and tile.uri; the
cid halves of those StrongRefs are not checked during resolution — they are the caller's job.
That separation is deliberate: resolution is a pure fetch/shape layer, integrity is a separate pass
(§5).
Worked resolution walkthrough
Target: at://did:plc:abc/pub.mini.site/3kxyz (a site pointer).
parseAtUri()→{ did: did:plc:abc, collection: pub.mini.site, rkey: 3kxyz }.getRecord("at://did:plc:abc/pub.mini.site/3kxyz")→SiteRecordValue. Readcurrent={ uri: "at://did:plc:abc/pub.mini.deployment/3kdep", cid: "bafyrei…DEP" }.getRecord(current.uri)→DeploymentRecordValue. Readroot(cid-linkbafyrei…ROOT) andtile={ uri: "at://did:plc:abc/ing.dasl.masl/3kman", cid: "bafyrei…REC" }.getRecord(tile.uri)→MaslRecordValue. Itscidfield isbafyrei…ROOT; itstilefield is the inlineMaslDocumentwithresources.- Result:
{ did, site: {…}, deployment: {…}, manifest: {…} }. The manifest'sresourcesmap is now the source for path→blob-CID lookups (§4); the CIDs collected here (current.cid,tile.cid,root,cid) are what §5 cross-checks.
3. Encodings & CID codecs
3.1 The CID profile — only two codecs
All CIDs are CIDv1, base32-lowercase (b-prefixed), SHA-256 multihash, using only two
codecs (§3.2, packages/core/src/cid.ts):
| Codec | Hex | Used for | Producer |
|---|---|---|---|
raw |
0x55 (85) |
Each file's exact bytes → its blob CID. Path is not part of file identity. | packages/core/src/cid.ts rawCid() |
dag-cbor |
0x71 (113) |
The MASL document → the root CID; and any whole record value → its atproto CID. | packages/core/src/cid.ts dagCbor() |
mini-site-spec.md §3.2 states this is a strict subset of IPFS CIDs: "No other CID shapes are
valid."
There is no UnixFS / dag-pb here. Each file is hashed as a single raw block over its whole
byte range — no chunking, no UnixFS DAG, no dag-pb (0x70) links. A verifier re-hashes the exact
blob bytes with SHA-256 and compares against the single raw CID
(packages/verify/src/verify.ts verify() resource loop → recomputeBlobCid()); it never walks a
block DAG. (The string dag-pb appears in the repo only inside third-party CID libraries bundled
into packages/verify/dist/sw.js; no Szzt source path constructs or consumes a dag-pb CID.) An
implementer should treat "large file → UnixFS chunking" as out of model: a file is one raw block,
full stop, and atproto blob storage (com.atproto.sync.getBlob) serves it as one raw blob.
3.2 The dCBOR42 / DASL encoding stack
The MASL document and every record value are encoded with dCBOR42 (DASL's DRISL profile), via
@atcute/cbor (packages/core/src/cid.ts imports encode from @atcute/cbor). This encoder:
- sorts map keys and omits
undefinedvalues (so "field absent" and "field present" are unambiguous — the reason optional MASL fields are omitted, nevernull); - emits tag-42 for cid-links (
{ $link }), rejecting all other tags.
This is not @ipld/dag-cbor. packages/core/src/cid.ts warns in-line that @ipld/dag-cbor
"produces different bytes"; using it would fork the root CID and break interop (§3.7). A conforming
publisher and any conforming verifier MUST use the DRISL encoder so they agree byte-for-byte.
packages/core/src/cid.ts dagCbor(obj) returns both the encoded bytes and their dag-cbor CID,
so the same encoding that produced the CID is the encoding written to the record — there is no risk
of a CID computed from one encoding and bytes stored from another.
3.3 The two-CBOR-encoder boundary (verifier side)
Inside the reference verifier's signed-repo check
(packages/verify/src/car.ts verifyCarRecord()), two CBOR encoders coexist in one process, and
which one owns which side is load-bearing (see the BOUNDARY block at the top of car.ts):
@ipld/dag-cbor(pulled in by@atproto/repo/@atproto/lex-cbor) owns everything repo-shaped: CAR block CID checks, decoding MST nodes and record blocks, and — critically — re-encoding the unsigned commit insideverifyCommitSig, because dag-cbor is the encoding the PDS actually signed. Verifying that signature with the DRISL encoder could produce different preimage bytes and reject a genuine commit.@atcute/cbor(dCBOR42/DRISL) (@minisite/core) owns the Szzt-computed CIDs: the MASL root CID andrecomputeRecordCid. Those are Szzt's encodings of Szzt's documents.
The invariant that keeps the two from ever having to agree: car.ts never re-encodes a block to
derive a CID. Every CID it reports is the CID the block arrived filed under, and
readCarWithRoot has already checked the received bytes hash to it (skipCidVerification is
deliberately not passed). So a VerifiedRecord's cid is a property of the bytes on the wire, not
of any encoder linked into the process.
Consequence for an implementer: handing the decoded record value to recomputeRecordCid and
comparing against the CAR-reported cid crosses the encoder boundary — that comparison tests
encoder equivalence (does DRISL reproduce the same CID the signed MST filed?), not repo integrity.
The signed-repo chain in car.ts is already complete without it; the DRISL re-derivation in
verify.ts is a separate consistency layer.
3.4 How a file's bytes map to a CID
- File → blob CID: take the file's exact bytes (after build-time synthesis; §6). Hash to a
rawCIDv1 (rawCid()). This CID is the resource'ssrc.ref.$linkin the MASL and the blob CID under which the PDS stores it. Path and content-type are not inputs to this CID. - MASL document → root CID: assemble the canonical
MaslDocument(assembleMasl()), dCBOR42- encode it (dagCbor()), hash to adag-cborCIDv1. That is the root CID. - Whole record value → record CID: dCBOR42-encode the IPLD-JSON record value (cid-links as
{ $link }, StrongRefcidas plain strings, optionals omitted), hash to adag-cborCIDv1 (recomputeRecordCid()). That is the atproto record CID a StrongRef pins.
4. Records as bytes on the wire
4.1 How the manifest enumerates files
The inline MASL's resources map (assembleMasl()) is the file enumeration. It is built as:
- a
/entry first, aliasing the entry document (resources["/"]), then - one entry per built blob keyed by its
/-rooted path (packages/core/src/build.tsbuild()sorts blobs by path for determinism before assembly).
Each entry's src is a BlobRef whose ref.$link is the resource's raw CID and whose size is
the exact byte length. So the manifest is a complete, self-contained directory: every servable path,
its content CID, its byte size, and its serving content-type, with nothing left to a filesystem or a
naming convention.
4.2 How a served path resolves to a block CID
At serve time the gateway (and the browser SW verifier) holds the resolved manifest's resources
map. For a request path:
- Normalize the path and apply
routing(rewrite / fallback / 404) if present (§9.9) — routing only ever resolves to a path that is inresources, so it can never serve unverified content. - Look up the (possibly rewritten) path in
resources→ the entry'ssrc.ref.$linkis the block CID to fetch. - Fetch that blob by CID (
com.atproto.sync.getBlob, subject to the SSRF guard; cached by CID), re-hash the bytes with SHA-256, and compare against the CID's multihash. Fail closed on mismatch — the byte-hash-verify invariant isdocs/gateway-conformance.md§3. - Respond with the entry's
content-typeplus the mode-appropriate header set (siteHeaders()ortileHeaders()).
The reference credible-exit verifier does the same resolution offline over every resource in
verify() (§5), rather than one path per request.
RASL-by-CID (/.well-known/rasl/<cid>) is the same content, keyed directly by a bare content CID
instead of a path; the gateway builds a CID→location index only from signed state (§9.2) so it can
serve a CID only for content it has verifiably resolved. That transport is a gateway concern —
docs/gateway-conformance.md §3/§7a covers it.
4.3 What the root CID commits to
Because the root CID is the dCBOR42 CID of the canonical MASL, and the MASL enumerates every
resource by its raw CID + size + content-type + display/routing metadata, the root CID
transitively commits to:
- the exact bytes of every resource (via each
src.ref.$linkraw CID), - each resource's exact byte size and serving
content-type, - the set and spelling of every servable path (the
resourceskeys, including the/alias), - the site
nameand all display fields (short_name,theme_color,icons, …), and - the
routingrules (redirects/fallback/404), which are part of the canonical MASL.
Critically, synthesis precedes hashing (packages/core/src/build.ts build() runs synthesize
before computing any CID; §6). So the root CID commits to the finished, self-describing app —
including the synthesized web app manifest, SW registration, and neutralized author SW — and the
gateway never mutates served bytes. Identical input → identical root CID (build() is pure: no
clock, RNG, network, or filesystem), which is what makes root CIDs stable permalinks and version
identities that survive a PDS migration (§11.5, §3.7).
What the root CID does not commit to: the signed atproto records themselves. The site pointer,
the deployment, and the manifest record's envelope (its createdAt, prev, $type) are outside
the MASL and are pinned by the record CIDs / StrongRefs instead (§1.4). Two publishes of identical
content produce the same root CID but distinct deployment and manifest-record CIDs.
5. Worked end-to-end verification walkthrough
The reference verifier (packages/verify/src/verify.ts verify()) is the canonical consumer of this
content model. It re-derives every CID and trusts no source assertion. Given a resolved target
(§2), its checks — in order — are:
deployment-record-cid == site.current.cid—recomputeRecordCid(deployment.ref.value)equals the site'scurrent.cid. Runs only when the target was a site (otherwise nocurrentto compare; the check is absent, not failed).manifest-record-cid == deployment.tile.cid—recomputeRecordCid(manifest.ref.value)equals the deployment'stile.cid. Skipped (→ "incomplete", never "pass") when the target was a bare manifest with no deployment context.root-cid == manifest.cidandroot-cid == deployment.root—recomputeRootCid(manifest.value.tile)equals both the manifest record's owncidfield and (when present) the deployment'sroot. 2b. manifest has at least one resource — an emptyresourcesmap is a fail, not a vacuous pass. 2c.deployment.entryresolves to a resource — a dangling entry passes every CID check yet would 404 at serve time, so it is validated here.- every resource blob — for each
resourcesentry,getBlob(did, src.ref.$link), thenrecomputeBlobCid(bytes)must equal the asserted CID andbytes.lengthmust equalsrc.size.
A skip is not a pass (packages/verify/src/verify.ts VerifyOutcome): the outcome is "pass"
only when nothing failed and nothing skipped; a skip with no failure downgrades to "incomplete".
This is what lets a consumer trust a gateway's bytes without trusting the gateway.
Signature layer (optional, separate). The above proves the chain is internally consistent
(every CID re-derives). Proving the records are genuinely in the repo owner's signed tree — so a
hostile PDS/gateway cannot substitute a record — is packages/verify/src/car.ts verifyCarRecord(),
which verifies a com.atproto.sync.getRecord proof CAR: every block hashes to its filed CID, the
root is a v3 commit for the expected DID, the commit sig verifies under the expected signing key,
and the MST path from the signed commit.data root resolves to the record leaf. Only that pass
mints a branded VerifiedRecord (unforgeable outside car.ts). The verify() CID chain and the
car.ts signature chain are complementary: one proves content consistency, the other proves
signed provenance.
Code vs. spec notes (divergences and things to flag)
"tile" naming is genuinely overloaded (§1.5) — the record/
tile-field meaning (the MASL manifest) and the tile-mode serving meaning (sandbox headers) share a name but no mechanism. This is faithful tomini-site-spec.md(which uses "tile record" in §4.3 and "tile mode" in §9.6), not a code/spec disagreement, but it is the single most likely thing to confuse a new reader, so it is called out explicitly.No UnixFS/
dag-pbpath exists in Szzt source (§3.1). If a reader expects large files to chunk into a UnixFS DAG (as general IPFS does), that expectation is wrong for this model: one file = onerawblock. The onlydag-pbstrings in the tree are inside bundled third-party libraries inpackages/verify/dist/sw.js.