ZIFFER home

Publishing policy from your own CI

Who this is for: the engineer who will own your ZIFFER policy repository.

Who this is for: the engineer who will own your ZIFFER policy repository. You should be comfortable with pull requests, GitHub Actions (or the equivalent in your CI), and storing a secret in your CI's secret store. Nothing else is assumed.

What you end up with: your rules live in a git repository you own. Changing a rule is a pull request your team reviews. When it merges, your CI signs the result with a key ZIFFER has never seen and pushes it to us. We check the signature and serve it, or refuse it and tell you which check said no.


1. The idea in one paragraph

ZIFFER decides whether an AI agent's action is allowed by reading a policy bundle: a small set of JSON files, signed as one unit. The signature is what makes the bundle trustworthy: not who uploaded it, not which network it came from, not what permissions your CI has in our cloud account. You hold the signing key. We hold only the matching public key. That means we cannot write a rule on your behalf, cannot quietly alter one, and cannot be tricked into serving a bundle you did not sign, and none of that depends on us configuring anything correctly. It is cryptography, not a permission setting.

The practical consequence: you never touch a ZIFFER console to change a rule, and you never hand us a credential that could change one.


2. What we need from you once, before any of this works

Two things, handed over at onboarding. Neither is a secret.

  1. Your policy signing PUBLIC key, as a JSON file:

    {
      "alg": "hybrid-ed25519-mldsa65",
      "classical": "<Ed25519 public key, standard base64>",
      "pq": "<ML-DSA-65 public key, standard base64>",
      "fingerprint": "<the fingerprint of the two halves together>",
      "note": "policy signing key, generated 2026-09-01"
    }

    You compute none of those values. ziffer pubkey (section 4) derives them from your key file, and classical, pq and fingerprint are copied out of what it prints:

    ziffer pubkey --key ~/.ziffer/policy-signing.key \
      | jq '{alg: "hybrid-ed25519-mldsa65",
             classical:   .identity.classical,
             pq:          .identity.pq,
             fingerprint: .fingerprint,
             note:        "policy signing key, generated 2026-09-01"}' \
      > policy-signing.enrolment.json

    alg and note are the two you state yourself. Neither is a property of a key: alg names a signature suite, and the signing tool deliberately invents no suite name to sit beside an identity.

    Both halves, always. The bundle is signed with two algorithms at once (a classical one and a post-quantum one) and verification requires both: one valid half and one invalid half is a refusal, not a pass.

    We refuse this file at startup if it is malformed, if the two halves do not match the stated fingerprint, if it mentions a private key, or if the Ed25519 half is one of the handful of degenerate curve points under which a single signature would verify for every message.

  2. A certificate signing request from your CI runner. Your CI connects to our publish endpoint over mutual TLS, and we admit exactly one name. We issue that certificate, from our own private certificate authority, with the name publish-ci.<your tenant>.ziffer.internal. The first label is the role and says what the holder is; the second is your tenant and says whose it is, and the publisher compares your bundle's manifest against it. You generate the key; we never see it. On the runner, once:

    openssl ecparam -name prime256v1 -genkey -noout -out publish-ci.key.pem
    openssl req -new -key publish-ci.key.pem -subj "/CN=publish-ci" -out publish-ci.csr

    Send us publish-ci.csr. It contains a public key and nothing else. We discard every name it carries, set ours, and send back publish-ci.crt.pem plus our root ca.crt.pem. Keep publish-ci.key.pem on the runner as a CI secret; it never leaves it, and there is nowhere on our side to put it.

    Why our name and not yours. Use the certificate we issue: a leaf from your own certificate authority fails the handshake before we read a byte. What makes that affordable is what the pin actually buys: only one named peer reaches this handler, and nothing whatever about your policy. What decides whether a bundle is accepted is your own signature over it (item 1), so a stolen CI certificate still cannot publish a policy you did not sign.

    What it does buy a thief, and for how long. Reaching /v1/publish, and nothing past the door, because what is accepted there is a bundle under your signature, and this certificate is not that key. The leaf is valid for 30 days: your publisher is a CI job, not an always-on process, and the leaf buys reach only, so it gets the longer of our two lifetimes. The always-on holders, an Executor's puller and an application's submitter, get 24 hours. You renew it yourself with ziffer-renew (section 4): run it in the publish job before publish once the leaf is past 15 days, and it replaces the pair with a fresh key. Revocation is refusing renewal. If a runner is compromised, tell us, we deny the name at the issuer, and the identity stops existing within one lifetime. Rotate the signing key at the same time, for the reason above: the certificate never could publish what you did not sign. An expired leaf cannot be renewed, because the issuer renews a still-valid certificate, so a workflow idle for 30 days holds an expired one and re-enrols: the signing-request exchange in this section, again.

We never ask for your private key, and there is nowhere to put one if you offered it.


3. What lives in your repository

Your bundle directory, the files the signature will cover. A typical one:

policy/
  manifest.json            who wrote this, who reviewed it, which tenant, which epoch, when it expires
  attesters/registry.json  the people who can approve, and their public keys
  floors.json              the tier floor of each resource
  risk_functions.json      how an action's risk is computed
  reversibility.json       which actions can be undone
  notice_targets.json      who is told when an irreversible action runs
  alert_targets.json       who is paged when something is refused
  door_identities.json     the services that show decisions to people and send the mail
  receipt_identity.json    the key that signs decisions for you
  adapters.json            the front doors and the fidelity each one stamps

The exact member set is whatever is in the directory: ziffer list prints it, and that printed list is exactly what the signature covers. There is no manifest of members inside the signature, on purpose: if there were, a tampered bundle could hide a file by deleting its own mention of it.

Three fields in manifest.json will decide whether a publish succeeds, so put them under review like any other change:

FieldRule
bundle_epochAn integer. Every publish must raise it. Publishing the same epoch twice is refused, and that is how a rollback to older, more permissive rules is prevented.
author.id / reviewer.idMust be different identifiers. The signing tool refuses before it signs. Two-person review of policy is a rule, not a convention.
expires_atAn RFC 3339 UTC timestamp. An expired bundle is refused. Pick a renewal cadence you can actually keep.

tenant_id must be the tenant name we gave you at onboarding. A bundle naming any other tenant is refused.

Three of those files come from us, and you sign them with the rest. receipt_identity.json (the key that signs your receipts: it lives in our KMS, so only we can name it), door_identities.json and attesters/registry.json are handed to you at onboarding, produced for your tenant and your first bundle_epoch when we set you up. Put them in policy/ beside your own files before the first sign.

door_identities.json and attesters/registry.json are one pair, and we produce them together for that reason. The first names the service that shows your approvers what they are authorising and signs what they were shown; the second says who your approvers are. If they disagree, an approver is shown a page, taps their key, is told the quorum is complete — and the action is refused, because the thing they approved was signed by a service your policy does not name. So ask us for both when either changes, and never edit one of them by hand. ZIFFER verifies that the bundle you signed carries exactly the members it emitted for you, and refuses by name if one was changed or left out (BundleReceiptIdentityMismatch, BundleProvisionedMemberMismatch). A bundle that named a different receipt key would be signed policy pointing at a signer that does not exist.


4. The tool

ziffer is the offline authoring and signing CLI. It runs on your machines and your CI runners, and it is deliberately not something that runs on our hosts, because the whole guarantee is that no running ZIFFER component holds a key that could sign your policy. We build it from the decision engine's own source, one bare binary per platform — no archive, nothing to unpack — beside a SHA256SUMS file and an Ed25519 signature over it, versioned <crate version>-<engine commit> so one string names one tree.

install.md section 1 is the install, in full, and it is where the commands live: the download URL pattern, the signature check over SHA256SUMS before the checksum is trusted, the checksum check before the binary is made executable, and the one macOS trap (the system openssl is LibreSSL and cannot load an Ed25519 key at all). In short:

# <version> and <target> from install.md section 1.1; the tag is ziffer-<version>
BASE="https://github.com/ziffer-hq/ziffer-spec/releases/download/ziffer-<version>"
curl -fsSL -O "$BASE/ziffer-<version>-<target>"
curl -fsSL -O "$BASE/SHA256SUMS"
curl -fsSL -O "$BASE/SHA256SUMS.sig"
curl -fsSL -o release-key.pub \
  https://raw.githubusercontent.com/ziffer-hq/ziffer-spec/main/release-key.pub
openssl pkeyutl -verify -pubin -inkey release-key.pub -rawin \
  -in SHA256SUMS -sigfile SHA256SUMS.sig
grep " ziffer-<version>-<target>$" SHA256SUMS | shasum -a 256 -c -
sudo install -m 0755 "ziffer-<version>-<target>" /usr/local/bin/ziffer

In CI, none of those three values is a line in the workflow. The download base, the version and the checksum are the repository variables ZIFFER_CLI_URL, ZIFFER_CLI_VERSION and ZIFFER_CLI_SHA256, set beside ZIFFER_PUBLISH_URL in Settings -> Secrets and variables -> Actions. None of them is a secret. A value the handover already carries, pasted into a handout, is the copy that does not move when the real one does — while reading, at your end, exactly like a current one.

