The support bundle: exactly what it carries and what it can never contain
The support bundle is the redaction-safe diagnostic file an operator downloads from the console and attaches to a support ticket. It exists because the product’s no-custody rule forbids the two easy answers a support team usually reaches for, a vendor-held Cloudflare token and a vendor dashboard seat, so support works on evidence you hand out rather than access the vendor holds.
This page is the reference for an auditor who needs to know precisely what leaves your account when a bundle is produced. It gives the major field groups the bundle carries, the exhaustive list of what it can never contain, the signed-then-sealed construction that protects it in transit, and the four honest states the construction degrades through. Every claim here is grounded in buildSupportBundle and its wrappers in engine/src/admin/support.ts.
Exactly what the bundle carries
The bundle is a single JSON document assembled from facts that are already safe to disclose. Every field is presence-only, a coarse enumerated outcome, or a label you chose yourself. The v:2 bundle gathers 58 named diagnostic sections (the closed SUPPORT_SECTION_NAMES roster in engine/src/admin/support-roster.ts, re-exported from engine/src/admin/support.ts, from downpipes and runs through destResolution and volumes); the table below covers the field groups an auditor cares about most, not a line-by-line enumeration of every field in every section.
| Field group | What it contains |
|---|---|
| Engine version and provenance | The engine version string, the engine’s own Cloudflare account tag and deploy id when known, plus optional artefactSha384 and releaseSignerPin when the operator set them |
| Status | The presence-only status block (the same booleans GET /admin/status reports), never values |
| Preflight | The preflight report: the live, read-only entitlement probes that prove each Cloudflare prerequisite |
| Per-downpipe config summary | For each downpipe: its id, your own name for it, the enabled flag, the cadence in seconds, the source type, the crawl selector’s include/exclude prefixes, the source’s accountId or zoneId when it has one, the in-flight and stalled flags, the last run id, and any restore-test, seal-verify, and restorability markers |
| Per-downpipe run rows | The most recent run rows (up to ten per downpipe): run id, index, start time, status, the coarse error vocabulary and a cause digest, record counts (skipped, vanished, incomplete), byte and duration counts, op counts, destination ids, and the seal-verification verdict; never a record name or value |
| Notification outcomes | For each recent notification (up to twenty): the event, severity, channel kind, a delivered boolean, and closed-enum delivery and platform codes, never a channel URL or key |
| Licence | The full licence status: tier, validity, reason and reason code, expiry, activation source, and whether the licence’s account claim matches the engine’s own account; never the token bytes |
| Volumes | The estate rollup used to align a volume-band licence at support intake: total protected bytes, a records-and-bytes breakdown per source type, the count of distinct Cloudflare accounts and zones behind your bound sources, your downpipe count, and the timestamp the rollup was computed as of; never a record name or value |
| Integrity fault rows | Per downpipe, what the seal, verify, and restore paths knew at the moment one of them stopped: why a read of the RUNLOG failed, which way the anti-rollback chain was wrong, and a digest of the RUNLOG bytes you can reproduce yourself. Its own section below |
| Other diagnostic sections | Notify configuration, notify health, audit status, config-wrap-key health, config-change events, replication, canary liveness, scheduler signals, seal faults, SSO and auth signals, and similar sections, each a closed set of counts, booleans, or coarse enums |
The per-downpipe summary carries the source’s type and its crawl selector, plus the accountId or zoneId when the source has one, never the source’s binding, endpoint, or contents (buildSupportBundle maps sourceType: d.config.source.type and a selector: { include, exclude } pair). A separate, best-effort section can also list the binding NAMES of currently detached sources (an operator label, the same redaction class as source.binding in the config snapshot; never a secret) so an operator can see which source a deploy dropped. The run rows are sliced to the most recent ten per downpipe and carry the fields above, added conditionally so an absent field is omitted rather than null (fetchRunHistory, engine/src/admin/support-sections-runs.ts). The notification entries carry the event, severity, channel kind, a delivered flag, and closed-enum delivery and platform codes, because a channel’s URL or key never reaches the redacted views the bundle reads from in the first place (fetchNotifyHistory, engine/src/admin/support-sections-notify.ts).
The volumes section exists for exactly one reason: a volume-banded plan carries a number of Cloudflare accounts and an amount of protected data, and support needs a way to check a reported estate against that band without the engine measuring anything continuously or phoning home. It carries the same estate rollup the console’s Licence screen shows you locally and the engine’s own GET /admin/licence response carries as an estate field, so nothing here is computed specially for the pack; the pack simply reports the rollup the engine already keeps inside your own account (engine/src/admin/licence.ts, engine/src/admin/support.ts). Like every other section, it travels no further than your account until you attach a pack to a ticket. For the tier and band definitions this section is checked against, and the help-first rule that governs what happens when a reported estate is over band, see licensing, editions, and the control-plane.
The integrity fault rows, and the digest you can check yourself
Most of the bundle is written to be read by a support engineer beside a ticket. One section is worth opening yourself, because it can settle a question support cannot settle for you: integrityFaults, keyed by downpipe, which records what the seal, verify, and restore paths knew at the moment one of them stopped. Every field in it is a count, a clamped integer, a member of a closed vocabulary, or a one-way digest, which is how it carries this much detail while carrying no record name, no object key, no endpoint host, and no text from the store’s own exception (fetchIntegrityFaults, engine/src/admin/support-sections-faults.ts).
A clean pass records nothing at all, so an absent section means no fault was captured rather than that no check ran. Rows are posted to your engine’s scheduler at the end of a crawl, verify, or restore, so they survive the invocation that produced them (reportIntegrityFaults, engine/src/admin/integrity-fault-report.ts).
Why a read failed, rather than only that it did
When the engine cannot read your _RECOVERY/RUNLOG, a run’s signed root manifest, or a recovery bundle, the verdict it reports is “absent”. That single word covers causes whose fixes are opposite, so the cause is recorded separately under fetchFaults, keyed by which read faulted, the fault class, and a coarse HTTP status class.
The read site is runlog, root-manifest, or recovery-bundle, because “the RUNLOG is unreadable” and “this run’s root is unreadable” are different tickets. The class is one of six.
| Class | What the store did | What it points you at |
|---|---|---|
not-found |
Answered 404 or NoSuchKey |
The object is genuinely not there: a deleted object, or the wrong bucket |
access-denied |
Answered 403 | A rotated credential, a narrowed bucket policy, or an expired session |
throttled |
Answered 429 or SlowDown |
Rate, not absence. Back off; nothing is wrong with the object |
cold-storage |
Holds the object in a restore-required tier | Restore it from the cold tier before retrying |
network |
Threw with no HTTP status at all | DNS, TLS, or a reset |
other |
Was reached and refused, and none of the above | Nothing. It is kept last on purpose so an unrecognised fault is reported as unclassified rather than folded into a neighbouring class |
The order those are tested in is itself a repaired misdiagnosis. Several stores deliver a restore-required object as a 403, so cold-storage is tested before access-denied; without that ordering every lifecycle transition would have read as a credential fault and sent you to rotate keys that were perfectly fine (classifyFetchFault, engine/src/format/integrity-fault-ledger.ts).
The status class beside it is 4xx, 5xx, or none, and it separates a store that refused the read from one that was broken while the class stays the same. Read none as “no 4xx or 5xx was seen”, not as proof that no HTTP exchange took place.
Which way the anti-rollback chain was wrong
A rollback verdict used to arrive with no evidence attached, because the reason string the detector builds names your own run and downpipe ids and therefore can never be carried. The runlogAnomalies rows carry the rest of what the detector knew. The kind is the field the tamper-versus-corruption question turns on, and it is a closed set of ten.
| Kind | What it says about the chain |
|---|---|
duplicate-index |
Two entries carry the same account-global index. The counter never reissues one, so the log was rewritten |
forked-prev |
Two entries share a prevRunId: the chain forked, and two histories claim the same parent |
dangling-prev |
An entry’s prevRunId resolves to no entry at all: a predecessor was deleted from the middle of the chain |
chain-break |
A downpipe’s prevRunId linearity broke, so an entry does not chain to the prior retained entry |
index-regression |
The log’s account-global maximum fell below your out-of-band minimum-index pin: a whole-document replay of an older, validly signed log |
parse-field |
A line failed the shape or canonical-number gate, so the document is corrupt. A destination-side bit flip is the classic cause |
sig-invalid |
The RUNLOG’s own detached signature did not verify over its bytes |
runlog-absent |
The RUNLOG or its signature could not be read at all. Deleting the document is the quietest attack on the chain, so its absence is itself a row |
run-missing |
The log verified and parsed, but the run being checked is not in it |
root-disagreement |
The log’s entry for the run does not bind to the signed root, either on the index or on the chain pointer |
A rewritten or forked chain and a replayed document point at an attack. An unparsable line, or a signature that will not verify over the bytes, points at a damaged object. Telling those apart is the whole reason the kind is recorded, and it is the highest-stakes question the product’s support model has to answer.
Beside the kind, a row carries the disagreeing pair of indices the detector compared (indexA and indexB), which line of the document failed to parse (lineOrdinal), and how long the chain was (entryCount). Which of those are present depends on the kind. On index-regression the pair is the observed maximum and the pin it fell below, so a row reading 41 against a pin of 57 says sixteen runs of history were rolled back, which no reason string ever said.
The vocabulary is closed at both ends. The detector types its own rows against the same list the pack checks against, and a kind outside it is dropped rather than carried, so the set is a boundary and not only a type.
The digest you can reproduce against your own object
Every row that had bytes to hash carries a digest: the first twelve lower-case hex characters of a SHA-384 over the RUNLOG bytes the engine actually read (runlogDigestField, engine/src/format/freshness.ts). It is the only value in the section that is neither an integer nor a member of a closed set, and it is there so you can answer one question support cannot answer without you. Is the document support is reasoning about the document still sitting in your bucket?
Fetch _RECOVERY/RUNLOG from the destination and hash it with any SHA-384 tool:
shasum -a 384 RUNLOG
Compare the first twelve characters of the output against the digest on the row. A match means you and support hold the same bytes, so the fault is durable and in the bucket. A mismatch means the object has changed since the engine read it, which is itself the finding.
Two kinds carry no digest, and neither is an omission. runlog-absent never obtained bytes to hash, and the fetch-fault class above is what says whether the read was denied, throttled, or gone. The parse-field row raised while appending to the log carries the failing line’s ordinal in its place.
Locator rows in the same section carry a digest of the same construction over the failing record id or object key, so you can match one by hashing the keys you already hold in your own listing. The digest is one-way and truncated, so it identifies a document without carrying any of its content, and it can be compared but not inverted.
You can only read this from an unsealed pack
Sealing is off until you set VENDOR_SUPPORT_PUBLIC yourself, so by default the file you download is signed JSON you can open and read. Once you configure a vendor sealing key the whole body, this section included, is ciphertext that only the vendor identity opens. That is a confidentiality gain rather than a loss of access to your own facts, but if you want to check a digest yourself, read it from an unsealed pack or ask support to read the row back to you from the opened one.
Exactly what the bundle can never contain
The never-carry list is not a promise bolted on after the fact. It is structurally true, because every field the assembler emits is presence-only, a coarse enumerated outcome, or a label you chose. There is no field whose value is plaintext content, so there is nothing to leak.
| Never present | Why it cannot appear |
|---|---|
| Plaintext customer data | No field carries record contents; the assembler reads sizes, counts, and outcomes only |
| Keys or secret values | No key material is read into the bundle; the signer and recipient keys never enter it |
| Tokens | No admin token, discovery token, or ingest secret is included |
| Destination endpoints or regions | The per-downpipe summary carries the source’s type and crawl selector, never the destination’s address |
| Record names | Run rows carry no record name; the only names present are your own downpipe labels |
The single category of human-readable names in the bundle is the set of downpipe labels you typed yourself, which are your own operational metadata, not record names from inside a source. This is the structural reason the never-carry list holds rather than depending on a filter that could be misconfigured.
The bundle is redaction-safe, not anonymous. It carries operator-chosen downpipe names and operational facts about your account, so treat it as evidence about your operation. It is not telemetry stripped of identity, and this page never claims it contains no personal data.
The signed-then-sealed construction
The bundle protects itself in two layers, both built from the same hybrid primitives the rest of the product uses.
First, signing. The engine signs the canonicalised bundle with its own run signer, the same signer it uses to sign every backup run. The signature is a hybrid of Ed25519 and ML-DSA-87, so support can verify the file came from your engine and was not altered on the way (signedSupportBundle, engine/src/admin/support.ts; the signer loads from SIGNER_PRIVATE in engine/src/keys-env.ts). This is provenance and integrity. It is tamper-evident: an alteration after your engine produced the file is detectable. It is not a claim that the file cannot be altered.
Second, sealing. When VENDOR_SUPPORT_PUBLIC is configured, the signed bundle is additionally sealed to that public key. The engine wraps a fresh key to the vendor key using the same hybrid X25519 and ML-KEM-1024 capsule the archive uses for its recipients, then encrypts the bundle bytes under an HKDF of that key with AES-256-GCM (sealedSupportBundle, engine/src/admin/support.ts). Only the holder of the matching vendor identity can open it, so the file stays confidential through whatever ticket system carries it.
Use tamper-evident wording for the signature. It lets support detect a modified file; it does not make the file unmodifiable, and the construction is a post-quantum hybrid, not a single classical scheme.
The cleartext band manifest, not yet in a shipped engine
This section describes work that has not shipped
No released engine produces a band manifest. The envelope you download today carries the ciphertext and the signature described above and nothing else: there is no manifest field, no manifestSignature and no bodySha256, and buildBandManifest does not exist in engine/src/admin/support.ts. The work is built but held on a branch and merges at a release, so this section is the design as it will be, not what your engine does now. Do not expect a manifest in a pack you take today, and do not rely on one in an intake procedure.
The intent is narrow. Whenever the signer key exists, the envelope will also carry a small cleartext manifest beside the ciphertext, with its own detached manifestSignature from the same run signer. That lets support intake confirm a subscription band at ticket time with public-key material only, so the vendor’s sealing identity never has to leave the machine it lives on.
The manifest is to carry exactly, and only: its own kind and version (the kind sits inside the signed bytes, so the signature cannot be presented as any other artefact’s), the capture time, your signer fingerprint and the signer public keys it is derived from, the measured protected-data total and distinct account count from the latest successful runs, and whether a licence is present with its tier. On a sealed envelope it is also to carry bodySha256, the hash of the ciphertext it travels with, so a manifest cannot be moved onto a different pack undetected. Everything else in the bundle stays sealed: the manifest deliberately omits your downpipe count, capture recency, per-type breakdown and every diagnostic section.
The trade the manifest will make
Once it ships, anyone holding the file will be able to read the manifest: it is cleartext by design, so mail scanners, ticket systems and anyone a pack is forwarded to can see the measured totals, the tier claim and the signer fingerprint. If that trade is not acceptable for a given pack, the figures are also inside the sealed body, and support can read them after opening it on the vendor’s machine; the manifest only makes the band check instant. The manifest is engine-attested: it proves the file left your engine unaltered, not that the engine’s own measurement was honest.
The four honest states
The construction degrades honestly. Rather than fabricate a signature or a seal it cannot produce, the engine returns the strongest envelope it can and the console states which one you are looking at. There are four states, and the console maps the engine’s two booleans (signerConfigured and vendorSealConfigured) to a plain line for each (vendorSealPresentation, console/src/screens/settings/support.ts).
| State | Signer key | Vendor key | What you get |
|---|---|---|---|
| Unsigned | Absent (pre-ceremony) | Absent | The bundle is returned unsigned rather than with a fabricated signature; still useful, still redaction-safe |
| Unsigned, sealed | Absent (pre-ceremony) | Set | Sealed to the vendor key, but unsigned until the key ceremony has run |
| Signed-plain | Set | Absent | Signed by your engine so provenance is verifiable, but not sealed; treat the file like any diagnostic attachment |
| Signed-and-sealed | Set | Set | Signed by your engine and sealed to the vendor support key |
The first state is the one that matters most for honesty. Before the signer key ceremony has run, there is no SIGNER_PRIVATE, so signedSupportBundle returns the bundle with an empty signature rather than inventing one (engine/src/admin/support.ts). The console reflects this exactly, claiming a signature only once the signer is configured, so a pre-ceremony engine never tells you the file is signed when it is not (vendorSealPresentation).
Setting the public values is documentation, not access
Three values shape the bundle’s provenance and sealing, and all three are public, set out of band by the operator.
VENDOR_SUPPORT_PUBLIC is the vendor support team’s hybrid public key, the same layout as a recipient key. Setting it enables sealing and nothing else: it is a public key, so configuring it grants no access to anything in your account (engine/src/env.d.ts, the VENDOR_SUPPORT_PUBLIC declaration). ARTEFACT_SHA384 and RELEASE_SIGNER_PIN are public, non-secret descriptors of the running build that the bundle carries when set, and they are reported verbatim rather than verified by Cloudflare.
Configuring the vendor sealing key is a confidentiality improvement, never a security risk and never a grant of access. It only turns the third state into the fourth. The provenance descriptors are operator-cross-checkable documentation the engine self-reports against the published release; they are not Cloudflare-verified attestations.
How the bundle reaches support
The default flow needs no inbound vendor access at all. Any signed-in role opens the console, downloads the bundle, and attaches it to the ticket, and the console states the redaction-safe property right at the download button so it is visible where you act (renderSupportBundle, console/src/screens/settings/support.ts). The download is served from GET /admin/support/bundle, which returns the sealed-or-signed bundle as raw JSON for you to save as a file.
There is one escalation, for the case where a live ticket needs fresh evidence after the file was attached: an Owner mints a short, read-only, revocable diagnostics credential and support pulls the same bundle on it. That credential lifecycle is its own subject.
Where this fits
The bundle is one half of the support model; the credential that lets a vendor fetch it on a lease is the other. For how an Owner mints, uses, and revokes that lease, see pull credentials. For the support model end to end, including the precise meaning of “no vendor access,” see getting support without giving us access. For the hybrid signing and sealing primitives the bundle reuses, and why the engine holds a decryption-capable operational key only where one has been added, see the no-custody trust model.
Last updated .