Policy by example
Who this is for: anyone who writes or reviews the JSON files in a ZIFFER policy bundle.
Who this is for: anyone who writes or reviews the JSON files in a ZIFFER policy bundle. You do not need to know how the Executor works. You do need to be comfortable reading JSON and willing to think about absence, because that is most of what this page is about.
What you end up with: a reading of every member of a bundle, one file at a time: what it declares, a small valid example, the fields it requires, and what happens when a key you expected to be there is not. For how a bundle is signed and published, see the Publishing policy from your own CI guide.
1. The idea in one paragraph
A policy bundle is a directory of small JSON files, signed as one unit. Nothing in it is a secret: every value is a public key, an identifier, a class name or a number, which is why the whole directory can live in a repository your team reviews by pull request. Every reader verifies the signature over the directory before it reads a byte of any member, so a member is either signed policy or it is not there at all. Most of the rules below are about absence, because absence is the case nobody thinks about while writing the file, and the case an implementation is most tempted to resolve in the convenient direction.
2. The directory
Twelve members: eleven files you can read, plus the signature over them.
manifest.jsonwho the bundle is for, who wrote it, which epoch, when it expiresfloors.jsonthe tier floor of each governable resourcerisk_functions.jsonhow an action class is gradedreversibility.jsonwhich action classes can be undonenotice_targets.jsonwho is told when an irreversible action runs below floor-HIGHalert_targets.jsonwho is woken for each class of critical alertadapters.jsonthe fidelity class of each registered front doorlimits.jsonthree deployment numbers, all optionalreceipt_identity.jsonthe public half of the key that signs your receiptsdoor_identities.jsonthe approval and notification services' verification keysattesters/registry.jsonwho may approve or confirm, and the quorumSIGNATUREthe suite and one signature value per primitive
The Publishing policy from your own CI guide prints the same layout with the publishing rules
beside it: the three fields in manifest.json that decide whether a publish succeeds, and the
three members handed to you rather than written by you.
One thing about the list itself: limits.json is optional in a way the others are not, and a
bundle without it reads as every one of its fields at its default. The walk that decides which
files the signature covers is described under SIGNATURE below.
3. Three kinds of missing
Which answer a lookup gives when it misses is a decision per file, not an accident.
| Kind | Members | What happens |
|---|---|---|
| A fail-safe value | floors.json, reversibility.json, limits.json, the assurance map | The lookup returns the safe answer: the highest tier, irreversible, the stated default, the lowest assurance claim. |
| A refusal | risk_functions.json, notice_targets.json, alert_targets.json, adapters.json, SIGNATURE | There is no safe value to return, so nothing runs. An action nobody graded, or one whose notice has no addressee, is refused rather than guessed at. |
| A rule no schema can state | the registry, door_identities.json, alert_targets.json, manifest.json, risk_functions.json | Comparisons between two values, or completeness over a list the schema does not own. Enforced when the bundle loads, and a loader that skips them is not conformant however cleanly the JSON validates. |
The third row is the one you cannot see by reading the JSON: no two attesters may share a public key, the two doors may not share one, every alert class must name a recipient, author and reviewer must differ, and at most one risk function may exist per action class.
4. manifest.json
Who the bundle is for and where it came from: the tenant, the epoch, the two people behind it, the expiry, the suite floor it imposes on downstream receipts and attestations, and a human-readable note on how the signing key is held. Every field is required.
{
"schema_version": "1",
"tenant_id": "acme",
"bundle_epoch": 42,
"created_at": "2026-09-09T09:15:00Z",
"author": { "id": "u-1042", "display_name": "Ada Okonkwo" },
"reviewer": { "id": "u-2087", "display_name": "Ravi Menon" },
"expires_at": "2026-12-09T00:00:00Z",
"min_suite": "hybrid-ed25519-mldsa65",
"custody": {
"tier": "T1",
"classical": { "tier": "T2", "mechanism": "cloud KMS, non-exportable key" },
"pq": { "tier": "T1", "mechanism": "age-encrypted file, offline laptop" }
}
}A manifest naming no tenant is not a bundle: the fail-safe reading of "no tenant" is "no bundle",
never "any tenant". bundle_epoch is strictly increasing, never reused and never decreased, and
the Executor keeps a high-water mark for it indefinitely, because an epoch mark that expired would
reopen rollback. author.id and reviewer.id are compared byte for byte, never display_name,
because two people can share one. An expired bundle must not be served: a grace window in which
every decision is capped at attestation is allowed, one that serves normally is not.
min_suite floors the suites of downstream receipts and attestations. It is not this bundle's
own floor, which is configured out of band in the verifier, because a floor the bundle itself can
lower is not a floor. Reusing this field as the bundle's own floor looks like an improvement and
is the recurring mistake.
custody is not a control input: no verifier weights any decision on it, because a bundle
asserting its own custody strength is exactly what a compromised signer writes freely. It is there
for auditors, and the real binding is that the verifier holds the expected public key out of band.
Note the example: the legs are held differently and the top-level tier reports the weaker of
them, because a claim should never round in the flattering direction.
5. floors.json
The floor tier of each governable resource. T0 public or sandbox, T1 internal, T2
production, T3 privileged. Ordered, and composed by taking the maximum, so the order carries
weight rather than being cosmetic. Requires schema_version and floors; the map is open
because resources are specific to your deployment, and the values are closed.
{
"schema_version": "1",
"floors": {
"billing-db-prod": "T2",
"iam-role-admin": "T3",
"support-inbox": "T1",
"sandbox-scratch": "T0"
}
}A resource that is missing is treated as T3. Absent means unknown, and unknown is the
highest tier, not the lowest. Returning T0 for an unclassified resource is the whole class of
defect this design exists to prevent, and it is the direction a "maybe a tier" lookup invites.
What this file cannot do. Nothing at runtime evaluates a resource below its signed floor, and
nothing can tell you the floor was set correctly. A production database labelled T0 defeats the
design with no attack at all. Maintaining this file is the highest-leverage governance task in
the system and the one part of it that is structurally unprovable.
6. risk_functions.json
How an action class is graded, from the proposal's parameters and its targets' tiers rather than
from the action's name: modify_firewall_rule = HIGH says nothing useful, because the risk
depends on which rule and which firewall. Each function requires applies_to, base and
raise_to, and at most one function may exist per action class, since two is an ambiguity rather
than something to merge.
{
"schema_version": "1",
"risk_functions": [
{
"applies_to": "transfer_funds",
"base": "MEDIUM",
"raise_to": [
{ "if": "resource.effective_tier >= T2", "then": "HIGH" },
{ "if": "amount_minor >= 1000000 && currency in ['USD', 'EUR']", "then": "HIGH" }
]
},
{
"applies_to": "send_email",
"base": "LOW",
"raise_to": [
{ "if": "recipient_class == 'external'", "then": "MEDIUM" },
{ "if": "recipient_class == 'external' && resource.effective_tier >= T3", "then": "HIGH" }
]
}
]
}Every clause above was run through the engine's own parser and evaluator before it was printed here, in both implementations, and they agree on each one. A worked example that does not parse teaches a reader a language nobody implements.
raise_to holds clauses, each a condition and the level it raises to. A clause can only raise,
never lower: one that could lower would let a crafted parameter walk a high-risk action down to
low. The final level is the maximum over every clause that fired, so the order clauses are
written in does not change the answer (ZIFFER-SPEC-001 RK-3) -- the two transfer_funds clauses
above could be swapped and grade every proposal identically. Section 6a is the grammar.
An action class with no function is REFUSED, not graded HIGH. Grading an unknown action HIGH looks conservative and is worse: it routes something the policy author never considered into the human quorum, and doing that at volume teaches approvers to rubber-stamp. Unknown is never LOW, and here it is not HIGH either. The grade is recomputed by the Executor from the proposal it received independently and from the signed bundle; a risk value asserted in a receipt is ignored.
6a. The grammar of a raise clause
This is a rendering of ZIFFER-SPEC-001 §8.3.1 for someone writing a clause. The specification is the definition; every rule below names the clause it comes from, so where this page and the specification disagree, the specification is right and this page is the defect.
Expr ::= AndExpr ("||" AndExpr)*
AndExpr ::= Term ("&&" Term)*
Term ::= "(" Expr ")" | Comparison
Comparison ::= Value ("==" | "!=" | "<" | "<=" | ">" | ">=") Value
| Value "in" Set
| Value ".prefixlen" "<=" Number
Value ::= FieldRef | Literal | Number
FieldRef ::= Identifier ("." Identifier)*
Literal ::= String | TierLiteral
TierLiteral::= "T0" | "T1" | "T2" | "T3"
Set ::= "[" Literal ("," Literal)* "]"That is the whole language. No loops, no functions you can define, no regular expressions, no
arithmetic (RK-4). A string literal is written in single quotes. Tier literals are bare words and
are ordered, so >= T2 means what it reads as. Risk levels are not values in this language at
all: LOW, MEDIUM and HIGH appear only in base and then, never in a condition.
The names you may reference. The environment a clause is evaluated against is built from the proposal and the signed bundle, and it holds exactly these:
| name | what it is |
|---|---|
each key of the proposal's params | bound under its own bare name -- a parameter called currency is written currency, with no prefix |
each key of the proposal's cidrs | a network, usable only through .prefixlen |
<resource>.effective_tier | the tier of one named target, written with that target's own resource name |
resource.effective_tier | the maximum tier over every target of this proposal. With no targets it is T3, because absent is never the lowest tier |
fidelity | the fidelity class of the front door the proposal came through, recomputed from adapters.json and never read from what arrived |
There is nothing else. A name outside that list is not an error, and that is the next rule.
The language is total, and totality is not leniency (§8.3.1, evaluation rules). A field that
does not resolve evaluates to false. A comparison between two different types evaluates to
false. There is no error path for an expression to fall open through, which is the point: an
evaluator that threw on a bad reference would be an evaluator a crafted proposal could stop. What
it costs you is that a misspelled name silently never fires, and since a raise_to clause that
cannot fire cannot raise, the failure is in the permissive direction and nothing reports it. Read
your clauses against your own params names; that comparison is yours and no verifier makes it.
A clause that does not parse fails closed, at clause 8.3.1. Not ignored, not treated as
false -- the proposal is refused and the refusal is named. Parsing is the only fallible half of the
language.
&& binds tighter than ||, and both are left-associative (EL-1). So
a || b && c is a || (b && c). This is written down because its absence was a real defect: for
four releases the production placed the two connectives at one level with no precedence rule, two
readers of the same text produced different parse trees, and a generated differential over 10,000
expressions found them disagreeing on 4.9% of them. Parenthesise mixed expressions anyway. The
rule makes the meaning defined; parentheses make it the meaning you intended, and the two are not
the same problem.
Only integers and strings may arrive in a value (EL-2). A proposal that supplies a parameter
as a non-integer number, a boolean, null, an array or an object is refused under that clause,
and so is an integer too wide for the deployment's declared width. It is not coerced. 22.0 is not
read back to 22: reading it back is a second definition of a number, it leaves 22.5 with no
home, and it decides on your behalf which of two spellings you meant. The refusal is there because
the alternative was measured -- a JSON 22.0 once bound as a string, every comparison mentioning
it went quietly false, and the same action that graded HIGH as 22 graded MEDIUM and executed
with no attestation at all.
Sets are non-empty and homogeneous, and .prefixlen is only for a CIDR field. Both are static
constraints of §8.3.1 rather than runtime behaviour.
What the clause is graded against is the FLOOR, not what the runtime says (TR-5). The tier in
resource.effective_tier may be raised at runtime, and a raise only ever produces more
attestation. But the grade that decides whether an action is floor-HIGH -- the one that decides
whether a human is required -- is computed from the signed floors alone, so no runtime component
can drive it down. Your clauses are read twice against two tier values for that reason, and the
lower reading is the one that governs the quorum.
The Executor recomputes all of this. It holds the same signed bundle and its own copy of the proposal, so the grade is derived twice, independently, and a risk level asserted in a receipt is ignored (TR-8). A clause you write is therefore evaluated by more than one process, which is also why the precedence rule above had to be written down rather than left to each parser.
7. reversibility.json
Whether an action class can be undone. Requires schema_version and reversibility. Open map,
closed values, and the two values are two cases rather than a scale, so they are deliberately
unordered.
{
"schema_version": "1",
"reversibility": {
"transfer_funds": "IRREVERSIBLE",
"send_email": "IRREVERSIBLE",
"scale_deployment": "REVERSIBLE"
}
}An action class that is missing is treated as IRREVERSIBLE. An action nobody classified is
one nobody thought about, and the fail-safe reading of that is that it cannot be undone.
This matters more than it looks, because reversibility selects the acknowledgement regime: an
irreversible action at floor-HIGH demands a positive acknowledgement from someone who is not the
operator, and silence is not consent. An entry wrong in the permissive direction converts a held
action into a released one. The value is recomputed here and never read from a receipt, because
an issuer that could claim REVERSIBLE would thereby obtain the silent path. As with floors,
that a classification is honest is conceded unprovable.
8. notice_targets.json
Who is told when an irreversible action of a given class executes below floor-HIGH. Requires
schema_version and notice_targets, and every value is a non-empty set of identities.
{
"schema_version": "1",
"notice_targets": {
"send_email": ["security-oncall", "workspace-owners"],
"transfer_funds": ["finance-controllers"]
}
}Risk is recomputed from the target's floor; reversibility is a property of the action class. They
are orthogonal, so an action can be irreversible and low-risk at once, and mail is exactly that
shape: floor an inbox at T1, the natural choice because reading mail is routine, and an injected
send is both unrecoverable and too low-risk to reach the approval path. This file is what catches
it. The recipients are named in signed policy rather than in the notifier's configuration because
a service that selects its own audience is certifying its own coverage.
A class with no entry, or an empty one, REFUSES the action. A notice with no addressee is not
a detection channel, and executing while the detection channel is absent is executing unwatched.
An empty array is refused rather than read as "notify nobody", because at run time the two are
indistinguishable from an entry that was never written. A deployment that wants no notice channel
for a class must say so through floor policy, by raising the class to T2 or above, rather than
by writing a control that looks configured and does nothing.
It deliberately does not require acknowledgement: below floor-HIGH there is no quorum, the traffic is the bulk of a deployment, and saturating approvers produces assent rather than refusal. It buys detection, not prevention. Nor is delivery a precondition of execution: what the action waits for is the durable local commit of the notice, so a notice committed and never delivered leaves an audit record and no human, and reconciling the two is a deployment obligation.
9. alert_targets.json
Who is woken for each class of critical alert. Requires schema_version, alert_targets, and
every one of the eleven classes below naming at least one recipient.
{
"schema_version": "1",
"alert_targets": {
"BUNDLE_INVALID": ["policy-owners", "security-oncall"],
"SIGNING_SUBSTRATE": ["platform-oncall"],
"RECEIPT_INVALID": ["security-oncall"],
"RECOMPUTATION_MISMATCH": ["security-oncall"],
"ATTESTATION_INVALID": ["security-oncall"],
"LEDGER_REPLAY": ["security-oncall"],
"SUITE_BELOW_FLOOR": ["security-oncall"],
"DEFERRED_RELEASE": ["platform-oncall"],
"AUDIT_INTEGRITY": ["security-oncall"],
"ATTESTATION_QUEUE_DEPTH": ["platform-oncall"],
"CONTEXT_STORE": ["platform-oncall"]
}
}The class list belongs to the specification rather than to this file, so the schema leaves the map open and the loader does the checking. Identities, not addresses, for the notice recipients' reason: an address is a routing detail that changes without a policy review, and putting one under an offline signing key makes routine operations require the signing ceremony.
A class that names nobody stops the bundle loading. An alert with no addressee is not a detection channel. The refusal is at load and not when an alert is raised, and the difference is the point: a notice's "fail closed" has an action it can withhold, but an alert is raised on a path that has already failed, so refusing then would withhold nothing. What can be withheld is the bundle. A deployment either names who is woken for each class, or it does not run. How an alert travels, and what acknowledgement closes one, are deployment obligations decided elsewhere.
10. adapters.json
For each registered schema_id, the fidelity class of the front door that produces proposals
under it. Requires schema_version and adapters, and the table as a whole rather than
optionally: a bundle without it fails to load rather than acquiring an empty registry that
refuses every proposal for a reason nobody wrote down. Keys are registered schema identifiers:
lower-case letters, digits, underscore and hyphen, up to 32 characters.
{
"schema_version": "1",
"adapters": {
"transfer_funds": "F-HIGH",
"ops_chat": "F-LOW"
}
}F-HIGH means no free text reaches the proposal: every field is a bounded scalar, a constrained
string, an enum or a fixed-shape nested object, so instruction injection into the proposal is
grammatically impossible. F-LOW means a translator model produced the proposal from free text,
injection into that step is possible by construction, and containment rests on capability limits,
confirmation and receipts rather than on any attempt to clean the input. Exactly one class per
front door, which the map shape enforces by having nowhere to put a second. The two are not
ranked: confirmation is required for F-LOW at effective tier T2 or above, a case distinction
rather than a comparison, and here HIGH is the safer class.
A schema_id with no entry is REFUSED, never defaulted. There is no fail-safe class to fall
back to: F-HIGH would suppress the confirmation requirement exactly as a forged stamp would, and
F-LOW would be a policy value the implementation invented for a front door nobody registered.
The value is a control input rather than metadata, readable by a raise clause as fidelity, and
disagreement between this table and the fidelity a receipt transmits is a critical alert.
That an adapter's declared class is honest is conceded unprovable. An F-LOW front door
registered as F-HIGH defeats the confirmation rule with no attack, and no verifier can tell from
this file.
11. limits.json
Three numbers the specification calls policy and no other member carried: how long a human has to
approve, how long a floor-HIGH action is held before it may release, and what fraction of
reversible releases draws a mandatory acknowledgement. Only schema_version is required.
{
"schema_version": "1",
"attestation_window_seconds": 900,
"hold_window_seconds": 60,
"sample_percent": 10
}A missing field takes the specification's own default: 3600 seconds for the attestation window, 60 for the hold, 10 percent for the sample. They are the document's numbers and not an implementation's, so a bundle omitting the file entirely reads as every field at its default. A deployment that wants the defaults may say nothing; one that wants anything else signs it here, rather than setting it in the environment of whichever process happens to read it.
Bounds are refusals, not clamps. A value outside its range makes the bundle invalid and is never rounded to the nearest legal value, because a clamped limit is a limit nobody chose.
| Field | Range | Why the bound is there |
|---|---|---|
attestation_window_seconds | 1 to 3600 | 3600 is the stated ceiling on how long an attestation stays valid. |
hold_window_seconds | 30 to 119 | At least 30, because a hold too short for a human to read the summary is not a detection channel. Below 120, because a receipt is valid for 120 seconds and a hold that can outlive its own receipt is an unsatisfiable configuration. |
sample_percent | 0 to 100 | A percentage. 0 is permitted and switches the sampled acknowledgement channel off; a deployment choosing it should say so in its residual risk statement. |
Integers only: a float in a signed policy file is a canonicalisation question. One honest limit: the Executor cannot verify the attestation window, because an attestation object carries its expiry and no issue instant, so only the issuer knows how long the window really was. The declared value governs the issuer, and the bundle is signed.
12. receipt_identity.json
The public half of the key that signs your decision receipts: one Ed25519 and one ML-DSA-65 verification key, named by the signed bundle. Handed to you at onboarding, signed by you with the rest. All four fields are required.
{
"schema_version": "1",
"name": "acme-receipt-signing-2026",
"classical": "EXAMPLEreceiptEd25519publicKEYAAAAAAAAAAAAA=",
"pq": "EXAMPLEreceiptMlDsa65publicKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}Both key legs are required because verification is conjunctive: a receipt verifies only when every
leg does, so an identity registered with one leg is a key under which no receipt can ever verify.
Keys are standard base64, and the values above are obvious placeholders at their real lengths: 44
characters for Ed25519, 2604 for the post-quantum key, which is 1952 bytes. name is a label for
the keys beside it, used in the audit record and the alert, and never what a signature is resolved
by: identity is what the signature verifies under, and a name does not authorise itself.
Absent, one leg short, or not a key: the bundle is refused at load, under one name. The classical leg is additionally checked for weakness, because a small-order point is a key under which one signature verifies every message.
The member exists so that a receipt check reads this key from signed policy rather than from its
own configuration, which would be a verifier trusting its deployment's description of the party it
verifies. ZIFFER's signer compares the identity it holds for you against this member and refuses
to sign on a mismatch, so a bundle naming a key we do not hold fails where an operator can read
it. Custody metadata belongs in manifest.json; rotation is not a field here, because a new
receipt identity is a new bundle_epoch.
13. door_identities.json
The two services whose summaries are compared while a floor-HIGH action is held: the approval path
the attesters were shown the action through, and the independent notification path the summary
reaches them by out of band. Both doors are required, each with name, classical and pq.
Handed to you at onboarding.
{
"schema_version": "1",
"presentation": {
"name": "acme-approval-presentation",
"classical": "EXAMPLEpresentationDoorEd25519BBBBBBBBBBBBB=",
"pq": "EXAMPLEpresentationDoorMlDsa65BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
},
"notification": {
"name": "acme-notification",
"classical": "EXAMPLEnotificationDoorEd25519CCCCCCCCCCCCC=",
"pq": "EXAMPLEnotificationDoorMlDsa65CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="
}
}The two doors must not share a key, compared over the complete suite, because two identities sharing only a post-quantum key are not distinct. That is enforced when the bundle loads, since a schema has no keyword for a relation between two of its own values.
The keys are what make the second path independent in a way a verifier can check. Establishing it instead from labels the notification service chooses about itself is evidence about a service produced by that service, which a compromised notifier satisfies by writing the right labels.
What a key here does not prove. A summary that verifies under the notification key was
produced by the holder of that key, for the proposal it names. That its prose is faithful to the
action is a structural requirement on the two services, not something a signature can supply. A
signature over a lie is a signed lie. What it can no longer be is a lie told by the other door.
14. attesters/registry.json
Who may approve or confirm, the verification key of each, and how many must sign. The one member
that lives in a subdirectory, and also handed to you at onboarding. Requires schema_version,
quorum_k and attesters.
{
"schema_version": "1",
"quorum_k": 2,
"min_attester_assurance": "AS1",
"attesters": {
"ada@acme.example": {
"kind": "webauthn", "role": "approver", "alg": "webauthn-es256",
"rp_id": "approve.acme.example",
"credential_id": "EXAMPLEcredentialIdAda",
"public_key": "EXAMPLEcoseKeyForAdaP256publicKEYplaceholder"
},
"ravi@acme.example": {
"kind": "webauthn", "role": "confirmer", "alg": "webauthn-es256",
"rp_id": "approve.acme.example",
"credential_id": "EXAMPLEcredentialIdRavi",
"public_key": "EXAMPLEcoseKeyForRaviP256publicKEYplaceholde"
}
},
"assurance": { "ada@acme.example": "AS2", "ravi@acme.example": "AS1" }
}Public keys only, and that is a custody rule rather than a stylistic one: a registry holding the keys that produce signatures is a registry whose reader can mint its own quorum, and no protocol test finds that, because the defect is custody rather than control flow.
Entries are tagged. kind: webauthn is a person who enrolled a passkey or a hardware security
key, as above; kind: hybrid is a machine signing with a key pair, carrying classical and pq
keys of exactly the shape receipt_identity.json uses. An entry without the tag is refused,
because a key kind guessed from which fields happen to be present is a guess.
quorum_k is how many distinct attesters must sign the same canonical hash for a floor-HIGH
action. 1 is permitted and is a deployment choice rather than a conformance failure, but it
collapses the invariant to a single compromise, so a deployment choosing it should say so in its
residual risk statement rather than discover it later. Whoever proposed the action can never be
among the k, enforced per action rather than by the roles here: one person may hold both roles.
No two identities may carry the same public key. A registry mapping two names onto one key lets the holder of that one private key sign two objects, label them with the two names, and satisfy a quorum of two alone. The schema cannot express uniqueness across the values of a map, so this is enforced when the bundle loads, over the full identity rather than one leg of it.
Assurance. AS0 is a software key, AS1 a multi-factor-protected key, AS2 a hardware-bound
key; they are ordered and compared with "at least". min_attester_assurance is optional and
absent means AS0, a permissive reading taken openly, because a floor nobody chose should not
silently invalidate every registry written before the field existed. An identity missing from the
assurance map also reads AS0, and that direction is deliberate: for a claim, the low default is
the fail-safe one, since an entry claiming less than it has can only cause a refusal, while one
defaulting to more than it has is the attack. A deployment claiming any human-approval property
stronger than key possession should raise this floor, and one sitting at AS0 should say so in
its residual risk statement.
An identity not in this registry is not an attester. Its signature verifies against no key here and counts toward no quorum. Resolving an unknown name to a default would let someone nobody enrolled satisfy the quorum invariant.
On the human entries: rp_id is the host the credential was created for, held per entry because
that is what the credential is bound to, so an assertion for any other origin is one made on
another site, which is the phishing case that field refuses. credential_id names the credential
and is neither a secret nor a key. public_key is written by the enrolling service in one
canonical encoding, because a key admitting two would be two strings for one credential and the
distinctness rule above would see one holder as two. A human entry at AS0, or with no level at
all, is an invalid bundle. Whether a key is really hardware-bound is established at enrolment by
people, and a wrong label here is not something any file can catch.
15. SIGNATURE
The signature over the whole directory: the declared suite, and one value per primitive that suite
requires, over the hash of the canonical tree. You do not author this file, the signing tool
writes it, and it is named SIGNATURE with no extension at the bundle root.
{
"suite": "hybrid-ed25519-mldsa65",
"parts": {
"classical": "0123456789abcdef0123456789abcdef",
"pq": "fedcba9876543210fedcba9876543210"
}
}Values are lower-case hex, and the two above are shortened placeholders: an Ed25519 signature is 64 bytes and an ML-DSA-65 signature is 3309, so the real strings run 128 and 6618 characters. The suite is inside the tree hash on purpose: outside it, an attacker relabels a hybrid bundle as classical, the verifier obligingly requires one primitive, and the downgrade costs nothing.
The values present must be exactly those the suite requires, and every one must verify. Not
"at least one": a value for a primitive the suite does not declare is as much a refusal as a
missing one, because a check shaped as "any of these verified" lets an attacker strip the
post-quantum leg, present a genuine classical signature, and be accepted. A primitive the suite
requires and this file does not carry is a stripped leg, and absent is never read as "not
applicable". One suite name in the list, slhdsa128s, is declared and not implemented; it keeps
its own name and fails closed, because the one thing an unimplemented primitive must never do is
let a verifier report success for something it cannot compute.
What this does not give you yet
Nothing here checks a bundle directory against these schemas before you sign it. The schemas
are what our services read when they load your bundle, and they are the source of every rule on
this page, but they are not run over your directory as an authoring step. What tells you a bundle
will be accepted is ziffer verify, which checks the bundle the way the runtime will and
names the refusal if there is one. Run it before you publish; the Publishing policy from your own
CI guide shows where it belongs in a workflow.
The input schema that closes a proposal's parameters is not a member of the bundle. What
adapters.json registers is the identifier of a proposal's schema and the fidelity class of the
front door that produced it; the schema itself, which would say what values a parameter may take,
is not signed policy today. For an F-HIGH front door that constraint lives in the adapter, and
it is not something you can read out of the bundle.
No file here can tell you whether its own contents are correct. Four of them say so explicitly: a resource floored too low, an action classified reversible when it is not, a notice addressed to the wrong people, a front door registered at the wrong fidelity. Each defeats the design with no attack at all, and no verifier anywhere can see it. That is why these files are small, reviewed by two people, and signed with a key that never touches a running service. The mechanism protects the file; reviewing what is in it is yours.
Synced from ziffer docs/onboarding/policy-by-example.md at 32bc4db; edit the source, never this page.