The same release carries a second binary, ziffer-renew, the certificate renewal client. It is versioned <crate version>-<ZIFFER commit>, after our own tree rather than the engine's, so it has its own version string, given to you beside the first and covered by the same SHA256SUMS. It holds no key of any kind: given your current certificate and key and the issuer's address, it generates a fresh key, asks for a certificate over the still-valid leaf, proves the answer goes with the key it made, and writes the new pair to paths you name, never over the inputs, so a failed run leaves you exactly what you had. With no arguments it prints its usage and exits 2.

ziffer-renew --addr <issuer host:port> --name <issuer dNSName> --ca ziffer-ca.pem \
          --cert client.pem --key client.key --out-cert new.pem --out-key new.key

It renews only a leaf past its half-life and refuses an expired one by name, whichever of our two lifetimes the leaf has; it does not know which holder it is renewing. install.md section 6 is how to install it and the fortnightly calendar task that has to own it.

Section 5's renew job runs this automatically, on a schedule: it installs ziffer-renew itself, at its own version ${{ vars.ZIFFER_RENEW_VERSION }} and checked against ${{ vars.ZIFFER_RENEW_SHA256 }}, reads the issuer's address and name as ${{ vars.ZIFFER_ISSUER_ADDR }} and ${{ vars.ZIFFER_ISSUER_NAME }}, and writes the renewed pair back under ${{ secrets.ZIFFER_SECRETS_TOKEN }} rather than the default GITHUB_TOKEN, which cannot write a repository secret at all.

Nine subcommands, and no others — the ten lines below are nine commands, because decide has two mutually exclusive forms:

ziffer list    <bundle-dir> [--suite <name>]
ziffer sign    <bundle-dir> --key <file> [--suite <name>] [--tier T0|T1] [--env development|production]
ziffer verify  <bundle-dir> --pubkey <file> --now <RFC3339> [--floor <name>] [--grace <seconds>] [--high-water <n>]
ziffer limits  <bundle-dir> --pubkey <file> --now <RFC3339> [--floor <name>] [--grace <seconds>] [--high-water <n>]
ziffer decide  <bundle-dir> --pubkey <file> --now <RFC3339> --proposal <file>
ziffer decide  <bundle-dir> --unsigned --now <RFC3339> --proposal <file>
ziffer publish <bundle-dir> --pubkey <file> --now <RFC3339> --url <URL> --cert <pem> --key <pem> --cacert <pem>
ziffer epoch   --url <URL> --cert <pem> --key <pem> --cacert <pem> --now <RFC3339>
ziffer pubkey  --key <file> [--out <file>]
ziffer keygen  --out <file>

--now is required wherever it appears and has no default. The tool never reads the host clock, so an expiry check is reproducible rather than dependent on which runner picked up the job. In a script it is always --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)".

--pubkey is your own public key document, the file ziffer pubkey writes (below). Every command that checks a signature takes it — including publish, which verifies your bundle locally under your own key before it opens a connection.

  • keygen generates a signing key from the OS random source, writes it at mode 0600, refuses to overwrite an existing file, and never prints a secret. What it prints is the public document pubkey prints.

  • list walks the directory and prints every member plus the tree hash the signature will cover. It needs no key, so anyone on your team can always see what the next signature would sign. It refuses a symlink and refuses a file type it does not recognise rather than skipping it quietly: an unsigned file inside a signed bundle is exactly what the signature exists to deny.

  • sign checks author-vs-reviewer first, then writes SIGNATURE into the directory. It writes to a temporary file and moves it into place only once the signature exists, so a mistyped key path cannot destroy the previous one. Defaults: the hybrid suite, --env production.

  • verify checks the bundle the way our runtime will and names the refusal if there is one.

  • limits verifies exactly as verify does and then prints the deployment limits your bundle declares, with the document's defaults applied where it declares none.

  • decide runs the engine's own grading fold over one proposal, offline, against your bundle. On a pass it prints risk=… reversibility=… notice=… and exits 0; otherwise it prints REFUSED <clause> and exits non-zero. This is the command that catches a rule change before it is published, and section 5's validate workflow runs it over your committed examples. It has two forms and they are mutually exclusive: --pubkey grades a bundle whose signature it has checked, and --unsigned grades the tree without checking one — same grader, same clause names, same exit codes, and a malformed member is still refused. The unsigned form announces itself on its first line, every time: UNSIGNED: the signature was not checked; this is a lint of the tree, not a verdict on a bundle. It exists because a pull request edits members and the signature over them is made at publish time (section 5).

    A green --unsigned run is a statement about what your rules DECIDE, and about nothing else. What proves a bundle is the one you signed is verify, and the publish job runs it on the bytes it just signed.

  • publish verifies locally, encodes the CBOR body, sends it over mutual TLS and decodes the answer: outcome, tenant, bundle_hash, epoch and activation. A refusal exits non-zero with the refusal's name. An expired client certificate is refused as ClientCertExpired, with the ziffer-renew command in the message, instead of a TLS handshake error you cannot read.

  • epoch asks the publisher which epoch it holds for you and prints the integer alone, so the next bundle_epoch is computed rather than typed (section 5).

  • pubkey derives the public halves of a key file and prints them, in every encoding either side of this recipe needs. It writes no secret material, and The public key below covers it.

Checking an audit chain download is not one of the nine. The console's audit page offers Download the audit chain for this window, verifiable offline, and the tool that checks that file is the TypeScript SDK's verifyChain, not ziffer. It recomputes every record's hash, checks every anchor's signature under the audit anchor key you hold, and says which records no anchor covers yet. Integrating the SDK, section 6, Checking the audit chain you download, says what it checks and where that key comes from.

The key file

{ "ed25519_sk_hex": "<64 hex characters>", "mldsa65_sk_hex": "<hex>" }

ziffer keygen --out ~/.ziffer/policy-signing.key writes it (install.md section 2). Store the whole file as one CI secret and write it to disk inside the job.

There is deliberately no --seed flag. Seeds are test key material, and a signing tool that offered derived keys would be endorsing them for production. Generate real key material once, offline (install.md section 2), and keep it out of the repository.

A key file that is the right size and the wrong content is refused by name. sign and pubkey share one reader, so a corrupted post-quantum secret cannot be refused by one and quietly accepted by the other. The tool derives the public half and then uses it, making and verifying one signature, before it will sign with the key or hand you the public half, and a file that fails that is told its content is wrong rather than its length, so nobody goes and re-counts bytes that were already correct. Nothing is written when it refuses.

The public key

Your public key has to appear in more than one encoding: hex for ziffer verify --pubkey, base64 for the file you hand ZIFFER (section 2), and a fingerprint over both halves. All of them are a function of your key file, and none of them is yours to compute:

ziffer pubkey --key ~/.ziffer/policy-signing.key --out policy-signing.pub.json
{
  "ed25519_pk_hex": "…",
  "fingerprint": "sha256:…",
  "identity": {
    "classical": "…",
    "pq": "…"
  },
  "mldsa65_pk_hex": "…"
}

One document rather than a --format flag, on purpose: choosing an encoding is the step that goes wrong, and here the spellings arrive together and agree by construction. The top level of that document is exactly what verify --pubkey reads, so commit the file as printed and point --pubkey at it: the reader takes the two hex fields and ignores the rest. identity is the same two halves in the base64 encoding an attester registry entry uses, which is what section 2's enrolment file wants. Output is byte-stable, so two runs diff clean and a file that changed means a key that changed.

Without --out the same document goes to standard output. Either way the tool checks its own work before you see it: the emitted bytes are compared against the key file's own secret hex strings, and --out writes a temporary file and moves it into place only once the document exists.

Transcription is the thing this removes, and it is worth knowing why it mattered. A slip in a hand-copied public key does not announce itself as a slip. It surfaces later, at publish time, as a signature that does not verify, wearing the face of a compromise.


5. The workflow

Copy the publish-policy.yml we hand you at onboarding into .github/workflows/, and change nothing in it but your policy folder. It is the publish workflow below as a file, and there is nothing in it for you to edit except POLICY_DIR — which has to be spelled a second time in the paths: filter, because GitHub reads on: before any env: exists. Leave it at policy/ if that is where your bundle lives. Everything that moves is a repository variable (§4): ZIFFER_CLI_URL, ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256 and ZIFFER_PUBLISH_URL for the publish job, and ZIFFER_RENEW_VERSION, ZIFFER_RENEW_SHA256, ZIFFER_ISSUER_ADDR and ZIFFER_ISSUER_NAME for the renew job beside it. It refuses a binary that does not match its checksum before running it, reads the epoch Ziffer holds and writes the next one into your manifest before it signs, checks that the committed public key is your signing key's, signs, verifies what it signed, publishes over mutual TLS, prints whether the change is active yet, and on any refusal prints the refusal's name with what to do about it. What follows is the explanation of each step, and the pull-request validation workflow the file does not carry.

bundle_epoch is not a number anybody types. Two pull requests that both bumped it to N+1 by hand would have the second merge refused EPOCH_ROLLBACK for being equal. The publish job runs ziffer epoch, writes one more than what Ziffer holds, and signs after that — the manifest is signed with the epoch inside it. Leave bundle_epoch in your committed manifest as whatever it last was; the job overwrites it in the runner and never commits.

The workflows are separate files. On a pull request, validate: no key, no publish, so a proposed rule change is checked before anyone approves it. On merge to your default branch, sign and publish. They stay separate because a job that can publish and a job that only reads should not share an environment:. A third file, policy-scan.yml, also runs on pull requests; it is described after the validate job below. All three name your policy folder as POLICY_DIR, and all three must name the same one.

