Skip to content
downpipes docs

Immutability, WORM and what an attestation means

Two words get conflated whenever backups and immutability are discussed, and conflating them is how an auditor ends up over-credited. This page keeps them apart. One guarantee is always in force and is a property of the platform. The other is stronger, lives in the object store, and the engine will only credit it after it has actually checked.

The first guarantee is tamper-evidence. Every archive is sealed with post-quantum hybrid encryption, signed with a post-quantum hybrid signature, and chained in a signed run log. That chaining makes rolling an archive back to an older state detectable. This holds for every destination, regardless of how the bucket was created.

The second guarantee is store-enforced S3 Object-Lock, which makes an object physically undeletable within its retention window. In compliance mode that holds against every credential, including the account root. In governance mode a principal with the bypass permission can lift it. That guarantee is not assumed. The engine runs a live capability probe against the bucket. It asserts Object-Lock only when the probe confirms the bucket enforces it and something actually applies a retention window to the archives written there.

This page covers the immutability report, the immutability posture check, the destination probe and the keyless attestation.

Two guarantees, kept distinct

The immutability report (buildImmutabilityReport in reports.ts) is built around this split, and it never collapses the two.

The platform baseline is unconditional. The report states it for every configured destination as tamper-evidence: each archive is signed with a post-quantum hybrid signature and chained in a signed run log with anti-rollback. That sentence is true whether or not the bucket has Object-Lock, because it is a property of how the archive was written, not of how the bucket was provisioned.

Store-enforced Object-Lock is the separate, stronger property, and it is conditional on two facts rather than one. The live probe must read the bucket as enforcing Object-Lock, and something must actually apply a retention window to the objects written there. Exactly two things apply one: the per-object retention header the engine writes under a valid WORM policy, and the bucket’s own default retention rule, which the same probe reads. Object-Lock being switched on for a bucket retains nothing by itself, so the report never rests the strong claim on that fact alone.

With a valid policy on an enforcing bucket, the per-destination line reads as store-enforced WORM with the lock mode and retention window. It then says who the window binds. In compliance mode, no credential can hard-delete or overwrite an archive inside it. In governance mode, a compromised delete-credential cannot either, unless it holds the bypass permission (s3:BypassGovernanceRetention, or on Azure the right to change an unlocked policy). The governance line says a principal with that permission can shorten or remove the window, and the check recommends compliance mode. If the probe could not read the mode, the line says a compromised delete-credential cannot hard-delete or overwrite an archive inside the window unless the mode is governance and it holds the bypass permission, and that the report does not state which mode applies.

No policy armed covers two cases: no policy at all, and an invalid one. In either case the line turns entirely on the bucket’s own rule, and it names which of the two cases you are in. If the bucket applies one, the report keeps that same sentence and attributes it to the rule. It says, in as many words, that the engine writes no retention header of its own there. If the bucket applies none, the report states that an archive written there carries no retention window and that a compromised delete-credential can hard-delete it. If the rule could not be read, the report says only that, and asserts neither direction.

When the probe has not confirmed enforcement, the report says which of the non-enforcing readings it found rather than giving one sentence for all of them, because the readings differ in what they mean for your backups. A policy on a bucket the probe read as not enforcing Object-Lock is reported as a destination that cannot be written to at all, since the store refuses the lock-bearing write. A policy the probe could not evaluate is reported as exactly that, asserting neither enforcement nor its absence. An invalid policy, which arms nothing, is reported as the tamper-evidence baseline with a note that no lock metadata is written. All of them carry the same plain statement that store-enforced Object-Lock is not in force.

An attestation of properties in force, never a blanket WORM claim

The immutability report is an attestation of the recoverability properties actually in force for your account at the moment it is generated. It always states the tamper-evidence baseline, and it asserts store-enforced Object-Lock only where the probe confirms enforcement and a retention window really does reach the archives. It is never written as an unqualified WORM or immutable claim, because the platform does not make that claim for a bucket it has not verified, and a bucket with Object-Lock switched on is not by itself a bucket that retains anything.

The three WORM states

