This specification profiles [[WACZ]]. It constrains how a WACZ package is written and defines the additional members BrowserHive records inside it, so that a reader holding only the archive can tell what the capture obtained, what it did not, and under what settings it ran.

This is a working draft. It describes what BrowserHive writes today. It has no standing beyond that project and is not endorsed by the publishers of [[WACZ]].

This version adds settle, which a conforming package is required to carry: the package's own account of how the wait after navigation ended, and whether the page had settled when the capture went on. Nothing else changes; the profile of storage stays "browserhive:storage/2". Because the new member is required, a package written against 1.6.0 does not conform to 1.7.0. This is intended, and it is why conformsTo names a version: a reader holding an older package can still tell what it was written against. Earlier versions — 1.0.0, 1.1.0, 1.2.0, 1.3.0, 1.4.0, 1.5.0 and 1.6.0 — remain published at their own URLs for that reason, but BrowserHive no longer produces them.

A capture has to decide when a page is ready to be taken, and every way of deciding is a bet: a fixed pause robs a page that was ready and cuts short one that was not, and a signal-based wait can reach its deadline first. Until this version a package could not say which of these happened. That matters to a reader weighing the package as evidence, because the difference between “the page looked like this” and “the page looked like this while it was still loading” is recoverable from nothing else in the package: the records say what arrived, not what was still on its way when the capture moved on.

Introduction

A [[WACZ]] package says what was captured. It does not say what was missed, nor under what conditions the capture ran. Those facts are knowable only at capture time, and they are lost once the capturing process exits.

That loss matters because the archive is the thing that travels. A reader opening a package three years later has no access to the logs, the API response, or the machine that produced it. If the package cannot answer “is anything missing here?”, nothing else can.

This profile therefore requires a package to carry its own account of the capture: which bodies are absent and why, which limits were in force, which requests it declined to send and which it sent but did not keep, what it removed from the page before recording it, which certificates the origins presented. None of these change what [[WACZ]] means. They record what would otherwise be unrecoverable.

Conformance

The conformance target of this specification is a package: a single file. Conformance is a property of files, not of the software that writes them. An implementation may write conforming and non-conforming packages at different times, and this specification says nothing about which.

A conforming package is a conforming [[WACZ]] package. This specification adds requirements. It removes none, and it relaxes none.

Where this specification uses the terms MUST, MUST NOT, SHOULD and MAY, they are to be interpreted as described in [[RFC2119]].

JSON member names are case-sensitive. Where a member is described as holding a timestamp, its value MUST be an [[RFC3339]] date-time in UTC.

The browserhive:capture member

The datapackage.json of a conforming package MUST contain a member named browserhive:capture whose value is a JSON object.

This member MUST NOT be nested inside config or metadata. Those names carry meaning for [[WACZ]] readers; see .

Members of browserhive:capture not defined here MAY be present. Readers MUST ignore members they do not recognise.

Conformance declaration

browserhive:capture MUST contain a member conformsTo whose value is the URL of the version of this specification the package claims to satisfy.

"browserhive:capture": {
  "conformsTo": "https://uraitakahito.github.io/browserhive-specs/wacz-profile/1.7.0/"
}

The declaration is carried here rather than in the profile member of datapackage.json, which is not available for this purpose.

A reader MUST NOT infer conformance from the mere presence of browserhive:capture. Without conformsTo, the version a package was written against is unknown.

build

browserhive:capture MUST contain a member build identifying, in machine-readable form, the software that produced the package.

MemberTypeRequiredDescription
versionstringyesRelease identifier.
revisionstringyesSource revision the build was made from.
buildTimestringyesWhen the software was built.
"build": {
  "version": "3.4.0",
  "revision": "0776cae",
  "buildTime": "2026-08-21T04:04:09.000Z"
}

The format of version is not constrained. A build made from a release carries something like "3.4.0", while a build made between releases may carry a value that includes its distance from the last one, such as "3.4.0-12-gabc1234". Both say which build produced the package. A reader should treat the value as an identifier — equal values mean the same build — rather than parse it as a version number.

[[WACZ]] already defines software. That member is prose, and no two producers write it alike, so nothing parses it. build exists so the same facts can be read by machine without guessing at a format.

browser

browserhive:capture MUST contain a member browser describing the browser that performed the capture. It MUST contain a member product whose value is a string.

"browser": {
  "product": "Chrome/151.0.7922.137"
}

The spelling is not defined here. The value is whatever the browser names itself as, so a package produced by a different engine carries a different shape. A reader can compare two values for equality; it cannot assume a grammar.

