ZIFFER home

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.json who the bundle is for, who wrote it, which epoch, when it expires
  • floors.json the tier floor of each governable resource
  • risk_functions.json how an action class is graded
  • reversibility.json which action classes can be undone
  • notice_targets.json who is told when an irreversible action runs below floor-HIGH
  • alert_targets.json who is woken for each class of critical alert
  • adapters.json the fidelity class of each registered front door
  • limits.json three deployment numbers, all optional
  • receipt_identity.json the public half of the key that signs your receipts
  • door_identities.json the approval and notification services' verification keys
  • attesters/registry.json who may approve or confirm, and the quorum
  • SIGNATURE the 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.

KindMembersWhat happens
A fail-safe valuefloors.json, reversibility.json, limits.json, the assurance mapThe lookup returns the safe answer: the highest tier, irreversible, the stated default, the lowest assurance claim.
A refusalrisk_functions.json, notice_targets.json, alert_targets.json, adapters.json, SIGNATUREThere 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 statethe registry, door_identities.json, alert_targets.json, manifest.json, risk_functions.jsonComparisons 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.

manifest.json
{
  "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.

floors.json
{
  "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.

risk_functions.json
{
  "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:

namewhat it is
each key of the proposal's paramsbound under its own bare name -- a parameter called currency is written currency, with no prefix
each key of the proposal's cidrsa network, usable only through .prefixlen
<resource>.effective_tierthe tier of one named target, written with that target's own resource name
resource.effective_tierthe maximum tier over every target of this proposal. With no targets it is T3, because absent is never the lowest tier
fidelitythe 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.

reversibility.json
{
  "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.

notice_targets.json
{
  "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.

alert_targets.json
{
  "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.

adapters.json
{
  "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.

limits.json
{
  "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.

FieldRangeWhy the bound is there
attestation_window_seconds1 to 36003600 is the stated ceiling on how long an attestation stays valid.
hold_window_seconds30 to 119At 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_percent0 to 100A 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.

receipt_identity.json
{
  "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.

door_identities.json
{
  "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.

attesters.json
{
  "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.

signature.json
{
  "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.

On this page