WORM status is fed by a live capability probe, not inferred from a refused delete. The probe is the S3 GetObjectLockConfiguration path (objectLockStatus in s3-read-ops.ts), a bucket-root read that asks the store what the bucket itself enforces. A refused delete proves only that one credential lacked permission this once; the probe reads the actual configuration, which is why the engine relies on it instead. That same read also returns whether the bucket carries a default retention rule of its own.

The probe feeds the immutability posture check (buildImmutability in posture-checks-immutability.ts, with posture.ts fixing its severity at medium), which grades to one of three states. Only one of them is a failure, and that failure is the dangerous case.

From engine 0.3.6 the check also reads the lock on every other destination an enabled downpipe writes to. The verdict still rests on the default destination. The detail says when another destination has no retention window in force.

StateWhat it meansVerdict
Store-enforced WORM is in forceA retention window really does reach every archive written here, whether the engine arms it or the bucket’s own default rule applies itPass, and the check says who the window binds: every credential in compliance mode, and every credential without the bypass permission in governance mode
The dangerous gapA WORM policy is intended, and the window it asked for is not the window in forceFail, a medium-severity open item
Not claimedNothing was intended, and the check does not assert store-enforced immutability, either because no retention window reaches an archive here or because it could not read whether one doesPass, informational: tamper-evidence still holds, Object-Lock is not claimed

A pass in this check is not always a pass in the signed evidence pack. From engine 0.3.6 a control row bound to immutability can read “not met”. It does when no retention lock is in force on every destination an enabled downpipe writes to. That includes each informational pass above. Three rows bind a backup administrator: Essential Eight ML3 admin immutability, SEC 17a-4(f)(2)(i)(B) and ISM-1708. They also read “not met” when the lock is not in compliance mode on every destination.

Google Cloud Storage reaches the first state, but only from a bucket created for it. Google Cloud Storage implements S3 Object Lock through its interoperable API. A bucket created with per-object retention answers the GetObjectLockConfiguration probe with Object Lock enabled, and a policy armed against it is enforced by the store: an archive written under a compliance policy carries a real retain-until instant and a delete inside the window is refused. A bucket created WITHOUT per-object retention answers 404, which the probe reads as a definite not-enabled. The engine refuses to save a destination carrying a policy it could never enforce. Per-object retention cannot be turned on after the bucket exists, so this is decided when you create it and not afterwards.

Azure Blob Storage reaches the first state as well, and it reaches it by a mechanism of its own rather than by S3 Object Lock. Azure carries two independent primitives: an immutability policy with a retain-until date, which is separately either unlocked or locked, and a legal hold, which holds a blob with no expiry and no policy involved. The vocabulary this form collects is one mode plus one retention window, which is the policy and not the hold.

The mapping onto that policy is exact rather than a guess, because the two vocabularies name the same two guarantees. governance (unlocked Azure policy) means a sufficiently privileged principal can lift the retention; compliance (locked) means nobody can shorten or remove it for the window. Nothing has to be inferred from the container, because the mode rides on each write as a request header (AZURE_IMMUTABILITY_MODE, engine/src/dest/azure-worm.ts), so compliance sends locked and either gets the strong guarantee or the write is refused. The legal hold is never set: it has no expiry at all, so deriving one from a retention-days answer would issue a promise the operator never made.

The precondition is Azure’s. A per-blob policy binds only on a container with version-level immutability enabled, which is set on the storage account at creation or on the container. Version-level immutability also needs blob versioning on the account. The Azure destination’s objectLockStatus (engine/src/dest/azure-blob.ts) is a real container probe for exactly that: one credentialed Get Container Properties read, graded on x-ms-immutable-storage-with-versioning-enabled.

A container with that property enabled reads as enforcing and the armed policy is credited like any other; a container without it reads as a definite not-enabled, and the save is refused rather than storing a destination whose every backup write would be rejected. A transport fault degrades to could-not-confirm, exactly as the S3 probe does, so a passing blip never reads as a permanent configuration fault. The configuration detail is in choosing a destination, and the mechanism each provider offers is one row each in destination providers compared.