The value MUST NOT be a placeholder. A producer that cannot obtain it MUST NOT produce a conforming package: an archive whose recording browser is unknown is outside this profile, and omitting the member is not a way to say so.

settings

browserhive:capture MUST contain a member settings recording the settings that were in force for this capture.

Values MUST be the resolved values, not the values a request asked for. A request that says nothing still runs under some setting, and it is that setting the archive has to report.

MemberTypeRequiredDescription
signaturestringyes Either "required" or "none": whether this capture was permitted to be published unsigned.
viewportobjectyes Members width and height, both integers, in CSS pixels.
devicePixelRatiosarray of numberyes The device pixel ratios the page was loaded at, in the order it was loaded. The number of entries is the number of loads.
sessionstringyes Whether the capture ran in a browser context created for it and discarded afterwards, or in one the capturing process reuses across captures. It tells a reader whether the package could depend on state left by an earlier capture.
behaviorsarray of objectyes Behaviors that actually ran, not those that were enabled, in the order they ran. Each entry carries id, the name the behavior was run under, and origin, one of "builtin", "site" or "custom". See the behaviors directory.
limitsobjectyes The body caps that were in force for this capture. Members maxResponseBytes and maxTaskBytes, both integers, in bytes.
urlPoliciesarray of objectyes What the capture did about each URL pattern that was in force, in the order the patterns were evaluated. See urlPolicies.
contentTypePoliciesarray of objectyes What the capture did about each media type that was in force. See contentTypePolicies.
acceptLanguagestringno Present only when the capture set one, since it changes what origins return.
"settings": {
  "signature": "none",
  "viewport": { "width": 1280, "height": 800 },
  "devicePixelRatios": [1],
  "session": "isolated",
  "behaviors": [
    { "id": "autoscroll", "origin": "builtin" },
    { "id": "autofetch", "origin": "builtin" }
  ],
  "limits": {
    "maxResponseBytes": 20971520,
    "maxTaskBytes": 209715200
  },
  "urlPolicies": [
    { "pattern": "*://*.example-analytics.com/*", "action": "deny" }
  ],
  "contentTypePolicies": []
}

In this example session records that the capture ran in a context of its own, and the single entry in devicePixelRatios that it loaded the page once. The two entries under behaviors are the behaviors that ran — not the ones that were enabled. acceptLanguage is absent because this capture set none. The values themselves are the producer's to choose; this profile does not define the spellings shown here.

The exception is origin. A behavior's id is a name its supplier chose and this specification says nothing about how it is spelled, but origin answers a question about the package rather than about the behavior — whether the code came from the producer or from the request — so its three values are defined here.

devicePixelRatios is a list rather than a number because a capture may load the page more than once. Recording a single value would tell a reader that one rendering is absent when it is present.

The values MUST appear in the order the page was loaded, so that a reader can say not only which renderings the archive holds but which one was drawn last — artefacts that carry the page's appearance follow the state left by the final load.

A capture that loads the page twice carries one value per load. A request that set acceptLanguage shows it here as well:

"settings": {
  "signature": "required",
  "viewport": { "width": 1280, "height": 800 },
  "devicePixelRatios": [1, 2],
  "session": "isolated",
  "behaviors": [
    { "id": "autoscroll", "origin": "builtin" },
    { "id": "example.com:gallery", "origin": "custom" }
  ],
  "acceptLanguage": "ja,en;q=0.9",
  "limits": {
    "maxResponseBytes": 20971520,
    "maxTaskBytes": 209715200
  },
  "urlPolicies": [],
  "contentTypePolicies": [{ "prefix": "video/", "action": "no-body" }]
}

The two measure different things. maxResponseBytes applies to the body of a single response; maxTaskBytes applies to the running total of the bodies written into this package.

The difference matters to a reader. maxResponseBytes weighs one response at a time, so any number of responses under it are kept and the size of the package cannot be inferred from it. maxTaskBytes watches the accumulated total, so once the line is crossed bodies are dropped regardless of size — a small response among the dropped ones is not a contradiction.

limits MUST be present even when no body was dropped. It is the only place the archive can say which line a truncated body crossed — a record of a truncation carries the fact and the actual size, but whether that size was too much is only legible next to the line.

The truncatedUrls member of completeness does not distinguish which of the two a body crossed, even though the remedies differ (one response too large versus the whole page too heavy). A reader who needs the distinction has to look outside the package, at what the capture reported.

completeness

browserhive:capture MUST contain a member completeness, the package's own account of the bodies it does not hold.

MemberTypeRequiredDescription
bodylessUrlsarray of stringyes URLs that appeared only as 304. The body never crossed the wire, so the archive cannot hold it.
truncatedUrlsarray of stringyes URLs whose body the capture dropped on reaching a size limit.
completebooleanyes true if and only if both arrays are empty.
"completeness": {
  "bodylessUrls": [],
  "truncatedUrls": [],
  "complete": true
}

