← szzt · All docs

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:


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):

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:

  1. The "tile" record / the deployment's tile field — a CONTENT concept. The ing.dasl.masl record is called the "tile record" (§4.3), and the deployment field that points at it is named tile. 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.

  2. "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.ts parseRequest() / TILE_PREFIX), which is stripped before host/path demux and forces mode = "tile". Tile mode affects only response headers — the locked set in packages/gateway/src/headers.ts tileHeaders() / 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 }):

  1. Target is pub.mini.site: fetch it (fetchRecord()), read its current StrongRef (readStrongRef(ref.value, "current", …)), and follow current.uri to the deployment. The site record is retained in the result as site.
  2. Target is pub.mini.deployment: use the target URI directly as the deployment URI (no site context — site is absent from the result, and the site→deployment check is later skipped).
  3. 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:

  1. Fetches the deployment record (fetchRecord(reader, deploymentUri)).
  2. Reads the deployment's tile StrongRef (readStrongRef(depRef.value, "tile", …)), and
  3. Follows tile.uri to fetch the ing.dasl.masl manifest 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).

  1. parseAtUri(){ did: did:plc:abc, collection: pub.mini.site, rkey: 3kxyz }.
  2. getRecord("at://did:plc:abc/pub.mini.site/3kxyz")SiteRecordValue. Read current = { uri: "at://did:plc:abc/pub.mini.deployment/3kdep", cid: "bafyrei…DEP" }.
  3. getRecord(current.uri)DeploymentRecordValue. Read root (cid-link bafyrei…ROOT) and tile = { uri: "at://did:plc:abc/ing.dasl.masl/3kman", cid: "bafyrei…REC" }.
  4. getRecord(tile.uri)MaslRecordValue. Its cid field is bafyrei…ROOT; its tile field is the inline MaslDocument with resources.
  5. Result: { did, site: {…}, deployment: {…}, manifest: {…} }. The manifest's resources map 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:

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):

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

  1. File → blob CID: take the file's exact bytes (after build-time synthesis; §6). Hash to a raw CIDv1 (rawCid()). This CID is the resource's src.ref.$link in the MASL and the blob CID under which the PDS stores it. Path and content-type are not inputs to this CID.
  2. MASL document → root CID: assemble the canonical MaslDocument (assembleMasl()), dCBOR42- encode it (dagCbor()), hash to a dag-cbor CIDv1. That is the root CID.
  3. Whole record value → record CID: dCBOR42-encode the IPLD-JSON record value (cid-links as { $link }, StrongRef cid as plain strings, optionals omitted), hash to a dag-cbor CIDv1 (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:

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:

  1. Normalize the path and apply routing (rewrite / fallback / 404) if present (§9.9) — routing only ever resolves to a path that is in resources, so it can never serve unverified content.
  2. Look up the (possibly rewritten) path in resources → the entry's src.ref.$link is the block CID to fetch.
  3. 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 is docs/gateway-conformance.md §3.
  4. Respond with the entry's content-type plus the mode-appropriate header set (siteHeaders() or tileHeaders()).

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:

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:

  1. deployment-record-cid == site.current.cidrecomputeRecordCid(deployment.ref.value) equals the site's current.cid. Runs only when the target was a site (otherwise no current to compare; the check is absent, not failed).
  2. manifest-record-cid == deployment.tile.cidrecomputeRecordCid(manifest.ref.value) equals the deployment's tile.cid. Skipped (→ "incomplete", never "pass") when the target was a bare manifest with no deployment context.
  3. root-cid == manifest.cid and root-cid == deployment.rootrecomputeRootCid(manifest.value.tile) equals both the manifest record's own cid field and (when present) the deployment's root. 2b. manifest has at least one resource — an empty resources map is a fail, not a vacuous pass. 2c. deployment.entry resolves to a resource — a dangling entry passes every CID check yet would 404 at serve time, so it is validated here.
  4. every resource blob — for each resources entry, getBlob(did, src.ref.$link), then recomputeBlobCid(bytes) must equal the asserted CID and bytes.length must equal src.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)

← All docs