Cloudflare R2 cannot reach the first state on any bucket. R2’s S3 API does not implement the Object-Lock configuration calls, and its CreateBucket rejects the object-lock-enabled header, so no R2 bucket enforces a lock and none can be made to. Reaching R2 over its S3-compatible endpoint is not a way round that: there the lock probe answers 404 and a write carrying x-amz-object-lock-mode: COMPLIANCE answers 501 NotImplemented, naming that header. The save refuses a mode on an R2 destination on the probe’s verdict, before anything is stored.

The check words its finding in the vocabulary of the store you actually use. The finding and remedy sentences take the mechanism name and the store noun from the destination’s provider (immutabilityMechanism and immutabilityStoreNoun in engine/src/dest/worm-remedy.ts): S3 Object-Lock on a bucket for Amazon, Object Lock through per-object retention on a bucket for Google Cloud, version-level immutability on a container for Azure, and write-once retention for R2, which names no product because R2 implements none.

Where the provider cannot be resolved the sentences fall back to Amazon’s wording. Amazon’s wording is the most general when the store is not known. The tables below are written in Amazon’s terms because that is the fallback. Each row holds for the other stores with their own mechanism substituted.

Two axes decide which of the three you are in, and the first alone is not enough. The first axis is whether the bucket enforces Object-Lock. The second is whether anything applies a retention window to the objects written there. That takes either the per-object retention header the engine writes under a valid policy, or the bucket’s own default retention rule. A lock-enabled bucket with neither retains nothing at all, so “the bucket enforces Object-Lock” cannot stand alone as the pass condition.

Configured policyBucket enforces Object-LockThe bucket’s own default ruleVerdict
ValidYesPresent, absent or unreadPass, the strong claim, resting on the retention header the engine writes
ValidNo, or could not be confirmedAnyFail, the dangerous gap
InvalidYesPresentFail: the detail credits the bucket’s rule, which is the whole of the protection and is not the window you asked for
InvalidYesAbsentFail: nothing applies a retention window, so archives are not protected
InvalidYesUnreadFail on the invalid policy, asserting neither direction on retention
InvalidNo, or could not be confirmedAnyFail, and archives are not protected
NoneYesPresentPass, the strong claim, attributed to the bucket’s rule rather than to the engine
NoneYesAbsentPass, informational, and the strong claim is refused: nothing retains an archive here
NoneYesUnreadPass, informational, asserting neither direction
NoneNo, or could not be confirmedAnyPass, informational: Object-Lock is not claimed

On Azure Blob the bucket’s own default rule is always unread. Azure keeps a container’s default retention policies on the management plane, and a storage-account key or a SAS cannot read them. A container with version-level immutability on and no WORM policy therefore gets the informational pass that asserts neither direction. Before engine 0.3.6 the check and the report read that container as having no default rule, and said that nothing retained its archives.

The dangerous gap is the row that matters, and its consequence is sharper than a missing guarantee. S3 Object-Lock can only be enabled when a bucket is created and cannot be turned on afterwards. If a WORM policy is set but the bucket was not created with Object-Lock, the store does not quietly discard the retention headers. It refuses the write. Cloudflare R2 answers a lock-bearing write to such a bucket with 501 NotImplemented, and AWS S3 answers it with ObjectLockConfigurationNotFoundError, so backups to that destination fail outright and no archive is stored there at all.

The check fails here on purpose, at medium severity, rather than passing on the strength of a policy the bucket cannot accept. Read that failure as “this destination is holding nothing”, not as “this destination is working without immutability”.

The “unconfirmed” reading folds into the same failing state for the same reason. If the probe could not read the configuration (the store did not answer, returned something unparseable, or the destination cannot answer the probe at all), the engine treats it as cannot-confirm, never as enforcing. A native R2 binding destination is one such case: the binding cannot read Object-Lock at all, so its probe returns unknown. A console-set R2 destination is reached over the S3-compatible endpoint instead, so it can be probed as S3 and gets a real answer rather than staying unreadable.

Saving that combination is refused, so the console cannot create it