A capture that lost nothing takes this shape. complete is derived from the two arrays, so two empty arrays alongside false never occur.

A capture missing bodies for both reasons:

"completeness": {
  "bodylessUrls": [
    "https://example.com/assets/app.css"
  ],
  "truncatedUrls": [
    "https://example.com/media/hero.mp4",
    "https://example.com/media/poster.png"
  ],
  "complete": false
}

Here the stylesheet appeared only as a 304: its body never crossed the wire, so the archive cannot hold it. The video and its poster were dropped at a limit. A URL dropped twice still appears once.

Both arrays MUST be sorted, so that two captures of the same page produce comparable output.

The two are separate members because they call for different responses. A 304 is the origin's doing and cannot be recovered from here; a body dropped at a limit is this side's doing and returns if the limit is raised.

Responses that legitimately carry no body MUST NOT be listed: redirects, 204, and bodies omitted by a content-type filter the requester configured. Reporting the last of these would tell the reader only what they had already decided.

coverage

browserhive:capture MAY contain a member coverage describing how far down the page the capture reached.

MemberTypeRequiredDescription
scrollExhaustedbooleanyes true when scrolling stopped at a step limit rather than at the end of the page — that is, when material below was never requested.
scrollStepsnumberyesSteps actually taken.
scrolledPxnumberyes Approximate depth reached: steps multiplied by viewport height.
"coverage": {
  "scrollExhausted": true,
  "scrollSteps": 40,
  "scrolledPx": 32000
}

The three values are read together. scrolledPx is scrollSteps multiplied by the viewport height recorded in settings — 40 × 800 here — so a reader can check one against the other. And because scrollExhausted is true, the 32,000 pixels are where the capture stopped, not where the page ended: whatever lay below was never requested, and therefore leaves no trace anywhere else in the package.

coverage MUST be absent when no scrolling behavior reported. It MUST NOT be written with default values: scrollExhausted: false asserts that the page was covered, and a capture that never scrolled has not earned that claim.

settle

browserhive:capture MUST contain a member settle stating how the wait after navigation ended: on the page settling, or on a deadline with the page still changing.

MemberTypeRequiredDescription
strategystringyes Which signals were watched, in what order. This version defines "fully-loaded": load, then a pause of limits.minMs, then network, cpu and dom together.
endedBystringyes "quiet" when every watched signal was satisfied before the deadline; "deadline" otherwise.
waitedMsnumberyes Milliseconds from the navigation returning to the wait ending.
devicePixelRationumberyes Which load this observation is of; one of the entries of devicePixelRatios. When there are several, the last.
limitsobjectyes The bounds in force, as numbers: minMs, maxMs (the deadline), quietMs (the window every quiet signal is held to) and networkConcurrency (requests still counted as quiet).
loadobjectyes atMs: when the document was seen fully loaded.
networkobjectyes quietAtMs: when no more than limits.networkConcurrency requests had stayed in flight for limits.quietMs.
cpuobjectyes quietAtMs: when no long task — a task holding the main thread for 50 ms or more — had run for limits.quietMs.
domobjectyes quietAtMs: when no element had been added or removed and no text rewritten for limits.quietMs. Attribute changes do not count.
"settle": {
  "strategy": "fully-loaded",
  "endedBy": "quiet",
  "waitedMs": 2310,
  "devicePixelRatio": 1,
  "limits": { "minMs": 1000, "maxMs": 3000, "quietMs": 1000, "networkConcurrency": 0 },
  "load": { "atMs": 210 },
  "network": { "quietAtMs": 2205 },
  "cpu": { "quietAtMs": 2104 },
  "dom": { "quietAtMs": 2004 }
}

Every time is in milliseconds from the navigation returning, on the same clock as waitedMs. Pauses a producer inserts between its own operations are not part of that clock.

A signal's time is a number when the signal was satisfied, and null when the deadline came first. The time MUST be written even when it is null. Omitting it instead would read as “this signal was not watched”, which is a different fact.

endedBy is derived from the four times. A producer MUST NOT write "quiet" while any of them is null, nor "deadline" while none is.

A page where the deadline came first:

"settle": {
  "strategy": "fully-loaded",
  "endedBy": "deadline",
  "waitedMs": 3116,
  "devicePixelRatio": 1,
  "limits": { "minMs": 1000, "maxMs": 3000, "quietMs": 1000, "networkConcurrency": 0 },
  "load": { "atMs": 188 },
  "network": { "quietAtMs": 2011 },
  "cpu": { "quietAtMs": 2108 },
  "dom": { "quietAtMs": null }
}

