Synced verbatim from
fornax-coreat build time — edit it there, not here.
ADR 0009: Policy revocation and emergency control
Status: Accepted Date: 2026-09-03 Jira: FORNX-123 (child of FORNX-69, Stage-7/v0.6.0)
Context
ADR-0007 (FORNX-118) named "no revocation" as residual risk #3: the only
lever available to stop a validly-signed bundle from being trusted was key
retirement via a TrustedKey's not_after, which retires every bundle
that key ever signed, not one specific compromised artifact. ADR-0008
(FORNX-119) built the local cache and activation machinery but did not
close that gap either — it names it again in its own "honest partial
closure" section. FORNX-123 is the fornax-core half of a cross-repo,
admin-triggered "emergency policy control" capability: an operator
discovers a published bundle was wrong (compromised signing key, a policy
that shipped with a bug, a revision that leaks something it shouldn't) and
needs a way to say "that specific artifact must never be trusted again" —
independent of, and faster than, publishing a corrected bundle and waiting
for every device's normal cache-refresh cycle. fornax-cloud implements
the admin-triggered side (building revocation artifacts) in parallel, in a
separate repo; this document is the single authority for the wire contract
between them.
Decision
A new, separately-signed artifact type — not a bundle field
Revocation is SignedRevocationList, its own top-level artifact, not a
field added to SignedPolicyBundle. Three reasons converge on this:
- A revoked bundle's signature is still perfectly valid. No check
inside
verify_bundlecan ever detect revocation — the entire point is that the artifact was correctly signed and is being un-trusted anyway, for a reason external to its own signature. Bolting a revocation flag onto the bundle schema would suggest the opposite: that some futureverify_bundlechange could catch it. It cannot, by construction. - Different signing domain, different producer cadence. A bundle
publish and a revocation are different events with different urgency
(revocation is the "break glass" path) and should not force a
bundle_schema_versionbump onfornax-cloud's bundle producer just because the revocation shape needs to evolve, or vice versa. - Different sequence space. See below.
SignedRevocationList/RevocationPayload/RevocationEntry/
RevocationTarget (crates/fornax-types/src/policy/revocation.rs) are the
canonical wire shapes:
SignedRevocationList {
revocation_schema_version: u32,
payload_b64: String, // base64 of the exact signed RevocationPayload bytes
signatures: [BundleSignature], // reused verbatim from bundle.rs
}
RevocationPayload { // parsed only after signature verification
revocation_schema_version: u32,
issuer: String,
sequence: u64, // per-issuer, DISJOINT from policy_sequence_high_water
issued_at: String, // RFC3339
entries: [RevocationEntry], // the COMPLETE current set, never a delta
}
RevocationEntry {
target: RevocationTarget, // { target_kind: "revision_digest" | "payload_digest", digest }
revoked_at: String, // RFC3339
reason: String, // non-empty, enforced at verify time
audit_ref: Option<String>,
superseded_by: Option<RevisionDigest>,
}
RevocationTarget is internally tagged on target_kind with a
#[serde(other)] Unrecognized tail: one entry naming a target_kind a
given binary doesn't understand yet must never make the whole list
unparseable. It is counted (RevocationSet::unrecognized_entry_count),
surfaced as a PolicyRevocationEntryNotUnderstood warning, and is
un-actionable but never fatal to the rest of the list — the same
forward-compat discipline ADR-0006 established for ActionClass/
PolicyFieldId's own Unrecognized tails.
Signing domain: REVOCATION_SIGNING_DOMAIN
pub const REVOCATION_SIGNING_DOMAIN: &[u8] = b"fornax-policy-revocation/v1\n";
Distinct from BUNDLE_SIGNING_DOMAIN (b"fornax-policy-bundle/v1\n").
Domain separation is a real, tested boundary here, not decoration: a
revocation payload signed under the bundle's domain is rejected by
verify_revocation_list, and a bundle payload signed under the
revocation's domain is rejected by verify_bundle — both directions
proven in revocation_tests.rs's T88. Without this, a signature valid for
one artifact type could be replayed as if it verified the other, since
both reuse the same ed25519_dalek::Signature::verify_strict call and (in
the common case) the same signing keys.
Reused, not duplicated: the extracted envelope-verification helper
verify_bundle's envelope/signature-verification steps (signature-count
bounds, strict base64 decode + size bound, the per-signature loop with its
4-level precedence — verified > SignatureInvalid (tampering, a trusted
current key whose signature simply doesn't check out) > first_skip_reason
(deterministic: the first known-but-unusable key's reason) > UnknownKeyId
— and the "loop always runs to completion, never returns early on the
first unusable key" rule that makes D4 key rotation work) are identical
regardless of which artifact type is being verified. bundle.rs now
exposes a pub(super) verify_signed_envelope(payload_b64, signatures, domain, max_payload_bytes, trusted, now) helper, parameterized by domain,
returning Result<VerifiedEnvelope, EnvelopeVerificationError> —
deliberately not BundleRejection, so that verify_revocation_list
does not inherit a rejection variant (like a tolerance_seconds-bearing
expiry check) for a validation this function never performs. Both
verify_bundle and verify_revocation_list map EnvelopeVerificationError
1:1 into their own exhaustive rejection enum. This refactor is
behavior-preserving by construction and by test: the full FORNX-118
acceptance suite (T28–T53 in policy/tests.rs) passes with the identical
pass/ignore count before and after the extraction.
Enforced at the cache layer, never inside verify_bundle
This is the load-bearing design decision, and it is directly testable
(T78, "the trap test," named first): a bundle that verify_bundle still
accepts — valid signature, unexpired, trusted current key — must be
rejected on activation with ActivationRejection::Revoked. If that
test can be made to pass by any change inside bundle.rs, the design was
implemented wrong.
RevocationSet::hit(revision_digest, payload_digest) is checked first
in evaluate_activation, before the pre-existing lineage/issuer check —
"must never be trusted again" outranks every other rejection reason,
including a legitimate sequence advance. Symmetrically,
try_generation_usable (the cache's reload path) checks
RevocationSet::hit first in its per-member loop, before the envelope
bytes are even read — a revoked member makes the whole generation
unusable, preserving the pre-existing "a generation is never served
partially loaded" invariant from ADR-0008.
Two digest kinds are checked, keyed independently:
revision_digest— catches every envelope that ever wraps the same underlying revision content, including a bundle "re-wrapped" under a differentbundle_id/sequence(same content, different transmitted bytes, therefore a differentpayload_digestbut the samerevision_digest).payload_digest— catches one specific transmitted artifact exactly, useful when an operator has a specific leaked/compromised envelope in hand and wants to name it precisely without asserting anything about its revision content being bad per se.
Sticky, union-only, no expiry — deliberately
Once a digest is revoked locally, it stays revoked:
- Sticky: nothing in this design ever un-revokes a digest. Not a
newer revocation list, not a trust-store edit that removes the signing
key that originally signed the now-revoked bundle (T84 proves both
directions — remove the key, still revoked; restore the key, still
revoked), not
rollback_policy_to_last_known_good(T91 — rolling back into a revoked last-known-good does not resurrect it). - Union-only:
evaluate_revocation_ingestcomputes new entries as a set difference against what is already stored. A newer list from the same issuer that omits a previously-seen entry never removes it (T86).entriesin the wire payload is always the complete current set, never a delta — the union-only rule lives in the ingest function, not in an assumption about delta encoding. - No expiry: no
not_before/expires_atexists on a revocation list or on an individual entry, andverify_revocation_listperforms no window check at all. An expiring revocation would let a bad artifact resurrect itself once the clock passed a date that the artifact's own signed content — or a merely misconfigured issuer — controls the shape of. A revocation is not a lease; it is a permanent local fact once observed.
Sequence: per-issuer, disjoint from policy_sequence_high_water
RevocationPayload.sequence is a per-issuer monotonic counter,
completely disjoint from policy_sequence_high_water's
(issuer, policy_id)-keyed counter. A revocation list has no policy_id
at all — it names digests directly, across whatever lineage they came
from — so conflating the two counters would be a category error, not a
naming collision. evaluate_revocation_ingest's rule:
sequence < high_water rejects (SequenceNotAdvanced);
sequence == high_water is AlreadyCurrent (idempotent re-import, no
duplicate rows — T85); sequence > high_water computes the new-entries
set difference and applies.
Issuer convention and cross-tenant isolation caveat
Proposed issuer convention: fornax-cloud:<organization_id> — stable,
tenant-scoped, and makes a future IssuerMismatchForLineage-style check
meaningful if one is ever added for revocation (none exists today; nothing
in this ticket needs it, since revocation intentionally has no
policy_id-keyed lineage concept to protect).
Issuer-agnostic on the device, by design, with a named caveat. A
revocation entry from any trusted issuer revokes the named digest —
RevocationSet does not partition by issuer when answering hit(). This
is a deliberate simplification: cross-tenant isolation (issuer A cannot
revoke issuer B's bundles) is enforced by fornax-cloud's own
authorization at publish time — the cloud only lets an organization's
admin build a revocation list naming that organization's own artifacts. It
is not device-verifiable, and this design does not attempt to fake that
verification locally. A device that somehow received a revocation list
from an issuer it should not have trusted for that purpose has a larger
problem (its trust store itself is misconfigured) that this ticket does
not solve and is not scoped to solve.
PolicyPosture: converting a silent fail-open into a loud one
A real, pre-existing gap this design surfaces, not introduces, and does
not silently fix. When a generation becomes wholly unusable — by
revocation or by any other cause ADR-0008 already covers — resolve()
returns baseline, whose enforcement_rules is an empty Vec.
EffectivePolicy::enforcement_outcome_for therefore returns ObserveOnly
for every action class, because the FORNX-119 staleness floors are
rule-anchored (staleness_floor only ever tightens an existing rule's
outcome) and there are zero rules to anchor to. Revocation does not, on
its own, tighten enforcement — it only stops the revoked artifact from
being loaded/trusted. T93 ("the honest test") pins this as tested
behavior: with usable = [] and ever_configured = true,
enforcement_outcome_for reads ObserveOnly for every
action-class/verdict pair.
This ticket does not invent a default per-action-class risk assumption to
paper over that gap — doing so would contradict ADR-0006's fail-open
selector philosophy and action_classification.rs's explicit "never a
silently invented risk assumption" discipline. Instead, PolicyPosture —
enum PolicyPosture { Normal, Degraded { reason: PolicyDegradationReason } }
enum PolicyDegradationReason { Revoked, Unverifiable, TrustStoreUnavailable, NoUsableGeneration }
— makes the condition loud and machine-readable: compute_posture
degrades only when usable is actually empty (a successful fallback to a
clean last-known-good, even after a revocation forced it, is Normal —
something is still being enforced), with an explicit precedence when
multiple diagnostic causes are present at once (Revoked >
Unverifiable > TrustStoreUnavailable > NoUsableGeneration — the
most-specific cause wins), and treats a fresh install that has never had a
bundle imported as Normal, mirroring freshness's own Unconfigured
tier philosophy: no penalty is invented merely because nothing has ever
been configured. Wiring PolicyPosture into an actual enforcement
decision is explicitly out of this ticket's scope — a future
enforcement-wiring ticket's job; this ticket only computes and surfaces
the posture.
Store layer: three tables, an append-only artifact log
Migration 0009_policy_revocation.sql adds three tables — a deviation
from the design sketch that showed only two
(policy_revocation_state/policy_revocations) but required, in prose,
an append-only artifacts table so fornax policy status has real
provenance and "immutable and reconstructable" (this ticket's AC5) is
literally true on-device, not merely asserted:
policy_revocation_artifacts(issuer, sequence, envelope, received_at)— append-only, the signed envelope bytes exactly as received, one row per(issuer, sequence)ever successfully ingested. Never re-verified after ingest (the sticky rule means there is no reason to), but gives a real audit trail.policy_revocation_state(issuer, max_sequence, last_payload_digest, last_seen_at, unrecognized_entry_count)— the latest-pointer table, one row per issuer, never lowered.unrecognized_entry_countaccumulates across every list ever ingested from that issuer, since anUnrecognized-kind entry carries no digest to key an actionable row on and is therefore never given a row in the next table.policy_revocations(issuer, target_kind, target_digest, reason, revoked_at, audit_ref, superseded_by, first_seen_sequence, first_seen_at)— the union-only, sticky set.INSERT OR IGNOREkeyed on(issuer, target_kind, target_digest): a row is written once, the first time a digest is observed, and never updated or deleted afterward.
Store::submit_policy_revocation mirrors Store::submit_policy_bundle's
normative order and crash-safety argument exactly (ADR-0008's own section
on this is unchanged and still applies verbatim): verify outside any
transaction (an invalid list never opens one) → BEGIN IMMEDIATE → load
state → evaluate_revocation_ingest → persist → COMMIT. T89 proves this
the same way ADR-0008's T71 did — open a transaction, write an uncommitted
competing update, drop the connection without committing, reopen: prior
state is byte-for-byte intact.
rollback_policy_to_last_known_good is not modified by this ticket.
Revocation does not need it to change: the load path already handles a
revoked last-known-good correctly on its own (falls through to
usable = [], per T91), and policy_sequence_high_water remains
completely untouched by anything in this ticket (disjoint counter,
different table, as above).
Surfaces
IngestMessage::PolicyRevocation { envelope: String }— additive variant, the same fire-and-forget UDS path asPolicyBundle.handle_policy_revocation_ingestmirrorshandle_policy_bundle_ingest's structure and refreshesAppState.policyafter every successfulApplied/AlreadyCurrentoutcome, exactly like the bundle path — this is what makes revocation take effect immediately in a running daemon rather than at next restart.fornax policy import <path>dispatches on the artifact's own top-level shape (bundle_schema_versionvsrevocation_schema_version) and sends the matchingIngestMessagevariant — an emergency responder must not have to remember which subcommand imports which artifact type during an incident. An ambiguous (both keys present) or unrecognized (neither key present) shape refuses with a clear message rather than guessing.GET /api/policygainsrevocations: { entry_count, unrecognized_entry_count, max_sequence_by_issuer }andposture, additive to the existing response shape.fornax policy statusrenders both as their own lines — posture is never collapsed into the pre-existingdegradedboolean, which answers a different question (any diagnostic present, or merely serving from last-known-good) than posture does (is anything actually being enforced right now).DiagnosticCodegainsPolicyCacheRevoked(Error),PolicyRevocationEntryNotUnderstood(Warning), andPolicyRevocationRejected(Warning), additive to the closed enum — the same cross-repo-consumer caveat ADR-0008 already noted for its own additions applies here: a consumer built against a prior version of this enum (e.g.fornax-cloud's authoring UI) must be updated to handle these.
Local-attacker boundary — unchanged
This design does not invent a new defense against a local attacker with
$FORNAX_HOME write access. ADR-0008's existing section on this boundary
applies verbatim: anyone who can write to the local SQLite file can already
do worse than un-revoke a digest. Revocation's sticky/union-only guarantees
are about a remote issuer/attacker's ability to un-revoke something via
the normal ingest path, not about local filesystem tampering.
Honest limits (named, not hidden)
- There is no cloud→device delivery channel yet. "Emergency" response
time today is however fast a human runs
fornax policy importon each device. This is the biggest gap and the natural next ticket — a push or poll mechanism that gets a revocation list onto a device without a human in the loop per device. - An offline device keeps using a revoked policy until it reconnects. Forced by ADR-0001 D2 (no cloud dependency on the local critical path, local-first by design) — not fixable within this design, and not a defect of this ticket specifically. A device that never talks to anything cannot learn anything it wasn't told locally.
- A transport attacker who delivers bundles but selectively drops
revocations defeats revocation undetectably. Nothing in a bundle
payload today attests to "the minimum revocation sequence this bundle's
issuer expects you to already have" — an attacker (or an unreliable
network) that lets bundles through while silently dropping revocation
lists leaves a device fully functional and fully unaware anything was
revoked. The fix — an issuer-attested
min_revocation_sequencefield inside the signed bundle payload, checked at activation time against the device's own revocation high-water mark — is a concrete, named, deferred requirement. It needs abundle_schema_versionbump, and no bundle producer exists anywhere yet (see out-of-scope below), so there is nothing to retrofit it into today. - The FORNX-119 gap this ticket surfaces is inherited, not
introduced. Full cache loss (by any cause — expiry, key retirement,
corruption, or now revocation) degrades enforcement to
ObserveOnlyfor every action class, never toBlock, because the staleness floors are rule-anchored and baseline carries zero rules. This was already true before FORNX-123; this ticket's contribution isPolicyPosture, which makes the condition observable instead of silent — it does not change what enforcement actually does.
Closing ADR-0007's residual risk #3
ADR-0007 listed "no revocation" as an open residual risk, with the only
lever being wholesale key retirement. This ticket closes it for
artifacts that reach a device: a specific compromised bundle (by
revision_digest or payload_digest) can now be named and permanently
un-trusted without retiring the key that signed it, which may have signed
many other still-good bundles. It remains open for the two cases named
above: withholding (honest limit #3) and offline devices (honest limit
#2) — both are inherent to the architecture (ADR-0001 D2's local-first
posture, and the absence of any transport-level delivery guarantee), not
oversights of this specific ticket.
Out of scope (prerequisite follow-up tickets, not built here)
- Cloud→device policy distribution channel — the biggest named gap (honest limit #1). Closed by ADR-0010 (FORNX-311) on the device (fornax-core) side; the fornax-cloud fetch-endpoint half lands as a separate PR in that repo.
- A
SignedPolicyBundleproducer infornax-cloud— a separate ticket.fornax-cloud's FORNX-123 half builds revocation artifacts only, not policy bundle artifacts; no bundle producer exists anywhere in either repo yet. Closed by fornax-cloud's own FORNX-311 PR (out of this repo) — ADR-0010 references but does not build or review it. - Enforcement wiring that consumes
PolicyPosture/EffectivePolicy's outcome to actually change what gets blocked — a future enforcement-wiring ticket. min_revocation_sequenceanti-withholding binding (honest limit #3) — needs abundle_schema_versionbump and a bundle producer that doesn't exist yet.- Human-authenticated audit attribution for who triggered a
revocation — currently
device_id-only provenance, pending FORNX-111 (human-auth) elsewhere in the platform.
Test coverage
crates/fornax-types/src/policy/revocation_tests.rs, T78 onward
(continuing policy/tests.rs/policy/cache_tests.rs's T1–T70):
T78 (the trap test); T82/T83 (payload_digest vs. revision_digest
revocation reach); T86 (sequence discipline, union-only); T87 (the
#[serde(other)] forward-compat parse half); T88 (domain separation, both
directions); T92 (single-byte-mutation property test); T93 (the honest
test). crates/fornax-store/src/policy_cache.rs's own test module, T79-T81
and T84/T85/T87(store half)/T89/T91 (continuing its own T71–T77): the
store-integration and crash-safety scenarios that need a real
SQLite-backed Store — cached-then-revoked; fallback to a clean
last-known-good; revoked-in-both degrades posture; stickiness across a
trust-store edit in both directions; idempotent re-import with a row-count
proof of no duplication; the unrecognized-entry persistence half; crash
safety; and rollback into a revoked last-known-good never resurrecting it.
The full FORNX-118 acceptance suite (T28–T53) is the regression gate for
the verify_signed_envelope extraction and passes unmodified.