Because the combination can never work, the engine will not store it. When you add or edit a destination, the save runs a live probe of the bucket before anything is written down. If that probe finds the bucket cannot enforce Object-Lock on a WORM-mode destination, the save is rejected with a 400 and an error. The error explains that every backup written there would be refused by the store and that Object-Lock must be enabled when a bucket is created. Nothing is stored, so there is no half-saved destination to clean up.

The refusal fires only on an answer the store itself gave. A bucket that reports no Object-Lock configuration is one. A store that reports it has no Object-Lock API at all is the other, which is how Cloudflare R2 is caught: R2 cannot enable Object-Lock on any bucket, at creation or after, so a WORM policy on an R2 destination is always refused. Every other reading is treated as could-not-check and is allowed through. This includes the common case of a least-privilege credential that may write objects but may not read the bucket’s lock configuration. Refusing on those would reject correctly configured destinations because of a gap in the probe’s reading.

The refusal does not make the check redundant, because two routes still reach the failing state. The first is a save the probe could not answer, which the refusal lets through. The second is the deploy-time DEST_WORM_MODE and DEST_WORM_RETENTION_DAYS variables: they arm the WORM policy on the engine’s own environment destination directly, with no probe and no save step for the refusal to sit in front of, so a deployment that sets them against a bucket without Object-Lock lands in exactly this state. The immutability check is the only thing that will tell you.

Why an invalid policy fails even on an Object-Lock bucket

A policy is armed only when the mode and a positive retention window are both valid, so an invalid policy arms nothing: the engine writes no retention metadata on any archive. The operator asked for a window and got none. Whatever the bucket does on its own account, the window in force is not the one that was asked for, so a green check would tell that operator their policy was working. So an invalid policy fails in every reading, including on a bucket that enforces Object-Lock and applies a default rule of its own.

Where such a rule exists, the detail credits it and names it as the whole of the protection.

Why no policy at all still passes, even on an Object-Lock bucket

This is the reading that looks like a bug and is not. An Object-Lock bucket carrying no WORM policy and no default retention rule protects an archive no better than an ordinary bucket does. The check grades an ordinary bucket with no policy as an informational pass. Failing the first while passing the second would score you worse for owning the more capable bucket, on identical protection, for a change you never made. Nothing was intended on that destination, and intended-but-broken is what this check’s failure state means.

The detail line shows the difference. It does not promise a retention window: it says that the bucket has Object-Lock switched on, that nothing applies a window to the archives written there, that a compromised delete-credential can therefore hard-delete one, and that Object-Lock enabled on a bucket retains nothing by itself. The remediation points at two ways to close it: a WORM policy so the engine arms the window, or a default retention rule on the bucket. Neither needs the bucket re-creating.

On AWS S3 a default retention rule makes AWS require a checksum on every write. Engine 0.3.6 and later sends that checksum, so the rule works with no WORM policy set.

An amber grade is not a softer outcome. A check that grades cannot-verify reads as “needs attestation” rather than as a red failure, but it does not earn its weight in the score and it sits in the same needs-attention list, so amber costs exactly what failing costs. An informational pass, with a detail that withholds the strong claim, matches the protection in force.

What the failing state costs

An invalid policy on an Object-Lock bucket fails, and that has a cost in the console.

The check fails and joins the needs-attention list in the security centre as a medium-severity open item. Medium carries a weight of two in the weighted score, so on an estate where every other check passes the score falls by four points, from 100 to 96 against a set of in-scope checks whose weights total 49. Ten control rows across seven of the compliance frameworks bind immutability as their live evidence, so a signed evidence pack shows that check as failing in each of them, and each affected framework’s summary counts one failing check per bound row. In five of those ten rows immutability is the only bound check, so those rows carry no passing evidence at all.

You cannot reach this state from the console. The Destinations form refuses a half-set policy at submit, and the engine drops any invalid policy before storing it, so an invalid policy comes only from the deploy-time DEST_WORM_MODE and DEST_WORM_RETENTION_DAYS variables. Setting either of them means a policy is intended, so a pair that is partial (only one set) or invalid (a mode that is neither governance nor compliance, or a retention window that is not a whole number of days above zero) reads as configured-but-misconfigured and arms nothing. The fix is to set both correctly or to clear both. Failing that, the check can be risk-accepted or otherwise graded like any other failing check, and the grading is recorded with your reason.