Here the page kept rewriting its text — a clock, say — so the DOM never stayed still for a second, and the capture went on at the three-second deadline. The network and the CPU had settled; only the DOM had not.

endedBy: "deadline" does not mean the capture failed. The package holds what the page had become by then. It records that the page had not settled, which a reader weighing the package as evidence needs to know and cannot recover from anything else in it.

Each quiet signal asks whether the page stayed still for limits.quietMs, not whether it would have stayed still. A page that is silent for longer than the window and then changes reads as settled before the change.

dismissal

browserhive:capture MAY contain a member dismissal recording what the capture removed from the page before recording it, and what it looked for.

A capture that removes a consent banner changes the document it then archives. Nothing else in the package says so: the removed element leaves no trace in the WARC, in the index, or in the rendered snapshot, and a reader cannot tell it from a page that never carried one.

MemberTypeRequiredDescription
selectorsarray of objectyes The selectors that were in force, in the order they were evaluated. Members framework and selector, both strings.
heuristicobjectyes The thresholds that governed removal of elements no selector named. Members enabled (boolean), minViewportCoverageRatio (number) and minZIndex (number).
frameworkstring or nullno The framework of the first entry that matched, or "heuristic" when only the thresholds fired. null when nothing was removed.
removedSelectorsarray of stringno The selectors whose element was removed. A subset of selectors, in evaluation order.
unusableSelectorsarray of stringno Entries of selectors the reader of the page could not evaluate. These matched nothing because they were not valid selectors, which is not the same claim as matching nothing.
removedOverlaysnumberno Elements removed by the thresholds rather than by a selector.
devicePixelRationumberno Which load this observation is of; one of the entries of devicePixelRatios.
unreadabletrueno Present when removal was attempted but could not be carried out.
"dismissal": {
  "selectors": [
    { "framework": "OneTrust", "selector": "#onetrust-banner-sdk" },
    { "framework": "Cookiebot", "selector": "#CybotCookiebotDialog" }
  ],
  "heuristic": { "enabled": true, "minViewportCoverageRatio": 0.3, "minZIndex": 1000 },
  "framework": "OneTrust",
  "removedSelectors": ["#onetrust-banner-sdk"],
  "unusableSelectors": [],
  "removedOverlays": 0,
  "devicePixelRatio": 1
}

dismissal MUST be absent when no removal was attempted. Absence therefore means the page was archived as it was served; it does not mean nothing was found.

When dismissal is present, selectors and heuristic MUST be present even when nothing was removed. They are what makes the outcome checkable: an empty removedSelectors next to twenty entries says the page carried no banner any of them names, while the same empty list next to no entries says only that nothing was looked for. This is the argument that makes urlPolicies mandatory on a capture that filtered nothing.

A package MUST NOT carry unreadable alongside framework, removedSelectors, unusableSelectors or removedOverlays. A removal that could not be carried out has observed nothing, and writing an empty result beside it would assert that the page held no banner.

Scrolling also changes what the capture sees, and what it did is already recorded — by coverage, which states how far down the page it reached. A second account of the same act here would be a copy, and one of the two would eventually be the stale one.

A capture that loads the page more than once removes elements on each load; removal is not consent, so a consent framework re-renders on the next load. removedSelectors is an observation of one document at one moment, not a union across loads, and devicePixelRatio is what ties it to the load it came from.

tls

browserhive:capture MAY contain a member tls recording what the browser saw of each HTTPS connection. If present it MUST contain hosts and chains.

tls.hosts

An object keyed by host. Each value is either an object with the members below, or null.

MemberTypeRequiredDescription
protocolstringyesNegotiated TLS version.
cipherstringyesNegotiated cipher.
subjectstringyesSubject common name, as the browser reported it.
sanarray of stringyes The host names the certificate is valid for (subjectAltName). Empty for a certificate that carries none.
issuerstringyesIssuer common name.
validFromstringyesStart of the certificate's validity period.
validTostringyesEnd of that period.
chainRefstringnoKey into tls.chains. Absent when no chain could be obtained.

A host reached over HTTPS whose connection revealed no details MUST be recorded with the value null. Omitting the key instead would read as “this host was plain HTTP”, which is a different fact.

Host name matching uses san, not subject. subject records the common name, which current readers do not match against — the value of subject MUST NOT be read as “this is the host name it was accepted for”.

A missing chainRef MUST NOT be read as “this host presented no certificate”. Chain retrieval can fail for reasons unrelated to the host.

tls.chains

