Running the Executor
Who this is for: the engineer who will own the process that actually performs your agents' actions.
Who this is for: the engineer who will own the process that actually performs your agents' actions. You should be comfortable running a container, handing it environment variables, and storing a certificate and key where only that process can read them. Nothing else is assumed.
What you end up with: one process that asks ZIFFER for approved work, checks every approval against a policy bundle you signed, and either performs the action or refuses it by name. ZIFFER never holds the credential the action is performed with.
Read section 7 before you plan around any of this. The positive claims here are small and the honest scope is large, and a reader who takes "the Executor pulls" to mean "the Executor runs on your infrastructure today" will have taken the opposite of what ships.
1. The idea in one paragraph
Everything ZIFFER does ends in one artifact: a Decision Receipt, signed by our KMS, saying that a specific proposed action was allowed. The Executor is the component that reads a receipt and turns it into an effect. It refuses to do that unless the receipt survives a checklist: §9.3 of ZIFFER-SPEC-001, twelve steps, listed in section 5 below, run against the policy bundle you signed and against a durable record of what has already been executed.
The consequence worth stating: the gate is the receipt, not the network. A receipt that arrives over a perfect mutually authenticated connection is checked exactly as hard as one that does not. Nothing about who sent it, or over which link, weakens a single step.
2. Two ways work reaches the Executor, and you choose one
The Executor has always had a push listener: our Policy Engine posts the receipt to it, once, with no retry. That deployment is unchanged and is not deprecated.
There is now also a pull leg. Our Policy Engine writes the receipt to a durable queue instead of posting it, and your Executor comes and gets it. The two exist for one reason, and it is a limitation of the push path that we publish rather than hide:
An unreachable Executor is DENY, and it is a receipt in flight. The tempting answer is to retry, and a retry of a receipt whose first delivery may have landed is the doubled trade this whole system is built to refuse. So the engine sends once. What it leaves behind is worth stating plainly: a receipt was signed and nothing consumed it.
That note sits in the engine's own delivery code, written before anything needed it.
In pull mode that receipt has a durable home and a consumer that can arrive late. The row survives an Executor that was down for an hour. It does not survive an engine that never wrote it, and nothing here claims otherwise.
Both legs can be on at once. The push listener stays bound whether or not you configure the pull leg, and both transports go through the same entry to the checklist. There is exactly one implementation of §9.3 in this process, and section 5 says why that is load-bearing rather than tidy.
3. What we need from you once
Three things, handed over at onboarding. Only the second is generated by you.
-
Which mode. Push, pull, or both. In pull mode we run a delivery endpoint for your tenant and give you its address and the DNS name its certificate presents.
-
A certificate signing request from the Executor host. Your Executor connects to our delivery endpoint over mutual TLS, and we admit exactly one name. We issue that certificate, from our own private certificate authority, with the name
executor-pull.<your tenant>.ziffer.internal(since 2026-09-06; it wasexecutor-pull.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 delivery endpoint reads your queue off it, so no request you send carries a tenant field at all. You generate the key on the host that will use it; we never see it. Once:openssl ecparam -name prime256v1 -genkey -noout -out executor-pull.key.pem openssl req -new -key executor-pull.key.pem -subj "/CN=executor-pull" -out executor-pull.csrSend us
executor-pull.csr. We discard every name it carries, set ours, and send backexecutor-pull.crt.pemand our rootca.crt.pem. 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 limit, stated rather than dressed up.That name is not the one your CI uses to publish policy, and the separation is the point. Your CI may replace every rule the system reads; your Executor consumes decisions made under those rules. They are different authorities, and one certificate must never satisfy both: a stolen Executor certificate that could also publish would rewrite the policy it is judged by. For the same reason it is not the name your application uses to propose actions.
What a stolen Executor certificate buys, and for how long. It cannot cause an effect: the credential that performs an action is yours and never reaches us (section 1). It can read your pending work, your own decisions about your own proposals, and it can suppress: calling
/v1/work/completeon an item your real Executor has not polled marks it done, and your Executor never sees it. That is a denial, not a forgery, and nothing here detects it. The certificate is valid for 24 hours, and your Executor renews it itself: at the leaf's half-life, twelve hours in, the pull loop generates a fresh key, sends a signing request over the still-valid leaf, and replaces both files on disk. So a stolen pull certificate buys a thief that reading and that suppression for at most a day, and for the rest of the day only if your Executor has not renewed since the theft. There is no revocation list and no OCSP responder on that endpoint; revocation is refusing renewal. Tell us and we deny the name at the issuer, and the identity stops existing within one lifetime. A renewal that does not land is not an outage: the loop keeps the leaf it has, logsRenewalFailed <reason>on standard error every interval until the leaf expires, and never exits. Twelve hours of that line is the signal to act on. An expired leaf cannot be renewed, because the issuer renews a still-valid certificate, so an Executor that was down for more than a day comes back holding an identity it must re-enrol (section 3's signing-request exchange, again). -
Your policy signing PUBLIC key, the same file that section 2 of the Publishing policy from your own CI guide describes. The Executor verifies the bundle for itself, under its own configured copy of that key, and refuses to start if the bundle does not verify. It does not take our word for the bundle any more than it takes yours.
We never ask for a credential your Executor performs actions with, and there is nowhere to put one if you offered it. That is the whole of the gate-only arrangement: we decide, you execute.
4. The configuration surface
Every value below is required and has no default, with one exception marked as such. A missing or malformed value is a refusal that names the variable, and the process exits before it binds a socket. That is deliberate: a process that came up and cannot serve is worse than one that refused to come up, because a supervisor keeps the first alive.
The two listeners
| variable | what it is |
|---|---|
ZIFFER_EXECUTOR_LISTEN | host:port for the push leg, where our Policy Engine posts a receipt |
ZIFFER_EXECUTOR_ACK_LISTEN | host:port for human acknowledgements, from our notifier and our approval service |
ZIFFER_EXECUTOR_TLS_CERT / _TLS_KEY | this process's server identity, as PEM file paths |
ZIFFER_EXECUTOR_TLS_CLIENT_CA | the CA that issued the certificates of the two peers above |
ZIFFER_EXECUTOR_EXPECTED_POLICY | the one dNSName admitted on the execute listener |
ZIFFER_EXECUTOR_EXPECTED_NOTIFIER | a dNSName admitted on the acknowledgement listener |
ZIFFER_EXECUTOR_EXPECTED_APPROVAL | optional. A second dNSName admitted on the same listener: the approval service, carrying a person's confirmation or refusal from the hold page |
They are two sockets and not one because each pins a different counterpart. A single socket accepting both would mean a compromised notifier could submit a receipt for execution, which is exactly what the pin exists to prevent.
The acknowledgement listener admits up to two named peers, and never a pattern. Leave
ZIFFER_EXECUTOR_EXPECTED_APPROVAL unset and it admits the notifier alone, exactly as it did
before 2026-09-08. A person's refusal made on the hold page is then answered with a message
saying it reached nobody, which is the honest outcome and not a silent one. Set it, and it must
not be the same name as the notifier's: one identity on both legs is one service holding both,
and the process refuses to start naming the variable to fix.
The outbound legs
| variable | what it is |
|---|---|
ZIFFER_EXECUTOR_PEER_CA | the CA that issued the certificates of every peer this process dials |
ZIFFER_AUDIT_ADDR / ZIFFER_AUDIT_NAME | the audit chain: address, and the name its certificate must carry |
ZIFFER_NOTIFIER_ADDR / ZIFFER_NOTIFIER_NAME | the notifier, for hold notices, expiries and critical alerts |
ZIFFER_CONTEXT_ADDR / ZIFFER_CONTEXT_NAME | the Context Store, for the live capability re-check |
Each address sits beside a name, always. An address says where the bytes went; a name says who answered.
What the notifier does with a summons changed, and nothing here changed with it.
It used to append a line to a file on its own disk; it now sends email to addresses the notification
account placed. The Executor's side of that leg is identical: it hands over the canonical Proposal
and the recipient identities it read out of the signed bundle, and reads back a delivered list
that is still classified T (T-32). Whether anybody is reached at all depends on a delivery
configuration we place on our side; without it every recipient of every summons comes back
undelivered, which is a state the Executor already handles and reports.
Policy, identity and state
| variable | what it is |
|---|---|
ZIFFER_EXECUTOR_BUNDLE_ROOT | the root holding <root>/<tenant>/bundle and <root>/<tenant>/policy-signing-key.pub. On your host there is exactly one customer under it: you. It was ZIFFER_BUNDLE_DIR plus ZIFFER_POLICY_SIGNING_KEY until 2026-09-06 and both are deleted, not renamed, so a process started with the old names refuses at boot naming the variable rather than coming up against nothing |
ZIFFER_EXECUTOR_BUNDLE_POLL_SECS | how often (seconds, 1..300, default 15) the Executor re-lists <root>/<tenant>/ and adopts the newest bundle that verifies under your key, with no restart. The sync brings a new epoch onto your host; this is how soon the process notices it |
ZIFFER_EXECUTOR_PUBLISH_STORE | blank on your host, on purpose: in our installation the Executor also watches the store the publisher writes to; nothing delivers a signed bundle across the boundary to your host yet (section 5), so there is no store to watch and the variable is set empty rather than left out |
ZIFFER_EXECUTOR_SUITE_FLOOR | the minimum signature suite accepted; an unknown suite is refused, never defaulted |
ZIFFER_LEDGER_DSN | the consumption ledger: what makes a receipt single-use |
ZIFFER_EXECUTOR_ID | this Executor's identifier, recorded on every claim it makes |
ZIFFER_EXECUTOR_EFFECT_LOG | the at-most-once effect log, read from disk on every invoke |
Two of these are worth a sentence each.
The hold window and the sampling rate are not here, and that is the change. They were
ZIFFER_EXECUTOR_HOLD_WINDOW and ZIFFER_EXECUTOR_SAMPLE_RATE, and they are limits.json in
your signed bundle now. Three numbers live there:
| field | bounds | absent means |
|---|---|---|
attestation_window_seconds | 1 to 3600 | 3600 |
hold_window_seconds | 30 to 119 | 60 |
sample_percent | 0 to 100 | 10 |
How long a person has to approve, how long an action is held before it runs, and how often a
silent release is turned into one that needs a confirmation are decisions about your operation,
not settings on a process we run. A number outside its bounds is an invalid bundle, refused at
load and never rounded to the nearest legal value: a clamped limit is a limit nobody chose. A
bundle that declares no limits.json at all runs the numbers in the right-hand column, which is
what every deployment was already running.
An Executor started with either of the two old variables set is an Executor built before this change. It will not read them and it will not complain about them.
The effect log must be durable and must survive a restart. It is read from disk on every invoke rather than from memory, because a dedup table that forgets is a dedup table that doubles the action it exists to bound. Point it at storage that outlives the container.
The pull leg: optional, as a quintet
| variable | what it is |
|---|---|
ZIFFER_EXECUTOR_PULL_ADDR | host:port of your tenant's ZIFFER delivery endpoint |
ZIFFER_EXECUTOR_PULL_NAME | the dNSName that endpoint's certificate must present |
ZIFFER_EXECUTOR_PULL_INTERVAL_MS | milliseconds between one poll and the next |
ZIFFER_EXECUTOR_PULL_TLS_CERT | the pull leg's own certificate: the 24-hour leaf we issued for the name in section 3 |
ZIFFER_EXECUTOR_PULL_TLS_KEY | its private key |
All five, or none of them. Fewer is a refusal at startup naming the variables that are missing:
ZIFFER_EXECUTOR_PULL_TLS_CERT: the pull leg is all five of
ZIFFER_EXECUTOR_PULL_ADDR, ZIFFER_EXECUTOR_PULL_NAME,
ZIFFER_EXECUTOR_PULL_TLS_CERT, ZIFFER_EXECUTOR_PULL_TLS_KEY and
ZIFFER_EXECUTOR_PULL_INTERVAL_MS or none of them: ZIFFER_EXECUTOR_PULL_ADDR,
ZIFFER_EXECUTOR_PULL_NAME, ZIFFER_EXECUTOR_PULL_INTERVAL_MS are set and
ZIFFER_EXECUTOR_PULL_TLS_CERT, ZIFFER_EXECUTOR_PULL_TLS_KEY are not. Fewer is
an Executor that comes up, binds, and pulls nothing, or pulls with an identity
that is not the pull leg's.That refusal exists because the permissive reading has no symptom. An address with no pinned name is a connection to whatever answers; a name with no address is a pin over nothing; either without an interval is a loop with no tick. Every one of those produces a process that starts, binds both listeners, and quietly does nothing, while the receipts we signed for you sit in a table nobody reads. The pull leg's own certificate and key are in the same set for the reason the refusal names: without them the leg would dial with an identity that is not the pull leg's.
An interval of zero is also refused. A poll takes this process's own lock to run the checklist, so a loop with no interval would starve the push listener beside it. There is no upper bound and no default: how often you want to ask is yours.
Two pairs, and which is which. The pull leg presents its own pair,
ZIFFER_EXECUTOR_PULL_TLS_CERT / _KEY: a tenant-facing identity under our CA, carrying
the SubjectAltName you gave us in section 3, living 24 hours. ZIFFER_EXECUTOR_TLS_CERT /
_KEY is the process's internal identity: the one both listeners and the audit, notifier
and context legs present, under whatever CA you run, for as long as you say. They are two trust
domains and they stay two files: a pull pair that names the internal pair's files is refused at
startup by name. The pull leg roots in ZIFFER_EXECUTOR_PEER_CA like every other leg. It adds
a peer, never a second trust root.
Renewal: optional, as a pair, and only beside the quintet
| variable | what it is |
|---|---|
ZIFFER_EXECUTOR_RENEW_ADDR | host:port of ZIFFER's certificate issuer |
ZIFFER_EXECUTOR_RENEW_NAME | the dNSName the issuer's certificate must present |
Both, or neither. Half a pair is refused at startup by name, for the quintet's reason with a
sharper edge: an address with no pinned name is a certificate signing request handed to whatever
answers that port, and what comes back becomes this process's own identity. A pair with no
pull leg is also refused, because renewal runs on the pull loop's timer. Accepted, it would be
a leg that never runs, and the leaf this process presents on every other connection would
expire tomorrow with nothing having asked for another. The refusal says which way out you meant:
configure the quintet, or renew out of band with ziffer-renew and unset the pair.
With the pair set, the loop checks the leaf named by ZIFFER_EXECUTOR_PULL_TLS_CERT once
per interval, before the poll. Past its half-life it asks for a new one, proves the answer goes
with the key it generated, writes both new files beside the old ones, and only then moves them
into place by rename: a reader sees the whole old file or the whole new one, never half of
either, and a write that fails (a full disk) leaves the old pair exactly as it was. The
certificate file then holds the leaf and, once our issuing key is an intermediate, that
intermediate beneath it; keep the file whole. Renewal rewrites the pull pair and nothing
else. Your internal pair is never read by it and never written by it, and the pull client is
the one leg built from the renewed files: it picks them up on the next tick, so no restart is
needed for anything.
5. What actually gates an action
When a receipt arrives, by either transport, this process runs §9.3. The steps are not a checklist in prose somewhere; they are code, in one place, and both transports reach it through the same function. That is the single most important structural fact in this document: a second verify path would be a second definition of §9.3, and the two would diverge at the first change that landed in one and not the other. At that moment "does this deployment enforce the audit gate before a high-risk release?" would become "which transport did the receipt arrive on?".
What the checklist establishes, by clause:
| what is checked | clause |
|---|---|
| the Proposal and the receipt body encode to exactly the bytes that were signed | AT-8a |
| the receipt's signature verifies under the enrolled KMS identity, every primitive, never any | CR-1…CR-5 |
| the signature suite is at or above the configured floor | CR-4 |
| the receipt is not past its validity window | L-14 |
| the bundle the receipt was issued against is not older than one already seen | RAD-3 / DP-40 |
| the receipt's nonce is claimed, once, for ever | CL-2 |
| this Proposal is bound to this origin, immutably | DS-6f |
| the risk is recomputed from the signed bundle, never read from the receipt | TR-8 |
| reversibility is recomputed the same way | RV-3 |
| every attestation in a quorum verifies and its identifier is claimed | AT-*, AB-*, CL-3 |
| the capability is still live at execution time, for high-risk actions | T-10 |
| a covering audit anchor exists before a high-risk release | AU-7 |
| the idempotency key is recomputed from the ledger's own binding | DS-6b |
Three rows use the word recomputed, and it is the same rule three times: a verifier never accepts a derived security value from the party it is verifying. The receipt states a risk level; the Executor ignores that statement and derives one from the bundle you signed. If the two disagree, the receipt is refused.
The order is itself a control. On an input carrying more than one defect, the order decides which clause fires, and the clause is what you are told. The bundle epoch is checked before a nonce is claimed, so a receipt issued against a superseded bundle does not consume a slot on its way to being refused. The nonce is claimed before the origin is bound. The idempotency key is recomputed last, because it reads the binding the two steps before it established.
Records are written before effects, always. The decision record is committed before anything can happen, and a deferred-release notice before the effect it warns about. A record written after the action could be suppressed by whatever the action enabled, and detection that can be erased is not detection. This is why an audit service that cannot answer stops the Executor rather than being skipped.
6. What it refuses, and what a refusal means
Every refusal carries a clause id and reaches two places: an audit record, and a critical alert whose audience is named in your signed bundle rather than by us.
| alert class | what raised it |
|---|---|
LEDGER_REPLAY | a consumed identifier or a rebound origin: CL-2, CL-3, DS-6e, DS-6f |
RECOMPUTATION_MISMATCH | the receipt's stated risk or reversibility is not what the bundle says: TR-8, RV-3 |
SUITE_BELOW_FLOOR | a signature suite under your floor: CR-4 |
CONTEXT_STORE | the live capability check disagrees with what was decided: 9.3-9 |
AUDIT_INTEGRITY | the chain or its anchors: AU-7 |
ATTESTATION_INVALID | a quorum that does not hold, including a human approver's assertion: HM-3, HM-4 |
DEFERRED_RELEASE | the hold, the notice, or an acknowledgement |
RECEIPT_INVALID | anything else the Executor refuses on the receipt's own bytes |
Two behaviours are worth knowing before you build alerting around this.
A refusal is terminal, not a retry. In pull mode the Executor reports the work as refused
and the queue row closes. Nothing is lost by that, since the refusal is already an audit record
and already a critical alert, and leaving it open would wedge every later item behind one
poisoned receipt.
A dependency that could not answer is not a refusal. If the audit service, the Context Store or the ledger cannot be reached, the Executor reaches no verdict, reports nothing, and the work stays queued. The next poll asks again. A store that could not answer is not a store that said no, and recording a verdict nobody reached would close a receipt nobody decided.
Delivery is at-least-once, and execution is not. A poll marks nothing, so the same work comes
back until you complete it. That is safe only because the nonce claim above is at-most-once: a
second delivery of one receipt is refused before anything runs. Two consequences follow. First,
you may be handed the same receipt twice and you should not treat that as an anomaly. Second,
every genuine redelivery of already-executed work raises LEDGER_REPLAY, a real control firing
correctly, and one that becomes noise if your Executor completes work sloppily. Complete what you
consume.
7. What this does not give you yet
This is the section to read twice. Pull mode delivers the shape a customer-premises Executor will use; it does not put an Executor on your premises. Six legs still require our deployment perimeter, and each touches something inside it by name.
-
The consumption ledger. The claims that make a receipt single-use, including the nonce claim the whole at-least-once delivery argument leans on, are made against a PostgreSQL instance in an isolated network tier of our account. An Executor outside it has two options and both are refused. Handing it a database credential across the internet is the credential handover gate-only exists to end, pointed the other way; letting it skip the claim deletes the step that makes a receipt single-use. This is the largest of the six, because the safety argument for pull delivery is a claim against a store the puller cannot reach.
-
Audit append, and the covering anchor before a high-risk release. The audit service is behind an internal load balancer.
AU-7requires a covering anchor before a high-risk release, so an Executor that cannot reach it cannot release at high risk at all. Not "loses some evidence", cannot release. -
The live capability re-check. The Context Store's balancer is internal too. Same shape: the leg is not degraded from outside, it is absent.
-
Human acknowledgement. Acknowledgements arrive inbound, at this process's own acknowledgement listener, from our notifier. The pull leg inverts one transport and inverts nothing else, so an Executor with no inbound route from our cloud has no deferred door: no hold, no positive acknowledgement, no repudiation. That is the entire high-risk release path.
-
Getting the signed bundle to you. The Executor reads
ZIFFER_EXECUTOR_BUNDLE_ROOTas a local root and activates<root>/<tenant>/bundlefor the customers placed under it, which on your host is you alone. Moving a signed bundle onto a host we do not operate is a deployment act across a network boundary and it has no mechanism today. Without it the Executor has no policy to verify against and refuses rather than proceeds: correct, and not operable. -
Provenance of the binary itself. You would be running our Executor image, and today it ships with no SBOM and no build attestation. The whole argument of this arrangement is that you verify instead of trusting us; this is the one place in it where you cannot. It is recorded as open, it is the inversion of the rule the rest of this document is built on, and nothing available today closes it.
So, plainly: pull mode works within our deployment perimeter. The delivery endpoint, the durable queue, the two engine modes and your Executor's pull quintet are built and exercised end to end against an Executor that is still inside it. What you can say today is "the Executor can pull its work". You cannot yet say "the Executor runs on your infrastructure".
One more residual, on the endpoint rather than the process. A stolen pull certificate cannot cause an effect: that needs the target credential, which we never see. It can read your pending work, which is your own decisions about your own proposals. And it can suppress: completing a sequence number your genuine Executor never polled marks that work done, and your Executor never sees it. That is a denial, not a forgery, and nothing detects it today. What bounds it is the leaf's day, and the renewal that replaces the key underneath it. Detecting it needs a reconciliation of issued rows against your own execution records, which sits on the far side of every boundary above.
8. Deploy in your account
Everything above describes a process. This section is the CloudFormation that stands it up in your AWS account, what it asks you for, and what each line of IAM is for. Read section 7 first; this template does not close any of the six things named there.
What you run. One template, executor.yaml, into your own account on your own credentials.
We hold no credential in your account and no role to assume into it, so nothing here deploys on
your behalf. customer-executor.sh --render prints the aws cloudformation deploy command and
the parameter file from two inputs: the handover sheet we gave you at enrolment, and a file of
your own account values.
customer-executor.sh --render \
--sheet handover-<your tenant>.txt \
--values values-<your tenant>.env \
--region <your region>Fourteen parameters are read out of the handover sheet by name and must not be typed: your tenant label, the suite floor, the delivery address and the name its certificate presents, and the ten values that are ours rather than yours. A sheet missing any of them is a refusal naming the row, not a blank in a rendered command.
The values file is your answers and nothing else. If it names one of the ten, because you
copied it out of an older sheet or because someone gave it to you in an email, --render
refuses with ZifferValueInCustomerFile: <name> and renders nothing. That is deliberate:
preferring one of the two copies, or ignoring one, would leave you believing a value is in
effect that is not. Delete the line; the sheet is where that value lives.
What you get. A Fargate service with exactly one task, a cluster, a log group, a security group with no ingress, two roles, and three alarms. No load balancer, no public address and no inbound route: nothing dials this process, and section 7 item 4 says why.
8.1 The parameters
Values that come from the handover sheet.
| parameter | what it is |
|---|---|
TenantId | your tenant label, from the sheet title. It is the one directory under the bundle root and it is in the SubjectAltName of your pull leaf |
SuiteFloor | ZIFFER_EXECUTOR_SUITE_FLOOR, from the sheet's ZIFFER_SUITE_FLOOR row |
PullAddr | ZIFFER_EXECUTOR_PULL_ADDR, from the sheet |
PullServerName | ZIFFER_EXECUTOR_PULL_NAME, from the sheet |
Values that are ours, and that are on the sheet. You copy each one across and never answer
it: they are properties of our deployment, you cannot check a single one from your side, and a
wrong pin is a handshake this process loses against a service you have never seen. --render
reads all ten off the sheet, refuses to take any of them from the values file, and prints them
back under their own heading so you can compare them with the sheet in front of you.
The sheet splits them the way they are produced. The four addresses are read from our deployment's own CloudFormation exports at enrolment, so they are facts about a standing installation and not values anybody typed; the pinned names are constants of our PKI and are known before anything is deployed.
| parameter | what it is | on the sheet under |
|---|---|---|
AuditAddr | the audit chain's address | read from this deployment |
NotifierAddr | the notifier's address | read from this deployment |
ContextAddr | the Context Store's address | read from this deployment |
RenewAddr | our certificate issuer's address | read from this deployment |
ExpectedPolicy | the one dNSName admitted on the execute listener | pinned |
ExpectedNotifier | the first dNSName admitted on the acknowledgement listener | pinned |
ExpectedApproval | the second one: the approval service, relaying a person's confirmation or refusal from the hold page. ZIFFER_EXECUTOR_EXPECTED_APPROVAL is optional to the binary, and the sheet carries it so that an Executor whose human hold page cannot relay is never the result of a row nobody wrote | pinned |
AuditServerName | the pin on the audit leg | pinned |
NotifierServerName | the pin on the notifier leg | pinned |
ContextServerName | the pin on the Context Store leg | pinned |
RenewServerName | the pin on the issuer | pinned |
ZifferEndpointCidr | the address range those endpoints answer on. A security group rule takes an address and not a name, which is why this exists at all | read from this deployment |
ZifferDeliveryPort | the port egress is opened to for the delivery endpoint | read from this deployment |
ZifferIssuerPort | the port egress is opened to for the certificate issuer. The same export RenewAddr's port half comes from, so the address you dial and the port your rule admits cannot disagree | read from this deployment |
The last three joined the sheet on 2026-09-09. Until then they were ours, given at
onboarding, sitting in your values file where they read like your answers, and nothing checked
them. The template's own parameter descriptions had said "on the handover sheet"
from the day they were written, and the sheet had never carried a row for any of them. The CIDR
is the control-plane VPC's, read from this installation's ziffer-<env>-vpc-cidr export; there
is no narrower value to read, because a balancer's address moves within its subnets and a
host-level rule would stop matching with nothing changed.
Values that are yours.
| parameter | what it is |
|---|---|
Env | your environment name. It names the roles, the cluster, the service and the log group. Default prod |
VpcId / SubnetIds | where the task runs. Private subnets; the task is given no public address |
VpcCidr | your VPC range, for the one egress rule that stays inside your account: NFS to the effect log |
AwsServicePrefixListId | the managed prefix list for the AWS endpoints the task reaches on 443. A prefix list rather than 0.0.0.0/0, so this stack opens no general egress |
ImageUri | the Executor image. Pin a digest, not a tag: see 8.4 |
ImageRepositoryArn | the ECR repository ARN the image is pulled from, which is what the pull grant names. Required: publishing to a public registry is blocked until the agreement, so today every customer pulls from a private per-customer repository |
ConfigSyncImageUri | the image that copies your configuration out of S3 before the Executor starts. It runs one command, aws s3 sync, and no script |
ConfigBucketName / ConfigPrefix | a bucket and prefix in your account holding the configuration tree of 8.2. The grants below are scoped to the prefix and never to the bucket |
ConfigKmsKeyArn | the key that bucket is encrypted under. Required. If the bucket is SSE-S3 the answer is your account's AWS-managed key, aws kms describe-key --key-id alias/aws/s3, and the grant is then simply unused. It is scoped to S3 either way, so it cannot decrypt anything but an object of that bucket |
EffectLogFilesystemId / EffectLogFilesystemArn | an EFS file system for the at-most-once effect log |
EffectLogAccessPointId / EffectLogAccessPointArn | one POSIX access point on it. The mount grant is conditioned on this exact access point, so the task cannot mount the file system anywhere else |
LedgerSecretArn | a Secrets Manager secret with a dsn key. Read 8.6 before you plan around it |
ExecutorId | ZIFFER_EXECUTOR_ID, recorded on every claim this Executor makes. Yours to choose and yours to keep stable: it is how a claim is attributed, so it is stated rather than derived from a task id that changes on every restart |
PullIntervalMs | milliseconds between one poll and the next. Zero is refused; start at 1000 |
ExecuteListenPort / AckListenPort | the two listeners. Defaults 8443 and 8444, and the only defaults offered here, because nothing reaches either socket |
Cpu / Memory / CpuArchitecture | sizing; see 8.5 |
DesiredCount | one, and the maximum is one; see 8.5 |
LogRetentionDays | how long a refusal, a renewal failure and a boot refusal are kept. Default 30 |
AlarmTopicArn | an SNS topic for the three alarms of 8.7. Empty deploys them with no action rather than not deploying them |
8.2 What goes in the configuration prefix
Everything under ConfigPrefix is copied to /etc/ziffer at every task start, by an init
container that must exit successfully before the Executor is started. The Executor then
verifies the bundle for itself and refuses to serve if it does not verify. The tree:
tenants/<your tenant>/bundle/ the signed bundle
tenants/<your tenant>/policy-signing-key.pub the key it verifies under
tls/server.crt tls/server.key your internal identity, both listeners and three legs
tls/client-ca.crt the CA that issued our two peers
tls/peer-ca.crt the CA every leg you dial roots in
tls/executor-pull.crt.pem the 24-hour leaf we issued for your pull name
tls/executor-pull.key.pem its private keyThe pull private key is in your bucket, in your account, and never leaves it. There is no parameter here that takes a ZIFFER principal, and nothing in this template grants us a read.
The bundle is re-fetched on every start and the effect log is not. That is the opposite choice made twice on purpose. The bundle has a source to be fetched from and a signature that is checked; a durable copy of it would be a second copy of your own bucket, ageing quietly. The effect log has no source: it is the at-most-once table, and a table that starts empty on a restart is a table that doubles the action it exists to bound. That is why it is on EFS.
8.3 The IAM, line by line
Two roles. The execution role is the ECS agent's and does its work before the binary starts; the task role is what the containers hold while they run.
| statement | grant | why |
|---|---|---|
WriteItsOwnLogStream | logs:CreateLogStream, logs:PutLogEvents on one log group | a refusal reaches standard error and nowhere else in your account |
ResolveTheLedgerSecret | secretsmanager:GetSecretValue on one secret ARN | the agent resolves ZIFFER_LEDGER_DSN into the environment before the process starts. Named by ARN and not by prefix: a prefix grant is a grant over every secret somebody names that way tomorrow |
AuthenticateToTheRegistry | ecr:GetAuthorizationToken | the image pull. This action has no resource-level support in IAM, so the resource is a star for one named action |
PullTheExecutorImage | ecr:BatchCheckLayerAvailability, ecr:GetDownloadUrlForLayer, ecr:BatchGetImage on one repository ARN | the image pull, from the repository we published for you |
ListTheConfigPrefix | s3:ListBucket, conditioned on ConfigPrefix | the init container. Scoped by an s3:prefix condition, because a list over the bucket enumerates every object name in it |
ReadTheConfigPrefix | s3:GetObject under the prefix | the init container reads the tree of 8.2 |
MountTheEffectLogAccessPoint | elasticfilesystem:ClientMount, ClientWrite, conditioned on one access point ARN | the effect log. Without the condition the same two actions mount the file system root |
DecryptTheConfigObjects | kms:Decrypt on one key, via S3 only | an SSE-KMS bucket. Without it the sync answers AccessDenied naming S3, with the cause in KMS |
Every statement in both roles is unconditional, and that is deliberate. The check we run over this role refuses to answer at all over statements it cannot fully read, which is why neither ECR nor KMS is behind a CloudFormation condition here: a template that made that check stop reading would be a role nobody could say was consistent. The two parameters are required instead.
And nothing else. Two absences are worth stating, because both are what somebody would add:
No kms:Verify. A receipt is verified inside the process, under the public keys enrolled
in the bundle you signed. The binary links no AWS SDK at all, so it cannot call KMS even if
this role allowed it. A grant here would suggest the verification is AWS's. It is not.
No credential for any action the Executor performs. There is nowhere in this template to put one, and that is the whole of the arrangement: we decide, you execute, and the credential that executes is yours and never moves. A broad grant added here to make a first adapter work would be a conformance failure written where nobody would look for it.
8.4 The image
We copy the Executor image into a per-customer ECR repository with a pull grant for exactly
your account id: four read actions, no ecr:PutImage, no wildcard principal. The repository
has immutable tags and the copy is made by manifest digest, never by tag.
Pin the digest in ImageUri. A tag is a name somebody can move, and section 7 item 6 records
that this image ships with no SBOM and no build attestation, so the digest is the whole of
what you can hold us to. Publishing to a public registry is blocked until the agreement, and
the tool that makes the copy refuses that path by name rather than omitting it.
8.5 Sizing, and one task
Cpu defaults to 1024 units and Memory to 2048 MiB. Those two numbers are the size we
deploy our own Executor at: they are copied from our own control-plane template, and a check
re-reads it so the copy cannot drift from its source. They are not a measured working set:
no CPU or memory measurement of this binary exists, and a number invented here would be
provenance dressed as evidence.
Watch your own task's utilisation and raise them if your volume needs it.
DesiredCount is 1 and its maximum is 1. That is not a starting point. The at-most-once
effect log is a file, read from disk on every invoke, and two tasks appending to it are two
readers deciding independently whether an action has already happened, on a file neither of
them locks. For the same reason the deployment is 0/100 and not 100/200: a rolling deploy at
100/200 runs the old task and the new one together for the length of every rollout. The cost
is a gap in availability during a deploy, and single-use execution is not an availability
property.
8.6 The ledger, stated again because the template does not close it
LedgerSecretArn is the shape of the answer and not the answer. The consumption ledger that
makes a receipt single-use, including the nonce claim the whole at-least-once delivery
argument leans on, is a PostgreSQL instance in an isolated network tier of our account
(section 7 item 1), and nothing in this template moves it. Point this parameter at a database
your Executor can reach and read section 7 item 1 again before you plan around what that
means. This is disclosed rather than closed.
8.7 The health check
There is no HTTP health route and there is not going to be one. Both listeners are mutual TLS pinned to one name each, and a plaintext health endpoint on the one component that causes effects would be a second, unauthenticated way to talk to it. The runtime image is distroless, so a container health-check command has no shell to run in either.
What the stack watches instead is what the process actually tells your account. Three alarms, three different failures:
| alarm | what it means | what to do |
|---|---|---|
ziffer-<env>-customer-executor-not-running | the task is not there. It refuses at boot rather than coming up degraded | read the log group: the first line names the variable, the file or the clause |
ziffer-<env>-customer-executor-renewal-failed | the pull leaf is not being renewed. Nothing breaks until it expires, and an expired leaf cannot be renewed: it has to be re-enrolled | act inside the day |
ziffer-<env>-customer-executor-critical | a refusal, with its clause id on the line beside it | read the clause. A LEDGER_REPLAY on redelivered work is the nonce claim doing its job, not an incident |
The two log patterns are the process's own strings, and a check greps them out of the source rather than comparing this document to a copy of them.
8.8 What each refusal at start means
The process exits before it binds a socket, and the first line of the log group names the cause. The shapes you will see:
| the line names | what happened |
|---|---|
a ZIFFER_* variable | it is missing or malformed. Every value is required and nothing has a default |
ZIFFER_EXECUTOR_HOLD_WINDOW or ZIFFER_EXECUTOR_SAMPLE_RATE | you are running an Executor built before those two moved into the signed bundle. This template does not set them on purpose: they are policy you sign, not deployment settings. Use an image at or after that change |
a path under /etc/ziffer | the init container placed a tree that is missing a file, or the prefix in your bucket is not the tree of 8.2 |
| a bundle refusal | the bundle under tenants/<your tenant>/bundle does not verify under policy-signing-key.pub beside it. Both come from your own prefix, so this is a placement problem before it is a signing one |
LimitOutOfBounds | your limits.json declares a number outside the bounds in section 4. It is refused rather than rounded: a clamped limit is a limit nobody chose. Re-sign the bundle with a legal value |
HumanAssuranceAbsent | a webauthn approver in your registry carries no assurance level, or carries AS0. The level is derived from the authenticator at enrolment, and an entry nobody derived one for says the enrolment ceremony was not performed. Re-enrol that person |
attesters/registry.json: and a field name | your attester registry is not the document attesters.schema.json declares, and the line names which field and what is wrong with it. The two you are most likely to see are a schema_version that is absent or is not "1" (the registry's version, which is not your manifest's 1.0.0) and an assurance level this build does not know. Both are refused rather than repaired: a registry read as a version it does not claim, or a level nobody here can resolve, is a quorum decided under a policy you did not write. Re-sign the bundle with the field corrected |
| a pull variable, in a list | the quintet is incomplete. All five or none, and the refusal names the ones that are missing |
| the renewal pair | it is half set, or it is set with no pull leg. Both or neither, and neither outside a pull leg |
A refusal at start is the design working. A process that came up and could not serve would be worse, because a supervisor keeps the first one alive.
9. Checklist
- You told us push, pull, or both.
- The pull leg's own certificate (
ZIFFER_EXECUTOR_PULL_TLS_CERT, the leaf we issued) carries the SubjectAltName you gave us, and that name is used for nothing else: not your policy CI, not your proposing application, not your Executor's internal pair. - Your policy signing public key is at
<root>/<your tenant>/policy-signing-key.pub, beside the bundle it verifies. There is no separate variable for it any more. - A signed bundle is at
<root>/<your tenant>/bundle, underZIFFER_EXECUTOR_BUNDLE_ROOT. The process will not start with a root it cannot read, and it refuses that customer's work by name if their subtree does not verify. -
ZIFFER_EXECUTOR_EFFECT_LOGpoints at storage that survives a restart. - The pull quintet is all five variables or none, including the pull leg's own certificate and key, which are not your internal pair, and the interval is not zero.
- You have read section 7 and know which six things do not work outside our perimeter.
- You know where to write when an alert fires, and what to put in the report. The support guide has the addresses, the severities, and a row per alert class and clause in section 6 above saying what it means and who moves next.
If you are deploying the template of section 8, four more:
-
ImageUrinames a digest, not a tag, and it is the digest we published for you. - The configuration prefix holds the whole tree of 8.2, and the pull private key in it was generated on your side and has never been sent to us.
-
EffectLogAccessPointArnis an access point on a file system that outlives the task, andDesiredCountis still 1. - You have somewhere to send
AlarmTopicArn, or you have accepted that the three alarms of 8.7 are visible in the console and page nobody.
Synced from ziffer docs/onboarding/executor.md at 32bc4db; edit the source, never this page.