A WORM policy on a bucket without Object-Lock stops backups, it does not merely weaken them

If you have set a WORM policy and the immutability check is failing, do not read the configured policy as protection, and do not read the failure as backups continuing without immutability. Several readings share that one failing state, and which one you are in decides what is actually happening.

The bucket does not enforce Object-Lock. The store refuses every write that carries the retention headers, so that destination holds no archives at all, and the console’s Destinations table shows its immutability cell as “writes refused”. Object-Lock cannot be enabled on an existing bucket, so the fix is to re-create the destination bucket with Object-Lock enabled and set a valid policy (a mode plus a positive retention window), then confirm the check passes. Setting the mode back to off also clears the refusal, at the cost of the immutability you were after.

The engine refuses the save when the probe establishes that the bucket cannot enforce Object-Lock. If you are in this state, the cause is one of two routes. The first is the deploy-time DEST_WORM_MODE and DEST_WORM_RETENTION_DAYS variables, which arm the engine’s own environment destination without a probe. The second is a save on a probe that could not reach an answer, which the refusal lets through. A network fault can cause that, and so can a credential that may not read the lock configuration.

The policy is invalid, meaning the mode and a positive retention window are not both set. No lock metadata is written at all, and the check fails on that whatever the bucket turns out to be. On a bucket that does not enforce Object-Lock, writes are unaffected and archives are tamper-evident but not write-once-locked. On a bucket that does enforce it, the only thing that could retain an archive is the bucket’s own default retention rule: where the bucket has one the detail names it as the whole of the protection, where it has none nothing retains an archive at all, and where the rule could not be read the check states neither direction on retention and fails on the policy itself. An invalid policy cannot be produced from the console, so it comes from the deploy-time DEST_WORM_MODE and DEST_WORM_RETENTION_DAYS variables set as a partial or invalid pair.

Enforcement could not be confirmed. The check is reporting that it could not read the bucket’s Object-Lock configuration, and it is not reporting that the bucket refused anything. Whether a write carrying the retention headers is accepted turns on what the bucket actually enforces, which is the fact the probe could not read, so this reading tells you neither that your archives are locked nor that they are missing. The signed immutability report says exactly that and asserts neither.

Where the destination can answer the probe at all, giving its credential permission to read the bucket’s object-lock configuration resolves the check to one of the other two readings. A native R2 binding cannot answer it, so it stays here by construction, which is why a console-set R2 destination is reached over the S3-compatible endpoint instead.

The two lock modes differ in who can shorten retention. Governance mode lets a privileged, auditable override bypass retention; compliance mode prevents deletion for the window for everyone, including the root account. Compliance is the strong ransomware-resilient setting, and a wrong window under it cannot be undone, so it is chosen deliberately.

What a keyless attestation is

A keyless attestation is the cheapest recoverability signal the engine offers, and the most broadly available one. It is attestKeyless in keyless.ts, reached through POST /admin/restore/attest. It needs no decryption key and reads no record’s plaintext, so it runs even in the break-glass-only posture where the engine holds no in-account read-back key. It establishes three facts about a sealed run, and each is computed without any decryption key.

Signature valid

The stored root manifest verifies under the operator-pinned public verifier, a verification key and never a decryption key. Because that one signature covers the Merkle root, every per-record record hash, the declared counts and the shard digests, a valid signature means the run’s committed structure is authentic.

Complete

Every shard the signed root lists is present, and its bytes hash with SHA-384 to the value the signed root pins, and the declared shard and record counts are well-formed. So no shard has been dropped, truncated or swapped relative to the signed run.

Not rolled back

The signed, append-only run log verifies and places this run in the freshness chain with no index gap or fork. Unless staleness was explicitly acknowledged, the run is also the latest for its downpipe, so the archive has not been rolled back to an older state.