An object whose keys are the values used by chainRef and whose values are arrays of base64-encoded DER certificates. Each array MUST be ordered leaf first.

Hosts sharing a chain SHOULD share one entry. Certificates are commonly reused across the hosts of one page, and storing a copy per host inflates the package for no gain.

Chains are stored rather than referenced. Certificate Transparency holds the same bytes, but evidence that depends on a third-party service remaining reachable is weaker for that dependence — and a private CA's chain, which is the case worth examining, was never logged there at all.

"tls": {
  "hosts": {
    "example.com": {
      "protocol": "TLS 1.3",
      "cipher": "AES_128_GCM",
      "subject": "example.com",
      "san": ["example.com", "www.example.com"],
      "issuer": "Example Issuing CA",
      "validFrom": "2026-06-30T20:37:58.000Z",
      "validTo": "2026-09-28T21:37:55.000Z",
      "chainRef": "9a57318f8c89976c"
    },
    "cdn.example.net": {
      "protocol": "QUIC",
      "cipher": "AES_128_GCM",
      "subject": "*.example.net",
      "san": ["*.example.net", "example.net"],
      "issuer": "Example Issuing CA",
      "validFrom": "2026-08-05T20:42:14.000Z",
      "validTo": "2026-10-28T20:42:13.000Z",
      "chainRef": "c52653c92800f6fd"
    },
    "img.example.net": {
      "protocol": "TLS 1.3",
      "cipher": "AES_128_GCM",
      "subject": "*.example.net",
      "san": ["*.example.net", "example.net"],
      "issuer": "Example Issuing CA",
      "validFrom": "2026-08-05T20:42:14.000Z",
      "validTo": "2026-10-28T20:42:13.000Z",
      "chainRef": "c52653c92800f6fd"
    },
    "api.example.org": {
      "protocol": "TLS 1.3",
      "cipher": "AES_256_GCM",
      "subject": "api.example.org",
      "san": ["api.example.org"],
      "issuer": "Example Issuing CA",
      "validFrom": "2026-05-14T09:03:11.000Z",
      "validTo": "2026-08-12T09:03:10.000Z"
    },
    "beacon.example.org": null
  },
  "chains": {
    "9a57318f8c89976c": [
      "MIIDbzCCAxSgAwIBAgIRALaMq73qMP3mE30B…",
      "MIICjDCCAhKgAwIBAgIQf5wJDMcM1Y2ZBpFB…"
    ],
    "c52653c92800f6fd": [
      "MIIIUDCCBzigAwIBAgIQHnJSlrCYIdkSw8c/…",
      "MIIFCzCCAvOgAwIBAgIQVJ5xIVMKmZ0sNHxs…"
    ]
  }
}

The example records five hosts. Each of the rules above shows up in it.

Values in chains are base64-encoded DER. They are cut short here for readability; a real certificate is a string of roughly 1–2 KB.

URL and media type policies

A capture may treat some requests differently from the rest: it may decline to send them, decline to record them, or record them without their bodies. browserhive:capture.settings MUST record what it did, so that a reader can tell which of those happened without access to the capture's configuration.

urlPolicies

urlPolicies is an ordered array. Each entry is an object with two members:

MemberTypeRequiredDescription
patternstringyes The pattern that was in force, spelled exactly as the capture was configured with it.
actionstringyes One of the values below.
actionRequest sentWhat the package holds
denyno A record of the request only. Nothing left the capturing machine for that URL, so no origin learned of the capture through it.
no-archiveyes A record of the request only. The origin answered; the package does not keep the answer.
no-bodyyes The request and response records, without the response body.

The array MUST be present even when it is empty, and an empty array MUST mean that no policy was in force. Without that rule a reader cannot separate a page that requested nothing unusual from a capture that declined to record what it requested, and those are different claims about the same package.

The order is the order in which the capture evaluated the patterns, and a capture MUST apply the first entry whose pattern matches. Recording the order is what lets a reader answer “which policy applied to this URL?” when more than one pattern would match it.

"urlPolicies": [
  { "pattern": "*://cdn.example.com/tracker.js", "action": "deny" },
  { "pattern": "*://cdn.example.com/*",          "action": "no-body" }
]

In this example a request for tracker.js is never sent, while everything else on the same host is fetched and recorded without its body. Reversing the two entries would change what the capture did: the broader pattern would match first, and the tracker would be fetched.

contentTypePolicies

