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.
-
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, andclassical,pqandfingerprintare 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.jsonalgandnoteare the two you state yourself. Neither is a property of a key:algnames 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.
-
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 waspublish-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.csrSend us
publish-ci.csr. It contains a public key and nothing else. We discard every name it carries, set ours, and send backpublish-ci.crt.pemplus our rootca.crt.pem. Keeppublish-ci.key.pemon 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 withziffer-renew(section 4): run it in the publish job beforepublishonce 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 stampsThe 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:
| Field | Rule |
|---|---|
bundle_epoch | An 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.id | Must be different identifiers. The signing tool refuses before it signs. Two-person review of policy is a rule, not a convention. |
expires_at | An 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/zifferIn 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.keyIt 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.
-
keygengenerates 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 documentpubkeyprints. It exists because that key file was documented here and produced by nothing. -
listwalks 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. -
signchecks author-vs-reviewer first, then writesSIGNATUREinto 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. -
verifychecks the bundle the way our runtime will and names the refusal if there is one. -
limitsverifies exactly asverifydoes and then prints the deployment limits your bundle declares, with the document's defaults applied where it declares none. -
decideruns the engine's own grading fold over one proposal, offline, against your bundle. On a pass it printsrisk=… reversibility=… notice=…and exits 0; otherwise it printsREFUSED <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:--pubkeygrades a bundle whose signature it has checked, and--unsignedgrades 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
--unsignedrun is a statement about what your rules DECIDE, and about nothing else. What proves a bundle is the one you signed isverify, and the publish job runs it on the bytes it just signed. -
publishverifies locally, encodes the CBOR body, sends it over mutual TLS and decodes the answer:outcome,tenant,bundle_hash,epochandactivation. A refusal exits non-zero with the refusal's name. An expired client certificate is refused asClientCertExpired, with theziffer-renewcommand in the message, instead of a TLS handshake error you cannot read. -
epochasks the publisher which epoch it holds for you and prints the integer alone, so the nextbundle_epochis computed rather than typed (section 5). -
pubkeyderives 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:
SIGNATUREis not a member. A signature cannot cover itself, so the walk that computes the tree hash excludes theSIGNATUREfile at the bundle root. Send it insidemembersand 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:
| Status | What it means | What to do |
|---|---|---|
| 400 | Refused on the merits. Retrying the same bytes changes nothing. | Fail the job and fix the bundle. |
| 503 | ZIFFER 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:
denial | What happened |
|---|---|
BODY_MALFORMED | The body is not a CBOR map of exactly members and signature, each once. Your packaging step, not your bundle. |
MEMBERS_MALFORMED | members is not a map of member path to member bytes. |
SIGNATURE_MALFORMED | signature is not the SIGNATURE file's bytes, or those bytes are not the {suite, parts} object the signature format declares. |
BUNDLE_NOT_A_TREE | The member paths are not a bundle tree: a path that could escape the root, a repeated path, or no members at all. |
BUNDLE_REFUSED | The 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_MISMATCH | manifest.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_ROLLBACK | bundle_epoch is not above the last epoch accepted from you. Equal is refused; lower is refused. |
STORE_TREE_MISMATCH | What 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:
- Verifies the signature over the bundle's contents against your enrolled public key, in memory, before anything is written anywhere. Both algorithms must verify.
- Checks the bundle names your tenant. A bundle whose
manifest.jsonnames a different tenant is refused here, before any durable state changes, so a request naming someone else cannot affect them. - 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.
- Stores the bundle, addressed by its own content hash, written whole or not at all.
- 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:
-
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.jsonDo 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.
-
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.
-
Re-sign your CURRENT policy with the new key, at a
bundle_epochabove 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.keyWe 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.
-
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:
acceptedmeans 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.
activation | What it means | What to do |
|---|---|---|
active | every reader in your deployment reported this bundle's tree hash. The change is in force | nothing |
published-not-yet-active | the bundle is stored and recorded; the readers named beside it had not picked it up before the publisher stopped waiting | wait 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
-
zifferinstalled with its signature and checksum checked first (install.mdsection 1). - Key material generated offline with
ziffer keygen; private halves never in the repository. - Public key derived with
ziffer pubkey, committed as printed, and itsidentityhalves plusfingerprintcarried 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_KEYstored 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-- andZIFFER_SECRETS_TOKENas a fifth secret, for therenewjob (install.mdsection 5 is the list with the value beside each). -
policy/examples/holds one proposal per action you gate, so the validate workflow'sziffer decidehas something to grade. -
author.idandreviewer.idpopulated from the merge, and never equal. - A calendar reminder before
expires_at, and a fortnightly one to confirm the scheduledrenewjob passed (install.mdsection 6; the by-handziffer-renewrecipe there is the fallback). -
bundle_epochno longer needs a CI check of its own: the publish job reads it fromziffer epochand 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.