A check that could not be evaluated reports false, not a pass. That happens, for instance, because the signature failed and so the body must not be trusted as authoritative. The reason returned with a failure is a short, coarse, secret-free note on the first failing check. It is drawn from a fixed enumeration so that no shard id or object key ever leaks into the response or the log.

What a keyless attestation is not

A keyless attestation is not a Cloudflare-side or store-side verification of your recoverability. The object store is not asked to vouch for anything. The check is performed by the in-account engine, and it amounts to engine-side completeness plus signature-validity plus anti-rollback over the operator-pinned public verifier. Nothing in the store’s API is treated as proof of recoverability, and no decryption key is used at any point.

A keyless attestation is also not a full per-record recompute. It attests the per-record fields through the single signature that covers them, together with the shard-presence completeness check, but it does not open each record and recompute its hash and the Merkle tree from the plaintext. That stronger recomputation needs the manifest key and so belongs to the keyed tiers, the blind restore test that decrypts and verifies every in-scope record. A keyless attestation is the broadly available floor, not the keyed ceiling.

Engine-side, not a store-side check

Read a keyless attestation as the engine, inside your own account, confirming three things about a run’s signed structure with no decryption key in hand. It is not the object store confirming your data is recoverable, and it is not a full record-by-record decrypt-and-verify. Both of those are different, stronger statements, and only one of them, the keyed blind restore test, opens your records at all.

How this connects to coverage

A passed keyless attestation is enough to mark a resource protected. When POST /admin/restore/attest returns a clean three-flag result, the engine stamps a restore-proven record for the covering downpipe with the method recorded as a keyless attestation. That stamp is stampRestoreProven in router-audit.ts, called from router-restore.ts. On the coverage ladder, a downpipe with at least one successful run and a restore-proven record reads as protected.

This is by design. Because a keyless attestation alone can stamp the record, a resource can be marked protected without a full keyed blind restore having run against it. Proven recoverability here means a restore-proven record exists, set by either a passed blind restore test or a passed keyless attestation; it does not always mean every record was decrypted and verified. The coverage view never reads protected as an unqualified guarantee, for this reason.

Where each behaviour lives in the engine

The report split lives in buildImmutabilityReport and its wormProperty and wormAttestationClause helpers in reports.ts: the tamper-evidence baseline is stated for every configured destination, and the store-enforced Object-Lock line is added only where the probe reads the bucket as enforcing and either a valid policy is armed or the bucket carries a default retention rule of its own, which probeWormCapability carries through as defaultRetention. The posture check is buildImmutability in posture-checks-immutability.ts, with posture.ts fixing its severity at medium, where an armed policy on an enforcing bucket passes with the strong claim, a bucket rule on an enforcing bucket with no policy passes with the same claim attributed to the rule, an unarmed policy on an enforcing bucket with no rule or an unread rule passes informationally with the claim refused or withheld, a not-configured destination passes informationally, and an unenforced, unconfirmed or invalid policy fails. The live probe is objectLockStatus, the S3 GetObjectLockConfiguration read in s3-read-ops.ts (called from s3.ts), with the native R2 binding returning unknown by design in r2.ts. The router gathers the WORM signal best-effort in gatherWormSlice, so a probe fault degrades to unknown and never fails a report or posture read. The keyless attestation is attestKeyless and the KeylessAttestation type in keyless.ts, and the coverage stamp on a clean result is stampRestoreProven in router-audit.ts, called from router-restore.ts. Retention pruning is deferred under the break-glass-only posture (no in-account read-back key to compute the orphan set safely). On an S3 Object Lock bucket, which is always versioned, the prune’s delete names no version. S3 answers it with a delete marker and keeps the locked version, so write-once retention and pruning never fight at the destination. From engine 0.3.6 the prune counts such an object as hidden, not reclaimed.

For the integrity chain the engine builds when it finalises a run, the chain a keyless attestation later verifies, read verify at seal.

For how a passed attestation moves a resource to protected, and what protected means, read coverage and gaps. For the keyed counterpart that decrypts and verifies every record, read prove recoverability.

For why the audit log is tamper-evident in the same detection-not-prevention sense, and why it pairs with bucket-level immutability, read the audit log.

Last updated .