The validate job's lint is deliberately unsigned, and that is not a weaker check but a different one: a pull request edits members, and the signature over them is made at publish time, so there is no current signature on a branch to check. ziffer decide --unsigned grades the tree with the engine's own fold — same clause names, same exit codes, a malformed member still refused — and prints UNSIGNED: the signature was not checked; this is a lint of the tree, not a verdict on a bundle before anything else, so a green validate job can never be read as "this bundle verifies". The signature check lives where a signature exists: in the publish job, on the bytes it has just signed, before the POST.

A validate job that graded nothing does not go green. The examples are read from <POLICY_DIR>/examples/, so moving your policy folder moves them with it. A policy folder with no example proposal there FAILS the job as NoExampleProposals, naming the folder to add them to. If you genuinely have none, say so in the workflow: set NO_EXAMPLE_PROPOSALS: 'true' in its env: block (it is 'false' as shipped), and the job passes with a NOT CHECKED line instead.

The scan workflow grades the tools your AI agents can call against the pull request's policy. policy-scan.yml comes with the policy repository template; copy it into .github/workflows/ as it is. The one value in it you may edit is POLICY_DIR, set to the same folder as in the other two workflows. It runs on every pull request, with no paths: filter, and runs the scanner package, pinned to its major version, as ziffer-scan --ci "$POLICY_DIR". The scan reads the MCP servers the repository configures for AI agents (.mcp.json, .cursor/mcp.json, .vscode/mcp.json, .gemini/settings.json, .codex/config.toml, .continue/mcpServers/), starts each one only to ask for its tool list, and asks the ZIFFER engine the scan ships with what this pull request's policy decides for every tool. A tool with no risk function is refused, and the job fails with the line to add to risk_functions.json. It is unsigned, and says so on its first line: it checks what the rules decide, not that the bundle verifies. It asks for contents: read and nothing else, reaches no secret and no environment:, reads no home directory, sends nothing anywhere and needs no account. A server that needs a credential to start cannot list its tools there, and the job prints it as NOT CHECKED rather than guessing what it offers. Because the package is pinned to its major version, a release that changes what the output means reaches you as a change to that line, never silently.

Renewing the client certificate. The leaf lives 30 days (section 2). A second job in the same file, renew (section 4), runs on a fortnightly schedule and by hand, never on push, so it never shares a trigger with publish. It installs ziffer and ziffer-renew the way publish installs ziffer, runs ziffer-renew against the materialised pair, and writes the renewed pair back with gh secret set -- something the default GITHUB_TOKEN cannot do, at any permissions: setting, because GitHub grants that token no path to Secrets. The token that can is section 4's ZIFFER_SECRETS_TOKEN, scoped to Secrets: read and write, on this one repository, and nothing else, and the renewed key never reaches a shell argument or a log line -- it goes from the file ziffer-renew wrote straight into the secret store. If you would rather not grant that token, the by-hand recipe works: run ziffer-renew where you keep the secret, on an operator machine, and update the two secrets from its output (install.md section 6). A workflow that has not published for 30 days holds an expired leaf and re-enrols (section 2).

# .github/workflows/policy-validate.yml
#
# The keyless half, in its own file. It reads a proposed rule change and signs
# nothing, so no secret is reachable from it.
name: policy-validate

on:
  pull_request:
    paths: [ 'policy/**' ]

# Read the repository, and nothing else.
permissions:
  contents: read

env:
  POLICY_DIR: policy/
  # 'false' unless you have NO example proposals and say so here. With 'false',
  # a policy folder with nothing in <POLICY_DIR>/examples/ FAILS this job: a
  # green check that graded nothing reads exactly like one that graded
  # everything. 'true' lets it pass with a NOT CHECKED line instead.
  NO_EXAMPLE_PROPOSALS: 'false'