contentTypePolicies is an array whose entries carry a prefix (matched against the response's media type) and an action. no-body is the only action this version defines: a media type is known only once the response has arrived, so deny cannot apply.

The array MUST be present even when it is empty, for the same reason as urlPolicies.

"contentTypePolicies": [
  { "prefix": "video/", "action": "no-body" }
]

Records of withheld requests

For each request to which a deny or no-archive policy applied, the package MUST contain a WARC metadata record, hereafter a record of a withheld request. Its WARC-Target-URI MUST be the request URL, and its application/warc-fields body MUST contain:

FieldRequiredDescription
actionyes The action that applied: deny or no-archive. This is the field that says whether the request was sent.
patternyes The pattern that matched, spelled exactly as it appears in urlPolicies, so that a reader can find it there.
methodyes The request method.
resourceTypeno What the page was fetching, when the capture knows it.
WARC/1.1
WARC-Type: metadata
WARC-Target-URI: https://www.example-analytics.com/collect?v=2
WARC-Date: 2026-09-06T02:14:31.882Z
Content-Type: application/warc-fields
Content-Length: 112

action: deny
pattern: *://*.example-analytics.com/*
method: POST
resourceType: XHR

A record of a withheld request carries no response and no body. It asserts that the page asked for the URL and that this profile's producer decided what to do about it — not that the request failed, and not that the origin returned nothing. A capture that could not reach an origin MUST NOT report it this way.

A no-body policy produces no record of this kind. The request and response records are written as usual, and the absent body is reported the way any other absent body is.

Records of withheld requests are not required to appear in the package's CDXJ index. A reader looking for them reads the WARC.

The storage member

browserhive:capture MUST contain a member storage recording what web storage the captured origins held. The member records the presence and shape of that storage; it MUST NOT contain stored values.

The prohibition is on this member, not on the package. The values themselves live in a separate entry, storage/origins.jsonl, which a package carries when they were asked for; see . They are kept apart because web storage routinely holds bearer tokens and session identifiers, which appear in no WARC payload: a package that recorded them by default would carry them everywhere it went. valuesRecorded states which of the two a package is holding.

Web storage is recorded because it cannot be reconstructed later. It never crosses the network, so no WARC record holds it: a page that renders from localStorage renders from data the archive does not contain. Without this member a reader cannot tell such a page from one that genuinely had nothing to show, and completeness does not help — it reports bodies the package does not hold, and storage never became a body.

{
  "browserhive:capture": {
    "storage": {
      "profile": "browserhive:storage/2",
      "stage": "after-behaviors",
      "valuesRecorded": false,
      "origins": [
        {
          "origin": "https://www.example.co.jp",
          "local": { "keys": 2, "bytes": 32, "digest": "sha256:b58f31fdc511fb94c356ae7f6d64ac71e5bf056584653c3f3ec61a1f08bfaa0f" },
          "session": { "keys": 1, "bytes": 13, "digest": "sha256:8ce272dea9fc6d7902b23f10411da49a7fb2e3c954ba9f1580cc087292eaafc2" }
        },
        { "origin": "https://ads.example.net", "unreadable": true }
      ]
    }
  }
}
  

Members

storage MUST contain profile, stage, valuesRecorded and origins.

Origin entries

Each entry MUST contain origin, the origin in the serialised form of [[WACZ]]'s enclosing document — scheme, host and, when not the default, port.

Web storage is partitioned by origin, and a page routinely embeds documents from origins other than its own — an advertisement, an analytics frame, an embedded player. Each of those has its own storage, which the page's own origin cannot see. So this is not a record of “the page's storage”: it is one entry per origin the capture was able to observe, and an entry for a third party is the expected case rather than an anomaly.

An origin that cannot hold storage at all — an opaque origin, such as a document at about:blank or a data: URL — has nothing to record and does not appear.

An entry MUST contain either both local and session, or the single member unreadable with the value true. The second form says the capture could not read that origin's storage. It is distinct from an origin whose storage was empty, which is written as areas with "keys": 0. Recording them the same way would put back the ambiguity this member exists to remove.

An entry here and a line in storage/origins.jsonl are two halves of one record. The entry says what an origin's storage looked like — how many keys, how many bytes, which digest — and is always present. The line says what it contained, and is present only when the values were asked for; valuesRecorded states which of the two a package holds. Two things leave an origin without a line even then: there was nothing to put in one, recorded as unreadable, or what there was to put in one was too large, recorded as valuesOversize. The entry distinguishes them, which is why it is the half that is always written.

local and session each MUST contain:

A digest MUST NOT be treated as a commitment to the value. The values are frequently low entropy — a flag, a theme name, a timestamp — and such a digest is trivially reversed by guessing.

An entry MAY contain valuesOversize with the value true. It says this origin's stored values were not carried because they were too large, and that the entry is therefore everything the package holds about it. It MUST NOT appear alongside unreadable, since storage that could not be read has no size to have exceeded; and it MUST NOT appear when valuesRecorded is false, where no origin's values were going to be carried and the member would report a decision nobody made.

An entry carrying valuesOversize MUST still carry local and session. This is the difference from unreadable and it is deliberate: the capture did read this origin, so it knows how many keys there were and how large they were. A reader shown those figures beside the member knows both that values are missing and how much is missing, and can decide whether to capture the page again with a larger bound. Writing the entry without its areas would throw away a measurement already in hand.

{
  "origin": "https://heavy.example",
  "local": { "keys": 1, "bytes": 2129905, "digest": "sha256:1aeb74b512b17f6fc43ade8575d452df142ade5d1515a4afcaf05dd199688c38" },
  "session": { "keys": 0, "bytes": 0, "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" },
  "valuesOversize": true
}
    

The storage directory

[[WACZ]] permits additional files and directories, provided they stay out of archive, indexes and pages, and are listed in the resources section of datapackage.json. A conforming package MAY use that allowance.

A conforming package MAY contain an entry at storage/origins.jsonl. When present, it MUST be declared in resources like any other file, and it MUST be JSON Lines: one JSON object per line, each object recording the web storage one origin held.

When the entry is present, valuesRecorded MUST be true. When it is absent, valuesRecorded MUST be false. A reader can therefore tell whether a package carries stored values without opening the archive body.

{
  "profile": "browserhive:storage/2",
  "origin": "https://www.example.co.jp",
  "takenAt": "2026-09-06T01:22:14.881Z",
  "stage": "after-behaviors",
  "areas": {
    "local": { "feed.cache": "[{\"id\":1}]", "ui.theme": "dark" },
    "session": { "nav.scroll": "820" }
  }
}
  

Each line MUST contain profile, origin, takenAt as an [[RFC3339]] timestamp, stage and areas. areas MUST contain local and session, each an object mapping key to value exactly as the area held them. An origin recorded as unreadable or valuesOversize in storage MUST NOT have a line here. The entry MAY therefore contain no lines at all, when the values were asked for but no origin's could be carried. That is not the same as the entry being absent, which says they were never asked for.

The accessibility directory

[[WACZ]] permits additional files and directories, provided they stay out of archive, indexes and pages, and are listed in the resources section of datapackage.json. A conforming package MAY use that allowance.

A conforming package MAY contain an entry at accessibility/axtree.jsonl. When present, it MUST be declared in resources like any other file, and it MUST be JSON Lines: one JSON object per line, each object recording one snapshot of the accessibility tree the browser computed for the captured page.

The tree is recorded because it cannot be reconstructed later. It is derived from the DOM and the layout tree, it contains nodes with no corresponding DOM node (CSS generated content, list markers), and aria-owns reparents nodes so its shape differs from the document tree. Replaying the archive against a future browser produces a different tree. This is the same reason tls is recorded rather than referenced.

Snapshot members

Each line MUST contain the members profile, url, takenAt, stage and tree.

A line SHOULD also carry devicePixelRatio, viewport and nodes — respectively the pass the snapshot came from, the viewport in effect, and the number of nodes before pruning. The last of these lets a reader see how much was discarded.

{
  "profile": "browserhive:axtree/1",
  "url": "https://www.iana.org/",
  "takenAt": "2026-08-22T02:55:56.759Z",
  "stage": "after-behaviors",
  "devicePixelRatio": 1,
  "viewport": { "width": 1280, "height": 800 },
  "nodes": 255,
  "tree": [
    {
      "role": "RootWebArea",
      "name": "Internet Assigned Numbers Authority",
      "children": [
        { "role": "heading", "name": "Domain Names", "level": 2 },
        { "role": "image", "name": "logo",
          "url": "https://www.iana.org/_img/iana-logo-header.svg" }
      ]
    }
  ]
}
    

Shown across several lines for readability. In the file each snapshot occupies exactly one line.

Node shape

Every node MUST carry role. A node MUST NOT carry any member other than role, name, level, url, checked, expanded, disabled, required, value and children.

Two of those carry more weight than the rest:

A member whose value is absent, empty or false MUST NOT be written. Writing defaults on every node costs more than the content it surrounds.

Pruning

Nodes the browser marks as ignored MUST NOT appear in tree. Their children MUST be kept and attached to the nearest retained ancestor.

This is what keeps hidden text out of the record: subtrees removed by aria-hidden or display: none are ignored nodes, so they never reach the file. A reader can therefore treat the tree as a description of what was visible, which is a stronger claim than anything the WARC payload alone supports.

Nodes whose role is generic, none or InlineTextBox MUST NOT appear in tree either, and their children MUST be kept in the same way. These roles carry no meaning of their own; keeping them doubles the depth of the tree without adding anything a reader can use.

A producer MUST NOT drop nodes for being outside the viewport. Doing so is reasonable for an agent, which can only act on what it can see, but an archive that discards the part of the page nobody scrolled to is not a record of that page.

The behaviors directory

[[WACZ]] permits additional files and directories, provided they stay out of archive, indexes and pages, and are listed in the resources section of datapackage.json. A conforming package MAY use that allowance.

A conforming package MAY contain entries under behaviors, carrying the code the capture ran on the page before archiving it. When present, each entry MUST be declared in resources like any other file.

The code is recorded because it cannot be recovered later. Code the producer ships is at least pinned by build, but code a request carried arrives with the request and is gone once the response is discarded — and the name it ran under was chosen by whoever supplied it, so two runs of different code under one name are otherwise indistinguishable. This is the same reason tls is recorded rather than referenced.

Runtime

A conforming package MAY contain an entry at behaviors/runtime.js. When present, it MUST be the source the producer evaluated in the page, byte for byte.

It MUST be present when the producer evaluated such a source, and MUST NOT be present otherwise. Its presence therefore states that code ran on the page — whether or not any behavior went on to do anything.

“Byte for byte” is the requirement, not readability. A producer that minifies its runtime records the minified form, because that is what the page received. A reader who wants the form a person can read has build, which names the software that produced the package.

Request-supplied behaviors

A conforming package MAY contain an entry at behaviors/custom.jsonl. When present, it MUST be JSON Lines: one JSON object per line, each object recording one behavior that arrived with the capture request.

{
  "profile": "browserhive:behaviors/1",
  "id": "example.com:gallery",
  "source": "class { static id = \"example.com:gallery\"; … }"
}
    

Each line MUST contain profile, id and source.

The entry MUST be present when the producer placed any request-supplied behavior in the page, and MUST NOT be present otherwise. Its absence therefore states that the package holds no code that came from the request.

The condition is placement, not execution — the same condition as the runtime. Code that was put into the page ran there, whatever it then decided to do, and an archive that recorded only the code that acted would omit code that executed.

A behavior name is chosen by whoever supplied the behavior, and nothing constrains it — the names in use today contain both : and /. Using them as entry paths would let a name decide where a file lands inside the package. One file, one line per behavior, keeps the name a value rather than a path.

Reading these entries

A reader MUST NOT execute the contents of behaviors. They are a record of what the producer ran, not part of the archived page, and nothing in the package requires them to be run again for it to be replayed.

This is stated because the entries are the only ones in a conforming package whose contents are executable, and because a reader that replays a package has an execution context already open.

Constraints inherited from replay

The requirements in this section do not come from [[WACZ]]. They come from what existing readers of WACZ packages do, and a package that ignores them is unreadable in practice even though it satisfies the base specification.

These are recorded here so a package author can satisfy them. Why each one exists is a matter of implementation history and is documented outside this specification.

Security and privacy considerations

tls is not proof of provenance. Certificates are public: anyone can obtain one and present a copy. Holding these fields says nothing about who answered a request. Two narrower things are worth the storage — issuer changes when a connection is intercepted, and the validity period can be checked against the time the capture claims — and neither survives the capture unless it is written down.

completeness asserts absence, not correctness. It reports bodies this package empirically does not hold. It does not assert that the bodies it does hold are what the origin served.

A package records URLs in completeness and host names in tls. Where the captured page carried personal data in URLs, those URLs travel with the archive.

The accessibility tree carries the names the browser computed for elements, which are drawn from the visible text of the page. That text is already present in the WARC payload, so recording the tree exposes nothing the package did not already hold. The pruning rules make its exposure narrower than the payload's: ignored nodes are excluded, so text hidden by aria-hidden or display: none does not appear in the tree even though it appears in the response body.

storage exposes nothing the package did not already hold. It records key counts, byte counts and digests. It does not record values, and a digest is not a value — though, as noted above, a digest over low-entropy content is not a hiding place either.

A package that also contains storage/origins.jsonl does expose more. Web storage routinely holds bearer tokens, session identifiers and unsent drafts, none of which appear in any WARC payload; this is the one place in this profile where a conforming package can carry material the capture never received over the network. Producers SHOULD leave the directory out by default. A reader can tell the two cases apart without opening the archive body, because valuesRecorded says which one this is.

browser names the build that did the recording, and it is required. A producer that presents a different identity to the origin — a substituted User-Agent, for example — does not thereby change this member: what is sent to the server and what the package reports are separate. Operators who treat their build version as sensitive should know that a conforming package discloses it.