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 preload, a second place code can come from: scripts
a request supplies to run before navigation, in every frame, rather
than once on the main frame after load. It also adds a required
preload member to settings, so that a package states
which of them were supplied even though they cannot report anything
themselves. Because the new member is required, a package written against
1.7.0 does not conform to 1.8.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, 1.6.0 and
1.7.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.
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.
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.
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.
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.
| Member | Type | Required | Description |
|---|---|---|---|
version | string | yes | Release identifier. |
revision | string | yes | Source revision the build was made from. |
buildTime | string | yes | When 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.
| Member | Type | Required | Description |
|---|---|---|---|
signature | string | yes | Either "required" or "none": whether this
capture was permitted to be published unsigned. |
viewport | object | yes | Members width and height, both integers, in CSS pixels. |
devicePixelRatios | array of number | yes | The device pixel ratios the page was loaded at, in the order it was loaded. The number of entries is the number of loads. |
session | string | yes | 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. |
behaviors | array of object | yes | Behaviors that ran after load, in the order they ran. Each entry
carries id, the name the behavior was run under, and
origin, one of "builtin",
"site" or "custom". A producer that holds
no behaviors of its own writes "custom" for every entry.
See the behaviors directory. |
preload | array of object | yes | Scripts that were supplied to run before navigation, in the
order they were registered — not the ones that
reported something, because these cannot report at all. Each entry
carries id, the name the script was supplied under. An
empty array states that none were supplied.
See the preload directory. |
limits | object | yes | The body caps that were in force for this capture. Members
maxResponseBytes and maxTaskBytes, both integers, in bytes. |
urlPolicies | array of object | yes | What the capture did about each URL pattern that was in force, in the order the patterns were evaluated. See urlPolicies. |
contentTypePolicies | array of object | yes | What the capture did about each media type that was in force. See contentTypePolicies. |
acceptLanguage | string | no | 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.
| Member | Type | Required | Description |
|---|---|---|---|
bodylessUrls | array of string | yes | URLs that appeared only as 304. The body never crossed
the wire, so the archive cannot hold it. |
truncatedUrls | array of string | yes | URLs whose body the capture dropped on reaching a size limit. |
complete | boolean | yes | 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.
| Member | Type | Required | Description |
|---|---|---|---|
scrollExhausted | boolean | yes | true when scrolling stopped at a step limit rather than
at the end of the page — that is, when material below was never requested. |
scrollSteps | number | yes | Steps actually taken. |
scrolledPx | number | yes | 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.
| Member | Type | Required | Description |
|---|---|---|---|
strategy | string | yes | 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. |
endedBy | string | yes | "quiet" when every watched signal was satisfied
before the deadline; "deadline" otherwise. |
waitedMs | number | yes | Milliseconds from the navigation returning to the wait ending. |
devicePixelRatio | number | yes | Which load this observation is of; one of the entries of
devicePixelRatios. When there are several, the last. |
limits | object | yes | 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). |
load | object | yes | atMs: when the document was seen fully loaded. |
network | object | yes | quietAtMs: when no more than
limits.networkConcurrency requests had stayed in flight
for limits.quietMs. |
cpu | object | yes | quietAtMs: when no long task — a task holding
the main thread for 50 ms or more — had run for
limits.quietMs. |
dom | object | yes | 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.
| Member | Type | Required | Description |
|---|---|---|---|
selectors | array of object | yes | The selectors that were in force, in the order they were
evaluated. Members framework and
selector, both strings. |
heuristic | object | yes | The thresholds that governed removal of elements no selector
named. Members enabled (boolean),
minViewportCoverageRatio (number) and
minZIndex (number). |
framework | string or null | no | The framework of the first entry that matched, or
"heuristic" when only the thresholds fired.
null when nothing was removed. |
removedSelectors | array of string | no | The selectors whose element was removed. A subset of
selectors, in evaluation order. |
unusableSelectors | array of string | no | 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. |
removedOverlays | number | no | Elements removed by the thresholds rather than by a selector. |
devicePixelRatio | number | no | Which load this observation is of; one of the entries of
devicePixelRatios. |
unreadable | true | no | 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.
| Member | Type | Required | Description |
|---|---|---|---|
protocol | string | yes | Negotiated TLS version. |
cipher | string | yes | Negotiated cipher. |
subject | string | yes | Subject common name, as the browser reported it. |
san | array of string | yes | The host names the certificate is valid for (subjectAltName). Empty for a certificate that carries none. |
issuer | string | yes | Issuer common name. |
validFrom | string | yes | Start of the certificate's validity period. |
validTo | string | yes | End of that period. |
chainRef | string | no | Key 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.
cdn.example.net and img.example.net point at
the same chainRef — they presented
the same certificate, so the chain is stored once in
chains.api.example.org has no chainRef.
A certificate was presented — subject and the fields
below it are the evidence — only the chain could not be obtained.beacon.example.org is null. It was reached over
HTTPS but the connection revealed nothing. The key is kept because
dropping it would read as "this host was plain HTTP".cdn.example.net has protocol "QUIC".
The member is the string the browser reported, and it is not
always a TLS version.
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.
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.
urlPoliciesurlPolicies is an ordered array. Each entry is an object with two members:
| Member | Type | Required | Description |
|---|---|---|---|
pattern | string | yes | The pattern that was in force, spelled exactly as the capture was configured with it. |
action | string | yes | One of the values below. |
action | Request sent | What the package holds |
|---|---|---|
deny | no | A record of the request only. Nothing left the capturing machine for that URL, so no origin learned of the capture through it. |
no-archive | yes | A record of the request only. The origin answered; the package does not keep the answer. |
no-body | yes | 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" }
]
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:
| Field | Required | Description |
|---|---|---|
action | yes | The action that applied: deny or
no-archive. This is the field that says whether
the request was sent. |
pattern | yes | The pattern that matched, spelled exactly as it appears in urlPolicies, so that a reader can find it there. |
method | yes | The request method. |
resourceType | no | 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.
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 }
]
}
}
}
storage MUST contain profile,
stage, valuesRecorded and origins.
profile — the rules the member follows. For this
version of this specification the value MUST be
"browserhive:storage/2". A producer that changes what it
records, or how a digest is computed, MUST change this value.
stage — the point in the capture at which storage was
read. For this version the value MUST be
"after-behaviors". The stage is normative because the
content changes: a page writes to storage as it runs, so a reading taken
before behaviours ran and one taken after describe different states.
valuesRecorded — whether this package also carries the
stored values, in the directory defined below. It says the values were
asked for, not that every origin's are present: an origin marked
valuesOversize has none. See
.
origins — an array with one entry per origin the
capture could observe.
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:
keys — the number of keys the area held.bytes — the total size of the area, counted as the
UTF-8 encoding of every key and value. Not UTF-16 code units, which is
what the browser's own quota accounting uses.
digest — "sha256:" followed by the
lowercase hexadecimal SHA-256 of the area, computed over the keys sorted
by code point, each contributing
key U+0000 value U+0000 in UTF-8. The sort and
the separator are normative: without them the same content yields
different digests, and the digest exists to be compared across captures.
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
}
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.
[[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.
Each line MUST contain the members
profile, url, takenAt,
stage and tree.
profile — the pruning rules the line follows. For
this version of this specification the value
MUST be
"browserhive:axtree/1". A producer that changes the
pruning rules MUST change this value, so a
reader can tell what was discarded.url — the page the snapshot describes.takenAt — when the snapshot was taken, as an
[[RFC3339]] timestamp.stage — the point in the capture at which the
snapshot was taken. For this version the value
MUST be "after-behaviors".
The stage is normative because the tree changes.
Autoscroll triggers lazy loading, so a tree taken before behaviours
ran and one taken after describe different pages.tree — an array of nodes, described below.
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.
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:
level — the heading depth. Without it a
heading node cannot be told from another, and the document
structure the tree exists to record is lost.url — the reference an image or link points at. It
is what connects a node to the response recorded in
archive/, so a reader can go from “there was a chart
here” to the bytes of that chart.
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.
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.
[[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.
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.
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.
profile — the rules the line follows. For this
version of this specification the value MUST be
"browserhive:behaviors/1".
id — the name the behavior was registered under.
Every entry in settings.behaviors whose origin
is "custom" MUST have a line here.
The converse does not hold: a line MAY have no
matching entry, because a behavior can be placed in the page and then
decline to act.
source — the code as it arrived, byte for byte.
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.
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.
A conforming package MAY contain entries under
preload, carrying code a request supplied to run
before the captured page's own scripts. When present, each entry
MUST be declared in resources like any
other file.
Code under behaviors runs once, on the main frame, after the page has loaded, and can report what it decided. Code under preload runs before any of the page's own scripts, in every frame including subframes, again on every navigation, and has no way to report anything. A reader asking “what could this code have observed, and how many times” gets a different answer for each, so a package that mixed them into one list would answer neither question.
A conforming package that ran preload scripts
MUST record them at
preload/scripts.jsonl, one JSON object per line, in the order
they were registered. Each line MUST carry
profile, the string "browserhive:preload/1";
id, the name the script was supplied under; and
source, the script exactly as it was evaluated.
The id MUST NOT determine the
entry's name inside the package. It is a value on the line.
Names are supplied by whoever sent the script and are not constrained, so a name that looks like a path would otherwise decide where the entry sits. This is the same rule the request-supplied behaviors follow, and for the same reason.
A reader MUST NOT execute the contents of preload. As with behaviors, they are a record of what the producer ran, not part of the archived page.
The absence of preload/scripts.jsonl together with an empty
preload in settings states that no such code ran. A
reader MUST treat those two as one claim: a
package that carries the file but omits the member, or the reverse, does
not conform.
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.
profile member of datapackage.json
MUST be "data-package". Readers
switch on this value and reject values they do not know, so it is not
available to identify this profile. See
.
config and metadata in
datapackage.json MUST NOT be used to
carry values defined by this specification. Readers assign them meaning.
response record
MUST be in HTTP/1.1 form, whatever protocol
version carried the exchange.
.gz. Readers locate it by
extension and do not decompress it.
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.
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.