jobs:
  validate:
    # Runs on every PR. No key, so nothing here can publish.
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install the ziffer CLI at the pinned version
        run: |
          set -euo pipefail
          # The location, the version and the checksum all come from Ziffer at
          # onboarding and all three are repository VARIABLES. The checksum is
          # checked BEFORE the binary is made executable and before it is run: a
          # tool you did not verify is a supply chain you did not choose, and a
          # tool that has already run cannot be un-run by a check that follows.
          url="${ZIFFER_CLI_URL%/}/ziffer-${ZIFFER_CLI_VERSION}-linux-x86_64"
          curl --fail --location --silent --show-error \
            --retry 3 --retry-connrefused \
            -o "$RUNNER_TEMP/ziffer" "$url"
          printf '%s  %s\n' "$ZIFFER_CLI_SHA256" "$RUNNER_TEMP/ziffer" \
            > "$RUNNER_TEMP/ziffer.sha256"
          sha256sum -c "$RUNNER_TEMP/ziffer.sha256"
          mkdir -p "$RUNNER_TEMP/bin"
          install -m 0755 "$RUNNER_TEMP/ziffer" "$RUNNER_TEMP/bin/ziffer"
          printf '%s\n' "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
        env:
          ZIFFER_CLI_URL: ${{ vars.ZIFFER_CLI_URL }}
          ZIFFER_CLI_VERSION: ${{ vars.ZIFFER_CLI_VERSION }}
          ZIFFER_CLI_SHA256: ${{ vars.ZIFFER_CLI_SHA256 }}

      - name: What the signature would cover
        run: ziffer list "$POLICY_DIR"

      - name: Grade every example against this pull request's rules
        run: |
          set -euo pipefail
          now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

          # THE STEP THAT CATCHES A BROKEN RULE BEFORE IT IS PUBLISHED. Every
          # file in $POLICY_DIR/examples/ is a proposal your AI agent really
          # sends. The engine's own grading fold runs over each one, offline,
          # against this pull request's bundle -- so the review shows "this
          # proposal would now be REFUSED at 8.4-3" instead of the AI agent
          # finding out in production. There is no rollback: the epoch only rises, so a rule
          # change that refuses everything is fixed by publishing again.
          #
          # --unsigned, and it is not a shortcut. A pull request EDITS members,
          # and the signature over them is made at publish time in the job that
          # holds the key -- so there is no current signature here to check, and
          # a lint that waited for one would run on no pull request at all. It
          # says so in its own first line, every time, so nobody reads a green
          # validate job as "this bundle verifies". It refuses a malformed
          # member exactly as the signed form does, with the same clause names
          # and the same exit codes; the only thing it does not do is check a
          # signature. `--unsigned` and `--pubkey` are mutually exclusive.
          #
          # The examples are read from under POLICY_DIR, never from a folder
          # spelled here: a moved policy folder must move its examples with it,
          # or this step grades nothing and says so in green.
          case "$NO_EXAMPLE_PROPOSALS" in
            true|false) ;;
            *)
              echo "REFUSED: NO_EXAMPLE_PROPOSALS is '$NO_EXAMPLE_PROPOSALS'; it is 'true' or 'false'." >&2
              exit 1 ;;
          esac
          examples_dir="${POLICY_DIR%/}/examples"
          shopt -s nullglob
          examples=("$examples_dir"/*.json)
          if [ "${#examples[@]}" -eq 0 ]; then
            if [ "$NO_EXAMPLE_PROPOSALS" = true ]; then
              echo "NOT CHECKED: $examples_dir/ holds no proposals, and this workflow"
              echo "  says so (NO_EXAMPLE_PROPOSALS: 'true'). Nothing was graded: this"
              echo "  job checked the shape of your bundle and nothing about what it"
              echo "  decides."
              exit 0
            fi
            echo "REFUSED: NoExampleProposals -- $examples_dir/ holds no *.json proposal," >&2
            echo "  so this pull request's rules were graded against nothing." >&2
            echo "  Add one file per action you gate: a proposal your AI agent really" >&2
            echo "  sends, as JSON, in $examples_dir/. If you genuinely have none, set" >&2
            echo "  NO_EXAMPLE_PROPOSALS: 'true' in this workflow's env: block." >&2
            exit 1
          fi
          rc=0
          for example in "${examples[@]}"; do
            printf '%s: ' "$example"
            ziffer decide "$POLICY_DIR" \
              --unsigned \
              --now "$now" \
              --proposal "$example" || rc=1
          done
          exit "$rc"

And the publish half, which is the file we hand you:

# .github/workflows/publish-policy.yml
name: publish-policy

on:
  push:
    # Your default branch, and the directory your bundle lives in.
    #
    # THE PATH IS SPELLED TWICE, HERE AND AS POLICY_DIR, AND IT HAS TO BE.
    # GitHub reads `on:` to decide whether to start a run at all -- before any
    # `env:` exists -- so `${{ env.POLICY_DIR }}` here would resolve to nothing
    # and the filter would match nothing. Keep the two naming the same
    # directory. A `paths:` filter naming a directory the
    # steps do not sign is a workflow that runs on the wrong changes -- or, far
    # worse, one that never runs on the right ones and reports nothing at all.
    branches: [ main ]
    paths: [ 'policy/**' ]
  schedule:
    # Cron has no "every 14 days" primitive, only day-of-month arithmetic, so
    # this is day-of-month divisible by 14 (the 14th and the 28th) at 03:00
    # UTC -- not exactly a fortnight, and it does not need to be: ziffer-renew
    # refuses a leaf still short of its half-life and refuses one that is
    # already expired, each by name, so firing a day early is a no-op and
    # firing a day or two late still lands inside the 30-day leaf's window.
    - cron: '0 3 */14 * *'
  # The fortnightly task, run by hand: Actions tab -> this workflow -> Run
  # workflow. Same job, same `if:` below, so it still never touches `publish`.
  workflow_dispatch: {}

# Read the repository, and nothing else. Neither job writes a commit, opens a
# pull request, publishes a package or touches the GitHub API through this
# token -- `renew` writes to Secrets through ZIFFER_SECRETS_TOKEN instead,
# which is not something any `permissions:` entry can grant. A token scoped to
# what a job actually does is the difference between a compromised step
# leaking your policy and a compromised step rewriting your repository.
permissions:
  contents: read

env:
  # Your bundle directory, relative to the repository root. One place to change
  # for every step below (the `paths:` filter above is the exception: keep the
  # two equal).
  POLICY_DIR: policy/

jobs:
  publish:
    # Because `on:` also carries `schedule` and `workflow_dispatch` for the
    # `renew` job below, this guard is what keeps THIS job on push alone --
    # without it, every fortnightly tick would sign and publish your policy
    # again, unchanged, and spend an epoch for nothing.
    if: github.event_name == 'push'
    runs-on: ubuntu-latest

    # The signing key is readable only from a job running in this environment,
    # so the environment's protection rules -- a required reviewer is the one
    # worth having -- are a second lock on the key itself, independent of your
    # branch protection. Your pull request review is change control over the
    # RULES; this is change control over the KEY.
    #
    # Create it in Settings -> Environments and put all five secrets there (the
    # four this job reads plus ZIFFER_SECRETS_TOKEN, for the `renew` job below),
    # not in repository-wide secrets: a repository secret is readable from every
    # workflow in the repository, including one added by a pull request you have
    # not read yet.
    environment: ziffer-production

    steps:
      - uses: actions/checkout@v4

      - name: Install the ziffer CLI at the pinned version
        run: |
          set -euo pipefail
          # No secret is touched in this step; the discipline is uniform anyway,
          # because a step that traces today is a step that leaks the day
          # somebody adds a secret to it.
          set +x

          # Every one of the three is READ, never written here: the location,
          # the version and the checksum come from Ziffer at onboarding.
          missing=""
          [ -n "${ZIFFER_CLI_URL:-}" ]     || missing="$missing ZIFFER_CLI_URL"
          [ -n "${ZIFFER_CLI_VERSION:-}" ] || missing="$missing ZIFFER_CLI_VERSION"
          [ -n "${ZIFFER_CLI_SHA256:-}" ]  || missing="$missing ZIFFER_CLI_SHA256"
          if [ -n "$missing" ]; then
            echo "REFUSED: these repository variables are not set:$missing" >&2
            echo "  They are the ziffer CLI's download base, its version and that" >&2
            echo "  artifact's SHA-256. Ziffer hands you all three at onboarding --" >&2
            echo "  install.md section 5, and policy-ci.md section 4." >&2
            echo "  Set them in Settings -> Secrets and variables -> Actions ->" >&2
            echo "  Variables, beside ZIFFER_PUBLISH_URL. None of them is a secret." >&2
            echo "Nothing was downloaded and nothing was executed." >&2
            exit 1
          fi

          # The SHAPE of the checksum, checked here rather than trusted. A
          # variable holding a truncated paste, a leading "sha256:" or an upper
          # case digest fails at `sha256sum -c` with a message about a malformed
          # line, which reads like a corrupted download and is not one.
          case "$ZIFFER_CLI_SHA256" in
            *[!0-9a-f]*|"") bad=1 ;;
            *) [ "${#ZIFFER_CLI_SHA256}" -eq 64 ] && bad=0 || bad=1 ;;
          esac
          if [ "$bad" -ne 0 ]; then
            echo "REFUSED: ZIFFER_CLI_SHA256 is not 64 lowercase hexadecimal characters." >&2
            echo "  It is the linux-x86_64 line of the release's SHA256SUMS, the hash" >&2
            echo "  alone -- no filename, no 'sha256:' prefix, lower case." >&2
            echo "  A pin that is not a digest is a binary that runs unverified." >&2
            echo "Nothing was downloaded and nothing was executed." >&2
            exit 1
          fi

          # The release ships BARE BINARIES, one per platform -- no archive and
          # nothing to unpack. The runner is linux-x86_64; the build for it is
          # static, so it needs nothing from the image.
          url="${ZIFFER_CLI_URL%/}/ziffer-${ZIFFER_CLI_VERSION}-linux-x86_64"
          curl --fail --location --silent --show-error \
            --retry 3 --retry-connrefused \
            -o "$RUNNER_TEMP/ziffer" "$url"

          # The checksum is checked BEFORE the binary is made executable and
          # before it is run. A signing tool you did not verify is a supply
          # chain you did not choose, and a tool that has already run cannot be
          # un-run by a check that follows it.
          printf '%s  %s\n' "$ZIFFER_CLI_SHA256" "$RUNNER_TEMP/ziffer" \
            > "$RUNNER_TEMP/ziffer.sha256"
          if ! sha256sum -c "$RUNNER_TEMP/ziffer.sha256"; then
            actual="$(sha256sum "$RUNNER_TEMP/ziffer" | cut -d' ' -f1)"
            echo "REFUSED: the ziffer artifact does not match its pinned checksum." >&2
            echo "  version : ${ZIFFER_CLI_VERSION}" >&2
            echo "  url     : ${url}" >&2
            echo "  pinned  : ${ZIFFER_CLI_SHA256}" >&2
            echo "  actual  : ${actual}" >&2
            echo "Nothing was installed and nothing was executed. Either" >&2
            echo "ZIFFER_CLI_SHA256 is not the checksum Ziffer published for" >&2
            echo "ZIFFER_CLI_VERSION, or the artifact you fetched is not the one" >&2
            echo "Ziffer built." >&2
            exit 1
          fi

          # Into the runner's own temporary directory, not /usr/local/bin: this
          # job installs a tool for itself and has no business changing the
          # image. The PATH entry lasts exactly as long as the job.
          mkdir -p "$RUNNER_TEMP/bin"
          install -m 0755 "$RUNNER_TEMP/ziffer" "$RUNNER_TEMP/bin/ziffer"
          printf '%s\n' "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
          "$RUNNER_TEMP/bin/ziffer" --help > /dev/null
        env:
          ZIFFER_CLI_URL: ${{ vars.ZIFFER_CLI_URL }}
          ZIFFER_CLI_VERSION: ${{ vars.ZIFFER_CLI_VERSION }}
          ZIFFER_CLI_SHA256: ${{ vars.ZIFFER_CLI_SHA256 }}

      - name: Materialise the client certificate
        run: |
          set -euo pipefail
          set +x
          umask 077
          printf '%s' "$ZIFFER_CLIENT_CERT" > "$RUNNER_TEMP/client.pem"
          printf '%s' "$ZIFFER_CLIENT_KEY"  > "$RUNNER_TEMP/client.key"
          printf '%s' "$ZIFFER_CA"          > "$RUNNER_TEMP/ziffer-ca.pem"
          chmod 0600 "$RUNNER_TEMP/client.pem" "$RUNNER_TEMP/client.key" "$RUNNER_TEMP/ziffer-ca.pem"
        env:
          ZIFFER_CLIENT_CERT: ${{ secrets.ZIFFER_CLIENT_CERT }}
          ZIFFER_CLIENT_KEY: ${{ secrets.ZIFFER_CLIENT_KEY }}
          ZIFFER_CA: ${{ secrets.ZIFFER_CA }}

      - name: Set bundle_epoch from the epoch Ziffer holds
        run: |
          set -euo pipefail
          set +x
          # NOBODY TYPES THIS NUMBER, and that is the whole step.
          #
          # bundle_epoch must be strictly above the last epoch Ziffer accepted
          # from you (PB-5). Two pull requests that both bumped it to N+1 by
          # hand would have the second merge refused EPOCH_ROLLBACK for being
          # equal. `ziffer epoch` asks the publisher what it holds and
          # this step writes one more than that.
          #
          # It runs BEFORE signing on purpose: the epoch is inside the manifest
          # and the manifest is inside the signature. A number written after the
          # signature is a number the signature does not cover, and the publish
          # would be refused for a tree that does not hash to what was signed.
          #
          # The read is over the same mutual TLS the publish uses, so an expired
          # or wrong client certificate is found HERE, before a signature is
          # made, rather than at the last step of the job.
          now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          current="$(ziffer epoch \
            --url    "$ZIFFER_PUBLISH_URL" \
            --cert   "$RUNNER_TEMP/client.pem" \
            --key    "$RUNNER_TEMP/client.key" \
            --cacert "$RUNNER_TEMP/ziffer-ca.pem" \
            --now    "$now")"
          next=$(( current + 1 ))
          echo "Ziffer holds epoch ${current}; this publish will be ${next}."

          python3 - "$POLICY_DIR/manifest.json" "$next" <<'PY'
          import json, sys

          path, epoch = sys.argv[1], int(sys.argv[2])
          with open(path, encoding="utf-8") as f:
              manifest = json.load(f)
          before = manifest.get("bundle_epoch")
          manifest["bundle_epoch"] = epoch
          # Written through a temporary file and moved into place, so a failure
          # half way leaves the manifest you committed rather than half of one.
          tmp = path + ".tmp"
          with open(tmp, "w", encoding="utf-8") as f:
              json.dump(manifest, f, indent=2, sort_keys=True)
              f.write("\n")
          import os
          os.replace(tmp, path)
          print(f"bundle_epoch {before!r} -> {epoch}")
          PY
        env:
          ZIFFER_PUBLISH_URL: ${{ vars.ZIFFER_PUBLISH_URL }}

      - name: Materialise the signing key
        run: |
          set -euo pipefail
          set +x                    # never trace a line that holds key material
          umask 077                 # every file this step creates is 0600
          printf '%s' "$POLICY_SIGNING_KEY" > "$RUNNER_TEMP/policy-signing.key"
          chmod 0600 "$RUNNER_TEMP/policy-signing.key"
        env:
          POLICY_SIGNING_KEY: ${{ secrets.POLICY_SIGNING_KEY }}

      - name: The committed public key is this key's
        run: |
          set -euo pipefail
          set +x
          # This is the only job that holds both halves, so it is the only place
          # the pair can be checked. Derived and compared, never assumed: a
          # public key that drifted from the secret -- an old file after a
          # rotation, a key from another environment -- does not fail here, it
          # fails at Ziffer as a signature that does not verify. `pubkey`'s
          # document is byte-stable, so `diff` is the whole check.
          ziffer pubkey --key "$RUNNER_TEMP/policy-signing.key" \
            > "$RUNNER_TEMP/derived.pub.json"
          diff -u policy-signing.pub.json "$RUNNER_TEMP/derived.pub.json"

      - name: What the signature would cover
        run: |
          set -euo pipefail
          # Printed into the job log before anything is signed, so the log says
          # which files the signature covers. `list` needs no key.
          ziffer list "$POLICY_DIR"

      - name: Sign
        run: |
          set -euo pipefail
          set +x
          # `sign` checks author-vs-reviewer BEFORE it writes anything, and
          # writes SIGNATURE through a temporary file, so a mistyped key path
          # cannot destroy the previous one. Defaults: the hybrid suite,
          # --env production.
          ziffer sign "$POLICY_DIR" --key "$RUNNER_TEMP/policy-signing.key"
          # The key leaves the runner the moment it is no longer needed. Every
          # step after this one runs without it.
          rm -f "$RUNNER_TEMP/policy-signing.key"

      - name: Verify what was just signed
        run: |
          set -euo pipefail
          # The same check Ziffer will run, run here, where the answer is a
          # named refusal in your own job log rather than a 400 from us.
          # --now is required: the tool never reads the host clock, so an
          # expiry check is reproducible on any machine.
          ziffer verify "$POLICY_DIR" \
            --pubkey policy-signing.pub.json \
            --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)"

      - name: Publish to Ziffer
        run: |
          set -euo pipefail
          set +x
          # ONE COMMAND, and it does four things in this order: verifies your
          # bundle locally under your own public key (so a bundle that cannot
          # verify is refused by name BEFORE a connection is opened), encodes
          # the CBOR body, sends it over mutual TLS with the certificate Ziffer
          # enrolled, and decodes the answer.
          #
          # It prints, on success:
          #     outcome      published, or already_published when these exact
          #                  bytes were already stored and nothing was written
          #     tenant       your tenant id
          #     bundle_hash  the tree hash the signature covers
          #     epoch        the epoch Ziffer recorded
          #     activation   `active`, or `published-not-yet-active` with the
          #                  readers that have not reported yet
          #
          # READ `activation`. `published` means recorded and stored; `active`
          # means every reader in the deployment is serving this bundle. Between
          # the two there is a window -- policy-ci.md section 7 states it -- in
          # which your AI agents are graded under the previous epoch.
          #
          # On a refusal it exits non-zero with the refusal's NAME, which is the
          # stable thing to match on. `What the refusal means` below prints the
          # table.
          ziffer publish "$POLICY_DIR" \
            --pubkey policy-signing.pub.json \
            --now    "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            --url    "$ZIFFER_PUBLISH_URL" \
            --cert   "$RUNNER_TEMP/client.pem" \
            --key    "$RUNNER_TEMP/client.key" \
            --cacert "$RUNNER_TEMP/ziffer-ca.pem" \
            | tee "$RUNNER_TEMP/answer.txt"
        env:
          ZIFFER_PUBLISH_URL: ${{ vars.ZIFFER_PUBLISH_URL }}

      - name: What the refusal means
        if: failure()
        run: |
          set -euo pipefail
          set +x
          # Every name below is one Ziffer's publisher can actually answer with,
          # plus the two the
          # CLI itself answers with before it opens a connection. The name is
          # the stable thing to match on; the sentence beside it is for a human.
          answer="$(cat "$RUNNER_TEMP/answer.txt" 2>/dev/null || echo '(no answer was ever read)')"
          echo "Answer      : ${answer}"
          echo

          echo "  --- before a connection was opened: the CLI's own refusals ---"
          echo "  ClientCertExpired    your publish-ci leaf is past its 30 days. The message"
          echo "                       carries the ziffer-renew command. A renewal is presented"
          echo "                       over a STILL-VALID leaf, so an expired one cannot be"
          echo "                       renewed: send a new certificate signing request"
          echo "                       (install.md section 3). Set a fortnightly calendar task"
          echo "                       so this never happens twice (install.md section 6)."
          echo "  SignatureInvalid     the bundle does not verify under policy-signing.pub.json"
          echo "                       on this runner. Nothing was sent. Usually the committed"
          echo "                       public key is not this key's -- which the step three"
          echo "                       above would have caught -- or a member changed after"
          echo "                       the Sign step."
          echo
          echo "  --- 400, refused on the merits. The same bytes will be refused again. ---"
          echo "  EPOCH_ROLLBACK       bundle_epoch is not ABOVE the last epoch Ziffer accepted"
          echo "                       from you. Since the epoch is read from Ziffer and"
          echo "                       written by the workflow, this means two publishes raced:"
          echo "                       re-run this job and it will read the new number. If it"
          echo "                       repeats, an earlier publish recorded an epoch and then"
          echo "                       failed -- that epoch is spent, and the next read is"
          echo "                       already past it."
          echo "  BUNDLE_REFUSED       the bundle does not verify under the public key Ziffer"
          echo "                       holds for you; read the 'refusal' member for which check."
          echo "                         SignatureInvalid  -- the key in POLICY_SIGNING_KEY is"
          echo "                            not the one enrolled with Ziffer (or the bytes were"
          echo "                            altered after signing). Re-enrol the public halves"
          echo "                            from 'ziffer pubkey', or restore the right key."
          echo "                         Expired -- manifest.json's expires_at is in the past."
          echo "                         SuiteBelowFloor -- signed with fewer primitives than"
          echo "                            this deployment's floor requires."
          echo "                         AuthorIsReviewer, TenantAbsent, DoorAbsent,"
          echo "                         AlertAudienceMissing, QuorumInvalid,"
          echo "                         RegistryKeysNotDistinct, RegistryKeyWeak, Malformed --"
          echo "                            a rule about the bundle's own contents; 'ziffer"
          echo "                            verify' reproduces each one offline. 'Malformed' on"
          echo "                            an attester entry is almost always a missing"
          echo "                            \"kind\": \"hybrid\" or \"webauthn\"."
          echo "  TENANT_MISMATCH      manifest.json's tenant_id is not this deployment's tenant."
          echo "                       Set it to the tenant name Ziffer gave you at onboarding."
          echo "                       Checked BEFORE anything durable is written, so a bundle"
          echo "                       sent to the wrong deployment spends nobody's epoch."
          echo "  BODY_MALFORMED       the body is not a CBOR map of exactly members and"
          echo "                       signature, each once. 'ziffer publish' builds it, so this"
          echo "                       is a CLI version older than this deployment expects."
          echo "  MEMBERS_MALFORMED    members is not a map of member path to member BYTES."
          echo "  SIGNATURE_MALFORMED  signature is not the SIGNATURE file's bytes, or those"
          echo "                       bytes are not the {suite, parts} object the signature"
          echo "                       format declares."
          echo "  BUNDLE_NOT_A_TREE    the member paths are not a bundle tree: a path that could"
          echo "                       escape the root, a repeated path, or no members at all."
          echo "  STORE_TREE_MISMATCH  what arrived does not walk back as the tree it is"
          echo "                       addressed by -- typically a file type the bundle format"
          echo "                       does not recognise."
          echo "  HASH_COLLISION       a different bundle is already stored under that hash;"
          echo "                       nothing was overwritten. Contact Ziffer."
          echo "  SIGNATURE_DIFFERS    the bundle stored under that hash carries a different"
          echo "                       SIGNATURE; nothing was overwritten. Contact Ziffer."
          echo "  STORE_PATH_INVALID   the tenant or the bundle hash is not one ordinary path"
          echo "                       component inside Ziffer's store. Contact Ziffer."
          echo "  BUNDLE_INCOMPLETE    the verified bundle carries no epoch or no tenant."
          echo
          echo "  --- 503, Ziffer could not decide. A retry is reasonable. ---"
          echo "  LEDGER_UNAVAILABLE   the publish epoch mark could not be read or raised."
          echo "  STORE_UNAVAILABLE    the bundle store could not be read or written."
          echo "  CLOCK_UNREADABLE     Ziffer's own clock. Report it."
          echo
          echo "  A connection RESET with no answer at all, at either the epoch step or the"
          echo "  publish step, is one of two things:"
          echo "    * TLS HANDSHAKE FAILURE -- the client certificate's DNS SubjectAltName is"
          echo "      not the one name Ziffer pinned for this deployment, its chain is not"
          echo "      rooted in ZIFFER_CA, or no certificate was sent. Ziffer drops the"
          echo "      connection before reading a byte, so there is no denial to read."
          echo "    * the body is over the endpoint's size cap. The limit is applied to the"
          echo "      stream before a byte is collected, so an oversize request is reset"
          echo "      rather than refused and there is no name to match on. 'ziffer publish'"
          echo "      prints the encoded body size it was about to send; compare it with the"
          echo "      cap for your deployment (policy-ci.md section 7) and tell Ziffer if your"
          echo "      bundle is genuinely that large."
          echo
          echo "  Full table: policy-ci.md section 5, 'The answers, and the two"
          echo "  kinds of failure'."

  renew:
    # Runs on the schedule above and by hand, never on push: `publish` owns
    # that trigger. One job signs your policy; the other rewrites your own
    # secrets. They must never fire on the same event.
    if: github.event_name != 'push'
    runs-on: ubuntu-latest

    # Same reasoning as `publish`'s `environment:` below: this job reads the
    # current certificate pair AND the token that can rewrite it, so the
    # environment's protection rules are the second lock on both.
    environment: ziffer-production

    steps:
      - uses: actions/checkout@v4

      - name: Install the ziffer CLI at the pinned version
        run: |
          set -euo pipefail
          # No secret is touched in this step; the discipline is uniform anyway,
          # because a step that traces today is a step that leaks the day
          # somebody adds a secret to it.
          set +x

          # Every one of the three is READ, never written here -- the location,
          # version and checksum come from repository variables, the same way
          # the publish job above reads them, and for the same reason.
          missing=""
          [ -n "${ZIFFER_CLI_URL:-}" ]     || missing="$missing ZIFFER_CLI_URL"
          [ -n "${ZIFFER_CLI_VERSION:-}" ] || missing="$missing ZIFFER_CLI_VERSION"
          [ -n "${ZIFFER_CLI_SHA256:-}" ]  || missing="$missing ZIFFER_CLI_SHA256"
          if [ -n "$missing" ]; then
            echo "REFUSED: these repository variables are not set:$missing" >&2
            echo "  They are the ziffer CLI's download base, its version and that" >&2
            echo "  artifact's SHA-256. Ziffer hands you all three at onboarding --" >&2
            echo "  install.md section 5, and policy-ci.md section 4." >&2
            echo "  Set them in Settings -> Secrets and variables -> Actions ->" >&2
            echo "  Variables, beside ZIFFER_PUBLISH_URL. None of them is a secret." >&2
            echo "Nothing was downloaded and nothing was executed." >&2
            exit 1
          fi

          # The SHAPE of the checksum, checked here rather than trusted. A
          # variable holding a truncated paste, a leading "sha256:" or an upper
          # case digest fails at `sha256sum -c` with a message about a malformed
          # line, which reads like a corrupted download and is not one.
          case "$ZIFFER_CLI_SHA256" in
            *[!0-9a-f]*|"") bad=1 ;;
            *) [ "${#ZIFFER_CLI_SHA256}" -eq 64 ] && bad=0 || bad=1 ;;
          esac
          if [ "$bad" -ne 0 ]; then
            echo "REFUSED: ZIFFER_CLI_SHA256 is not 64 lowercase hexadecimal characters." >&2
            echo "  It is the linux-x86_64 line of the release's SHA256SUMS, the hash" >&2
            echo "  alone -- no filename, no 'sha256:' prefix, lower case." >&2
            echo "  A pin that is not a digest is a binary that runs unverified." >&2
            echo "Nothing was downloaded and nothing was executed." >&2
            exit 1
          fi

          # The release ships BARE BINARIES, one per platform -- no archive and
          # nothing to unpack. The runner is linux-x86_64; the build for it is
          # static, so it needs nothing from the image.
          url="${ZIFFER_CLI_URL%/}/ziffer-${ZIFFER_CLI_VERSION}-linux-x86_64"
          curl --fail --location --silent --show-error \
            --retry 3 --retry-connrefused \
            -o "$RUNNER_TEMP/ziffer" "$url"

          # The checksum is checked BEFORE the binary is made executable and
          # before it is run. A signing tool you did not verify is a supply
          # chain you did not choose, and a tool that has already run cannot be
          # un-run by a check that follows it.
          printf '%s  %s\n' "$ZIFFER_CLI_SHA256" "$RUNNER_TEMP/ziffer" \
            > "$RUNNER_TEMP/ziffer.sha256"
          if ! sha256sum -c "$RUNNER_TEMP/ziffer.sha256"; then
            actual="$(sha256sum "$RUNNER_TEMP/ziffer" | cut -d' ' -f1)"
            echo "REFUSED: the ziffer artifact does not match its pinned checksum." >&2
            echo "  version : ${ZIFFER_CLI_VERSION}" >&2
            echo "  url     : ${url}" >&2
            echo "  pinned  : ${ZIFFER_CLI_SHA256}" >&2
            echo "  actual  : ${actual}" >&2
            echo "Nothing was installed and nothing was executed. Either" >&2
            echo "ZIFFER_CLI_SHA256 is not the checksum Ziffer published for" >&2
            echo "ZIFFER_CLI_VERSION, or the artifact you fetched is not the one" >&2
            echo "Ziffer built." >&2
            exit 1
          fi

          # Into the runner's own temporary directory, not /usr/local/bin: this
          # job installs a tool for itself and has no business changing the
          # image. The PATH entry lasts exactly as long as the job.
          mkdir -p "$RUNNER_TEMP/bin"
          install -m 0755 "$RUNNER_TEMP/ziffer" "$RUNNER_TEMP/bin/ziffer"
          printf '%s\n' "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
          "$RUNNER_TEMP/bin/ziffer" --help > /dev/null
        env:
          ZIFFER_CLI_URL: ${{ vars.ZIFFER_CLI_URL }}
          ZIFFER_CLI_VERSION: ${{ vars.ZIFFER_CLI_VERSION }}
          ZIFFER_CLI_SHA256: ${{ vars.ZIFFER_CLI_SHA256 }}

      - name: Install ziffer-renew, from the same release, at its own version
        run: |
          set -euo pipefail
          set +x

          # ziffer-renew ships beside ziffer in the same release, under the
          # same tag, so it needs no download location of its own. It DOES
          # need its own version: it is built from a different tree than the
          # ziffer CLI, and its file is named after that tree --
          # ziffer-renew-<ZIFFER_RENEW_VERSION>-linux-x86_64, never
          # ziffer-renew-<ZIFFER_CLI_VERSION>-..., which the release does not
          # hold. Its checksum is its own too, ZIFFER_RENEW_SHA256.
          missing=""
          [ -n "${ZIFFER_CLI_URL:-}" ]       || missing="$missing ZIFFER_CLI_URL"
          [ -n "${ZIFFER_RENEW_VERSION:-}" ] || missing="$missing ZIFFER_RENEW_VERSION"
          [ -n "${ZIFFER_RENEW_SHA256:-}" ]  || missing="$missing ZIFFER_RENEW_SHA256"
          if [ -n "$missing" ]; then
            echo "REFUSED: these repository variables are not set:$missing" >&2
            echo "  ZIFFER_RENEW_VERSION is the version in ziffer-renew's own file name" >&2
            echo "  in the release's SHA256SUMS, and ZIFFER_RENEW_SHA256 is that row's" >&2
            echo "  hash. install.md section 5 has the full list." >&2
            echo "Nothing was downloaded and nothing was executed." >&2
            exit 1
          fi

          # The SHAPE of the version: a crate version, a hyphen, and the
          # 7-character commit it was built from. A value that is not that
          # shape is refused here, before a download that could only fail as
          # a missing file. A value of the right shape that names the wrong
          # tree (ZIFFER_CLI_VERSION pasted in, say) is NOT caught here: it is
          # a file the release does not hold, and curl --fail stops on it.
          if ! [[ "$ZIFFER_RENEW_VERSION" =~ ^[0-9A-Za-z.+-]+-[0-9a-f]{7}$ ]]; then
            echo "REFUSED: ZIFFER_RENEW_VERSION is not <crate version>-<7-character commit>." >&2
            echo "  It is the part of ziffer-renew's file name between 'ziffer-renew-' and" >&2
            echo "  '-linux-x86_64', as SHA256SUMS spells it -- no 'v' prefix, no file name." >&2
            echo "Nothing was downloaded and nothing was executed." >&2
            exit 1
          fi

          case "$ZIFFER_RENEW_SHA256" in
            *[!0-9a-f]*|"") bad=1 ;;
            *) [ "${#ZIFFER_RENEW_SHA256}" -eq 64 ] && bad=0 || bad=1 ;;
          esac
          if [ "$bad" -ne 0 ]; then
            echo "REFUSED: ZIFFER_RENEW_SHA256 is not 64 lowercase hexadecimal characters." >&2
            echo "  No filename, no 'sha256:' prefix, lower case, the hash alone." >&2
            echo "Nothing was downloaded and nothing was executed." >&2
            exit 1
          fi

          url="${ZIFFER_CLI_URL%/}/ziffer-renew-${ZIFFER_RENEW_VERSION}-linux-x86_64"
          curl --fail --location --silent --show-error \
            --retry 3 --retry-connrefused \
            -o "$RUNNER_TEMP/ziffer-renew" "$url"

          printf '%s  %s\n' "$ZIFFER_RENEW_SHA256" "$RUNNER_TEMP/ziffer-renew" \
            > "$RUNNER_TEMP/ziffer-renew.sha256"
          if ! sha256sum -c "$RUNNER_TEMP/ziffer-renew.sha256"; then
            actual="$(sha256sum "$RUNNER_TEMP/ziffer-renew" | cut -d' ' -f1)"
            echo "REFUSED: the ziffer-renew artifact does not match its pinned checksum." >&2
            echo "  version : ${ZIFFER_RENEW_VERSION}" >&2
            echo "  url     : ${url}" >&2
            echo "  pinned  : ${ZIFFER_RENEW_SHA256}" >&2
            echo "  actual  : ${actual}" >&2
            echo "Nothing was installed and nothing was executed." >&2
            exit 1
          fi

          mkdir -p "$RUNNER_TEMP/bin"
          install -m 0755 "$RUNNER_TEMP/ziffer-renew" "$RUNNER_TEMP/bin/ziffer-renew"
          printf '%s\n' "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
        env:
          ZIFFER_CLI_URL: ${{ vars.ZIFFER_CLI_URL }}
          ZIFFER_RENEW_VERSION: ${{ vars.ZIFFER_RENEW_VERSION }}
          ZIFFER_RENEW_SHA256: ${{ vars.ZIFFER_RENEW_SHA256 }}

      - name: Materialise the current client certificate
        run: |
          set -euo pipefail
          set +x
          umask 077
          printf '%s' "$ZIFFER_CLIENT_CERT" > "$RUNNER_TEMP/client.pem"
          printf '%s' "$ZIFFER_CLIENT_KEY"  > "$RUNNER_TEMP/client.key"
          printf '%s' "$ZIFFER_CA"          > "$RUNNER_TEMP/ziffer-ca.pem"
          chmod 0600 "$RUNNER_TEMP/client.pem" "$RUNNER_TEMP/client.key" "$RUNNER_TEMP/ziffer-ca.pem"
        env:
          ZIFFER_CLIENT_CERT: ${{ secrets.ZIFFER_CLIENT_CERT }}
          ZIFFER_CLIENT_KEY: ${{ secrets.ZIFFER_CLIENT_KEY }}
          ZIFFER_CA: ${{ secrets.ZIFFER_CA }}

      - name: Renew the publish-ci leaf
        run: |
          set -euo pipefail
          set +x
          umask 077
          # ziffer-renew holds no key of its own: given the still-valid pair
          # above and the issuer's address, it generates a fresh key ITSELF,
          # asks the issuer for a certificate over it, proves the answer goes
          # with the key it made, and writes the new pair to the two --out
          # paths -- never over the inputs, so a failed run leaves this job
          # exactly what it started with and the write-back step below simply
          # does not run.
          #
          # It renews only a leaf past its half-life and refuses an expired one
          # by name -- ClientCertExpired's twin at this tool -- so firing on
          # this job's schedule a day early or late is safe and cheap.
          ziffer-renew \
            --addr     "$ZIFFER_ISSUER_ADDR" \
            --name     "$ZIFFER_ISSUER_NAME" \
            --ca       "$RUNNER_TEMP/ziffer-ca.pem" \
            --cert     "$RUNNER_TEMP/client.pem" \
            --key      "$RUNNER_TEMP/client.key" \
            --out-cert "$RUNNER_TEMP/renewed.pem" \
            --out-key  "$RUNNER_TEMP/renewed.key"
        env:
          ZIFFER_ISSUER_ADDR: ${{ vars.ZIFFER_ISSUER_ADDR }}
          ZIFFER_ISSUER_NAME: ${{ vars.ZIFFER_ISSUER_NAME }}

      - name: Write the renewed pair back to the repository secrets
        run: |
          set -euo pipefail
          set +x
          # The default GITHUB_TOKEN cannot write a repository secret -- no
          # `permissions:` entry grants that, to any job, on any repository.
          # ZIFFER_SECRETS_TOKEN is a fine-grained personal access token scoped
          # to "Secrets: read and write" on THIS repository and nothing else:
          # it cannot read code, open a pull request, or reach any other
          # repository. `gh secret set` reads the new value from standard
          # input, so it is never a command-line argument and never anywhere
          # `set -x` could print it (which is off for this whole job in any
          # case) -- it goes from the file ziffer-renew wrote straight into
          # GitHub's own secret store, and nowhere else. It is never echoed.
          gh secret set ZIFFER_CLIENT_CERT < "$RUNNER_TEMP/renewed.pem"
          gh secret set ZIFFER_CLIENT_KEY  < "$RUNNER_TEMP/renewed.key"
          echo "ZIFFER_CLIENT_CERT and ZIFFER_CLIENT_KEY updated from this run's renewal."
        env:
          GH_TOKEN: ${{ secrets.ZIFFER_SECRETS_TOKEN }}

      - name: What a refusal here means
        if: failure()
        run: |
          set -euo pipefail
          set +x
          echo "  --- the CLI's own refusal, before anything ran ---"
          echo "  ClientCertExpired    your publish-ci leaf is past its 30 days. A renewal is"
          echo "                       presented over a STILL-VALID leaf, so an expired one"
          echo "                       cannot be renewed: send a new certificate signing"
          echo "                       request (install.md section 3). This job cannot do that"
          echo "                       part for you -- it is the same limit the publish job's"
          echo "                       own ClientCertExpired message names, at the same tool."
          echo
          echo "  --- the write-back step, if ziffer-renew succeeded but gh did not ---"
          echo "  HTTP 403 / 'Resource not accessible'   ZIFFER_SECRETS_TOKEN is missing,"
          echo "                       expired, or was never granted 'Secrets: read and"
          echo "                       write' on this repository. The renewed pair was"
          echo "                       generated and then discarded with the runner --"
          echo "                       nothing was written back, and the certificate you"
          echo "                       have today is unchanged. Re-run this job once the"
          echo "                       token is fixed; ziffer-renew will renew again from"
          echo "                       whatever pair is still valid."
          echo "  gh: command not found   the runner image changed. Report it; this workflow"
          echo "                       assumes the GitHub-hosted ubuntu-latest image, which"
          echo "                       carries gh by default."
          echo
          echo "  Full context: policy-ci.md section 4, install.md section 6."

