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 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 (since 2026-09-06; it was publish-ci.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, said plainly. No parameter in our deployment takes a customer certificate authority, so a leaf you issued would fail the handshake before we read a byte. That is a real limit and not a preference. What makes it 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. There is no revocation list and no OCSP responder on that endpoint; 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 that has not run 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 minimum assurance per 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 approval and notification services' identities
  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. 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.

docs/onboarding/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. Until ACP-330 the file we hand you carried a literal URL into a repository you have no account on, which contradicted this paragraph; the version and the checksum followed it out for the same reason, because 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 now runs this automatically, on a schedule: it installs ziffer-renew itself, 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. It exists because that key file was documented here and produced by nothing.

  • 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.

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/ unchanged. 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. 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_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. We check that file against this guide on every build, so the two cannot drift apart unnoticed. What follows is the explanation of each step, and the pull-request validation workflow the file does not carry.

bundle_epoch is no longer a number anybody types. It used to be a field you bumped in manifest.json in the same pull request as the rule change, and the failure was routine: two pull requests both bump to N+1, the second merge is refused EPOCH_ROLLBACK for being equal, and nothing said so until the merge. The publish job now runs ziffer epoch, writes one more than what Ziffer holds, and signs after that — the manifest is still signed with the epoch inside it; only who writes the number changed. Leave bundle_epoch in your committed manifest as whatever it last was; the job overwrites it in the runner and never commits.

Two workflows, in two 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:.

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.

Renewing the client certificate, no longer a manual step. 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 still 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/

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/examples/ is a proposal your 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 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.
          shopt -s nullglob
          examples=(policy/examples/*.json)
          if [ "${#examples[@]}" -eq 0 ]; then
            echo "NOT CHECKED: policy/examples/ holds no proposals, so nothing was"
            echo "  graded. One file per action you gate is the point of the"
            echo "  directory; a validate job with no examples checks the shape of"
            echo "  your bundle and nothing about what it decides."
            exit 0
          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
#
# This listing is docs/onboarding/publish-policy.yml verbatim from `name:`
# down, minus that file's own header comment. tools/check-publish-workflow.py
# holds the two to each other on every build.
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. Two spellings of one directory is the
    # defect shape this repository has published corrections for, so it is
    # CHECKED rather than trusted: tools/check-publish-workflow.py PW-8 requires
    # these to name 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/**' ]

# Read the repository, and nothing else. This job writes no commit, opens no
# pull request, publishes no package and touches no GitHub API. 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, and PW-8
  # is what keeps the two equal).
  POLICY_DIR: policy/

jobs:
  publish:
    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 the four secrets there, 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. ACP-330: the
          # location used to be a literal release URL in a repository you have
          # no account on, while policy-ci.md section 4 said the location comes
          # from Ziffer at onboarding -- the document and the file it hands you
          # disagreeing about who supplies a value, which is how a customer ends
          # up fetching from a host that will refuse them.
          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 "  docs/onboarding/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 ANY MORE, and that is the whole step.
          #
          # bundle_epoch must be strictly above the last epoch Ziffer accepted
          # from you (PB-5). When a person maintained it in manifest.json, two
          # pull requests both bumping to N+1 meant the second merge was refused
          # EPOCH_ROLLBACK for being equal -- routine, avoidable, and invisible
          # until the merge. `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      accepted
          #     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`. `accepted` 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 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
          # (services/publish/src/publish.rs, module `denied`), 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 now 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: docs/onboarding/policy-ci.md section 5, 'The answers, and the two"
          echo "  kinds of failure'."

There is no packaging step any more, and that is the visible half of ziffer publish. The body used to be built by a Python block in the workflow, with pip install cbor2 above it: a customer's CI assembling our wire format from a listing in a document. The encoding rules did not change — they moved into the tool, which is where they can be wrong in one place instead of in every customer's copy. Both still fail closed and neither was ever 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. For scale: our own rehearsal deployment runs at 1 MiB (1,048,576 bytes), which is a fact about that deployment and not a promise about yours.

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 seven 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 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 seven 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 checksum. All seven 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. Raise bundle_epoch, re-run. 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: accepted 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 accepted. ziffer publish prints both: outcome says what happened to your bundle at our door — accepted 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.

What this replaces. Until 2026-09-15 a publish went live only when an operator ran an activation command by hand, per publish, with a rolling restart behind it. That is gone for a bundle you publish: a published bundle activates itself, per tenant, with no operator command and no service restart. The operator command survives for the first placement of a new tenant, which is 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. Since 2026-09-06 that is no longer multiple deployments: 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.

For scale, our own rehearsal deployment runs at 1 MiB (1,048,576 bytes). That is a fact about that deployment on the day it was configured and not a promise about yours.


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.
  • Seven repository variables set: ZIFFER_CLI_URL, ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256, ZIFFER_PUBLISH_URL, 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/examples/ holds one proposal per action you gate, so the validate workflow's ziffer decide has something to grade.
  • 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 no longer needs a CI check of its own: the publish job reads it from ziffer epoch and writes the next one before signing. Leave the field in your manifest; nobody types it any more.

Synced from ziffer docs/onboarding/policy-ci.md at 32bc4db; edit the source, never this page.

On this page