ziffer publish builds the body. The encoding rules live in the tool, where they can be wrong in one place instead of in every customer's copy. Both fail closed and neither is guessable, so they are stated here for the reader who wants to know what the tool does:

  • SIGNATURE is not a member. A signature cannot cover itself, so the walk that computes the tree hash excludes the SIGNATURE file at the bundle root. Send it inside members and you have described a different tree from the one you signed: the signature will not verify against it, and the publish is refused.
  • Members carry bytes, not text. Each member's value is a CBOR byte string, because the tree hash covers bytes. A member re-encoded as CBOR text is a member whose encoding the transport chose, and it is refused for that reason rather than decoded helpfully.

Content-Type: application/cbor is honest labelling for anything between you and us; our endpoint does not consult it. The decode is the evidence, and a header is a claim by the sender.

A body over the endpoint's size cap gets no answer at all. The limit is applied to the stream before a byte is collected, so an oversize request is reset rather than refused: there is no denial to read, because nothing on our side ever read the request. ziffer publish prints the encoded body size it was about to send, so your job log carries the one number to compare against. The cap is per deployment and it has no default — a whole policy bundle is the largest body in this system and its size is a property of your policy rather than of our substrate, so a number invented by a template would be refusing your bundles for a reason nobody chose. Section 7 is where the cap for your deployment belongs; ask us for it if it is not on your handover sheet.

The answers, and the two kinds of failure

A refusal is a CBOR map: denial, the stable name to match on; reason, a sentence for the human reading the job log; and, on BUNDLE_REFUSED alone, refusal, the engine's own name for which check said no. The status code splits refusals into two kinds your CI must not treat alike:

StatusWhat it meansWhat to do
400Refused on the merits. Retrying the same bytes changes nothing.Fail the job and fix the bundle.
503ZIFFER could not decide: a store or a ledger did not answer.A retry is reasonable. Read section 6 on the spent epoch first.

A 400 with no body at all is a different failure and worth recognising on sight: the body was not CBOR, so the transport refused it before any check that could have a name. That is your packaging step, and nothing about your bundle.

The names you are most likely to meet:

denialWhat happened
BODY_MALFORMEDThe body is not a CBOR map of exactly members and signature, each once. Your packaging step, not your bundle.
MEMBERS_MALFORMEDmembers is not a map of member path to member bytes.
SIGNATURE_MALFORMEDsignature is not the SIGNATURE file's bytes, or those bytes are not the {suite, parts} object the signature format declares.
BUNDLE_NOT_A_TREEThe member paths are not a bundle tree: a path that could escape the root, a repeated path, or no members at all.
BUNDLE_REFUSEDThe bundle does not verify under the public key you enrolled. refusal names which check said no: an expiry, a suite below the floor, a signature leg that did not verify.
TENANT_MISMATCHmanifest.json governs a tenant other than the one this endpoint serves. Checked before anything durable is written, so a bundle sent to the wrong deployment cannot spend that deployment's epoch.
EPOCH_ROLLBACKbundle_epoch is not above the last epoch accepted from you. Equal is refused; lower is refused.
STORE_TREE_MISMATCHWhat arrived does not walk back as the tree it is addressed by: typically a SIGNATURE sent as a member, or a file type the bundle format does not recognise.

A 200 has two spellings and they are different facts. outcome: "published" means a directory was written, named by bundle_hash (sha256: and 64 lowercase hex characters). outcome: "already_published" means the identical bytes were already stored and nothing was written, reachable only when ZIFFER's epoch mark is behind what ZIFFER's store already holds, which publishing again repairs. A CI that cannot tell the two apart reports a no-op as a deployment.

Five secrets and eight variables: POLICY_SIGNING_KEY, ZIFFER_CLIENT_CERT, ZIFFER_CLIENT_KEY, ZIFFER_CA, ZIFFER_SECRETS_TOKEN, ZIFFER_PUBLISH_URL, ZIFFER_CLI_URL, ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256, ZIFFER_ISSUER_ADDR, ZIFFER_ISSUER_NAME, ZIFFER_RENEW_VERSION and ZIFFER_RENEW_SHA256. The signing key is the one that matters most if it leaks; ZIFFER_SECRETS_TOKEN is scoped to Secrets alone on this one repository, and the two are the only ones ZIFFER cannot rotate for you. The eight variables are not credentials: where you send a bundle; where the tool that signs it comes from, which version it is, and that version's checksum; and, for the renew job, our certificate issuer's address and name, and the renewal client's own version and checksum -- its own, because it is built from a different tree than ziffer and its file in the release is named after that tree. All eight are ZIFFER's to hand over and yours to record -- install.md section 5 is the list with the value beside each.

The environment: line is doing real work. A GitHub environment with a required reviewer means the signing key is only reachable from a job a second person released. Your PR review is change control; this is the second lock on the key itself.


6. What ZIFFER does with what you send

In this order, and the order is deliberate:

  1. Verifies the signature over the bundle's contents against your enrolled public key, in memory, before anything is written anywhere. Both algorithms must verify.
  2. Checks the bundle names your tenant. A bundle whose manifest.json names a different tenant is refused here, before any durable state changes, so a request naming someone else cannot affect them.
  3. Checks the epoch is strictly higher than the last one we accepted from you, and records the new one. Equal is refused; lower is refused. The record only ever moves up, and there is no operation that lowers it.
  4. Stores the bundle, addressed by its own content hash, written whole or not at all.
  5. Answers.

Refusals are named, not generic: you will be told whether the signature failed, the tenant did not match, or the epoch did not rise.

If a publish fails after step 3, that epoch is spent. Re-run: the publish job reads the epoch we hold and writes the next one (section 5). This is on purpose: the rollback guard is committed before anything acts on the bundle, and the alternative, storing first, would leave a window where a bundle exists that the guard does not cover.

What ZIFFER cannot do

  • Sign a policy bundle for you. No running ZIFFER component holds a key that can. If our entire control plane were compromised tomorrow, the attacker still could not author a rule your Executor would honour.
  • Modify a bundle you published. The store is addressed by content hash: an altered bundle is a different address, and its signature would not verify at the address it claims.
  • Roll you back to an older bundle. The epoch record only rises.
  • Recover your signing key. We hold no half of it and cannot reconstruct it. Losing it means generating a new one and enrolling its public half with us, which is the procedure below. Keep an offline backup.

If your signing key is lost, replaced or exposed

Nothing breaks at the moment it happens. The bundle serving you is signed, verified and still in force, and every bundle you have already published stays valid. A lost signing key is a key that cannot sign the NEXT bundle; it is not a key anything depends on being available.

What you cannot do until this is finished is publish a policy change. We verify every publish under the public key you enrolled, so a bundle signed with a new key is refused BUNDLE_REFUSED / SignatureInvalid until we have enrolled that key. Your CI certificate and your API key are unaffected; it is the signature that will not verify.

If it was stolen rather than lost, say so first and do this before anything else. Someone holding your signing key and a copy of your CI certificate can publish policy in your name. The steps are the same. The speed is not.

Four steps. The first and the third are yours:

  1. Generate a new key and its public document, exactly as you did at the start (section 4):

    ziffer keygen --out ~/.ziffer/policy-signing.key
    ziffer pubkey --key ~/.ziffer/policy-signing.key --out policy-signing.pub.json

    Do not overwrite the old key file if you still have it. Nothing needs it again, and a key you can still produce is a key you can still prove things about.

  2. Send us the new enrolment document on the channel you were onboarded on, in the shape section 2 gives, and reply on the same mail or ticket you opened. We record that reference against the rotation: it is what says afterwards that a person asked for it.

  3. Re-sign your CURRENT policy with the new key, at a bundle_epoch above the one in force, and send us the signed directory. Your policy does not change; only the signature over it does:

    # raise bundle_epoch in policy/manifest.json first
    ziffer sign policy/ --key ~/.ziffer/policy-signing.key

    We cannot enrol the new key without it, and that is a property of the system rather than a formality: every component verifies the bundle it holds under the key it holds, so a key enrolled without a bundle that verifies under it would leave your policy unreadable to the next component that restarts.

  4. We confirm, and then you publish. Replace the signing key in your gated CI environment, commit the new public document, and run the workflow as usual. The first publish after a rotation is the proof: published means the new key is the one we verify you under.

What a rotation does not change. Your epoch does not reset, and there is no way to reset one: it only rises, for ever. Your API key, your CI certificate, your approver registry and your recipients are untouched. Everything you published under the old key stays valid, and the old public key is kept rather than deleted, so what it signed can still be checked. We never see either private half of either key.


7. Three limits worth knowing before you plan around them

Your change is live when the workflow prints active, and not when it prints published. ziffer publish prints both: outcome says what happened to your bundle at our door — published means verified under the key we hold for you, recorded at its epoch and stored — and activation says whether every process that reads policy is serving it yet.

activationWhat it meansWhat to do
activeevery reader in your deployment reported this bundle's tree hash. The change is in forcenothing
published-not-yet-activethe bundle is stored and recorded; the readers named beside it had not picked it up before the publisher stopped waitingwait out the window below and re-read; if it persists, tell us and quote the tree hash

The window, stated so you can plan around it. Each reader polls for a new epoch on an interval — 15 seconds by default — so a published bundle is picked up within two intervals, 30 seconds at the default, and the publisher waits up to 30 seconds for every reader to report before it answers. During that window different processes may be one epoch apart: an Executor still holding N while the engine grades under N+1 refuses that receipt at clause 9.3-4, which is the control working rather than failing. sdk.md section 5 has the row and the one-line rule — retry once after the window; do not treat it as a policy refusal.

A published bundle activates itself, per tenant, with no operator command and no service restart. The first placement of a new tenant is ours, at your enrolment, and happens once.

One signing key, one tenant, one CI. Your CI leaf is publish-ci.<your tenant>.ziffer.internal and the publisher takes the tenant from that name, so one leaf publishes for exactly one of your tenants and a bundle whose manifest names another is refused TENANT_MISMATCH. Multiple environments (staging and production, say) means multiple tenants, each with its own enrolment, its own leaf and its own signing key — install.md section 8 is what that means for your CI, concretely. They can be enrolled into the same installation, which is our side of the arrangement and changes nothing you do.

There is a cap on the size of one publish, it is per deployment, and it has no default. A whole policy bundle is the largest body in this system and its size is a property of your policy rather than of our substrate, so no number is invented for you. The limit is applied to the stream before a byte is collected, which means an oversize request is reset rather than refused: there is no denial to read, because nothing on our side read the request. What you get is ziffer publish printing the encoded body size it was about to send.

The cap for your deployment: _______ bytes. We fill this in at onboarding. If it is blank on your copy, ask — a bundle that grows past a number nobody told you is a publish that fails with no answer.


8. Checklist

  • ziffer installed with its signature and checksum checked first (install.md section 1).
  • Key material generated offline with ziffer keygen; private halves never in the repository.
  • Public key derived with ziffer pubkey, committed as printed, and its identity halves plus fingerprint carried into the enrolment file handed to ZIFFER.
  • Signing request sent to ZIFFER from your CI runner, and the certificate it returned stored beside its key as CI secrets.
  • POLICY_SIGNING_KEY stored as a CI secret, in a gated environment.
  • Eight repository variables set: ZIFFER_CLI_URL, ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256, ZIFFER_PUBLISH_URL, ZIFFER_RENEW_VERSION, ZIFFER_RENEW_SHA256, ZIFFER_ISSUER_ADDR, ZIFFER_ISSUER_NAME -- and ZIFFER_SECRETS_TOKEN as a fifth secret, for the renew job (install.md section 5 is the list with the value beside each).
  • <POLICY_DIR>/examples/ holds one proposal per action you gate, so the validate workflow's ziffer decide has something to grade. With none, the validate job FAILS (NoExampleProposals) unless you set NO_EXAMPLE_PROPOSALS: 'true' in its env:.
  • author.id and reviewer.id populated from the merge, and never equal.
  • A calendar reminder before expires_at, and a fortnightly one to confirm the scheduled renew job passed (install.md section 6; the by-hand ziffer-renew recipe there is the fallback).
  • bundle_epoch left in your manifest as it is: the publish job reads the epoch from ziffer epoch and writes the next one before signing, and nobody types it.

On this page