ZIFFER home

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.

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

  2. 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 was executor-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.csr

    Send us executor-pull.csr. We discard every name it carries, set ours, and send back executor-pull.crt.pem and our root ca.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/complete on 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, logs RenewalFailed <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).

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

variablewhat it is
ZIFFER_EXECUTOR_LISTENhost:port for the push leg, where our Policy Engine posts a receipt
ZIFFER_EXECUTOR_ACK_LISTENhost:port for human acknowledgements, from our notifier and our approval service
ZIFFER_EXECUTOR_TLS_CERT / _TLS_KEYthis process's server identity, as PEM file paths
ZIFFER_EXECUTOR_TLS_CLIENT_CAthe CA that issued the certificates of the two peers above
ZIFFER_EXECUTOR_EXPECTED_POLICYthe one dNSName admitted on the execute listener
ZIFFER_EXECUTOR_EXPECTED_NOTIFIERa dNSName admitted on the acknowledgement listener
ZIFFER_EXECUTOR_EXPECTED_APPROVALoptional. 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

variablewhat it is
ZIFFER_EXECUTOR_PEER_CAthe CA that issued the certificates of every peer this process dials
ZIFFER_AUDIT_ADDR / ZIFFER_AUDIT_NAMEthe audit chain: address, and the name its certificate must carry
ZIFFER_NOTIFIER_ADDR / ZIFFER_NOTIFIER_NAMEthe notifier, for hold notices, expiries and critical alerts
ZIFFER_CONTEXT_ADDR / ZIFFER_CONTEXT_NAMEthe 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

variablewhat it is
ZIFFER_EXECUTOR_BUNDLE_ROOTthe 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_SECShow 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_STOREblank 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_FLOORthe minimum signature suite accepted; an unknown suite is refused, never defaulted
ZIFFER_LEDGER_DSNthe consumption ledger: what makes a receipt single-use
ZIFFER_EXECUTOR_IDthis Executor's identifier, recorded on every claim it makes
ZIFFER_EXECUTOR_EFFECT_LOGthe 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:

fieldboundsabsent means
attestation_window_seconds1 to 36003600
hold_window_seconds30 to 11960
sample_percent0 to 10010

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

variablewhat it is
ZIFFER_EXECUTOR_PULL_ADDRhost:port of your tenant's ZIFFER delivery endpoint
ZIFFER_EXECUTOR_PULL_NAMEthe dNSName that endpoint's certificate must present
ZIFFER_EXECUTOR_PULL_INTERVAL_MSmilliseconds between one poll and the next
ZIFFER_EXECUTOR_PULL_TLS_CERTthe pull leg's own certificate: the 24-hour leaf we issued for the name in section 3
ZIFFER_EXECUTOR_PULL_TLS_KEYits 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

variablewhat it is
ZIFFER_EXECUTOR_RENEW_ADDRhost:port of ZIFFER's certificate issuer
ZIFFER_EXECUTOR_RENEW_NAMEthe 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 checkedclause
the Proposal and the receipt body encode to exactly the bytes that were signedAT-8a
the receipt's signature verifies under the enrolled KMS identity, every primitive, never anyCR-1CR-5
the signature suite is at or above the configured floorCR-4
the receipt is not past its validity windowL-14
the bundle the receipt was issued against is not older than one already seenRAD-3 / DP-40
the receipt's nonce is claimed, once, for everCL-2
this Proposal is bound to this origin, immutablyDS-6f
the risk is recomputed from the signed bundle, never read from the receiptTR-8
reversibility is recomputed the same wayRV-3
every attestation in a quorum verifies and its identifier is claimedAT-*, AB-*, CL-3
the capability is still live at execution time, for high-risk actionsT-10
a covering audit anchor exists before a high-risk releaseAU-7
the idempotency key is recomputed from the ledger's own bindingDS-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 classwhat raised it
LEDGER_REPLAYa consumed identifier or a rebound origin: CL-2, CL-3, DS-6e, DS-6f
RECOMPUTATION_MISMATCHthe receipt's stated risk or reversibility is not what the bundle says: TR-8, RV-3
SUITE_BELOW_FLOORa signature suite under your floor: CR-4
CONTEXT_STOREthe live capability check disagrees with what was decided: 9.3-9
AUDIT_INTEGRITYthe chain or its anchors: AU-7
ATTESTATION_INVALIDa quorum that does not hold, including a human approver's assertion: HM-3, HM-4
DEFERRED_RELEASEthe hold, the notice, or an acknowledgement
RECEIPT_INVALIDanything 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.

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

  2. Audit append, and the covering anchor before a high-risk release. The audit service is behind an internal load balancer. AU-7 requires 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.

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

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

  5. Getting the signed bundle to you. The Executor reads ZIFFER_EXECUTOR_BUNDLE_ROOT as a local root and activates <root>/<tenant>/bundle for 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.

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

parameterwhat it is
TenantIdyour 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
SuiteFloorZIFFER_EXECUTOR_SUITE_FLOOR, from the sheet's ZIFFER_SUITE_FLOOR row
PullAddrZIFFER_EXECUTOR_PULL_ADDR, from the sheet
PullServerNameZIFFER_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.

parameterwhat it ison the sheet under
AuditAddrthe audit chain's addressread from this deployment
NotifierAddrthe notifier's addressread from this deployment
ContextAddrthe Context Store's addressread from this deployment
RenewAddrour certificate issuer's addressread from this deployment
ExpectedPolicythe one dNSName admitted on the execute listenerpinned
ExpectedNotifierthe first dNSName admitted on the acknowledgement listenerpinned
ExpectedApprovalthe 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 wrotepinned
AuditServerNamethe pin on the audit legpinned
NotifierServerNamethe pin on the notifier legpinned
ContextServerNamethe pin on the Context Store legpinned
RenewServerNamethe pin on the issuerpinned
ZifferEndpointCidrthe address range those endpoints answer on. A security group rule takes an address and not a name, which is why this exists at allread from this deployment
ZifferDeliveryPortthe port egress is opened to for the delivery endpointread from this deployment
ZifferIssuerPortthe 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 disagreeread 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.

parameterwhat it is
Envyour environment name. It names the roles, the cluster, the service and the log group. Default prod
VpcId / SubnetIdswhere the task runs. Private subnets; the task is given no public address
VpcCidryour VPC range, for the one egress rule that stays inside your account: NFS to the effect log
AwsServicePrefixListIdthe 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
ImageUrithe Executor image. Pin a digest, not a tag: see 8.4
ImageRepositoryArnthe 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
ConfigSyncImageUrithe image that copies your configuration out of S3 before the Executor starts. It runs one command, aws s3 sync, and no script
ConfigBucketName / ConfigPrefixa 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
ConfigKmsKeyArnthe 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 / EffectLogFilesystemArnan EFS file system for the at-most-once effect log
EffectLogAccessPointId / EffectLogAccessPointArnone 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
LedgerSecretArna Secrets Manager secret with a dsn key. Read 8.6 before you plan around it
ExecutorIdZIFFER_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
PullIntervalMsmilliseconds between one poll and the next. Zero is refused; start at 1000
ExecuteListenPort / AckListenPortthe two listeners. Defaults 8443 and 8444, and the only defaults offered here, because nothing reaches either socket
Cpu / Memory / CpuArchitecturesizing; see 8.5
DesiredCountone, and the maximum is one; see 8.5
LogRetentionDayshow long a refusal, a renewal failure and a boot refusal are kept. Default 30
AlarmTopicArnan 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 key

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

statementgrantwhy
WriteItsOwnLogStreamlogs:CreateLogStream, logs:PutLogEvents on one log groupa refusal reaches standard error and nowhere else in your account
ResolveTheLedgerSecretsecretsmanager:GetSecretValue on one secret ARNthe 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
AuthenticateToTheRegistryecr:GetAuthorizationTokenthe image pull. This action has no resource-level support in IAM, so the resource is a star for one named action
PullTheExecutorImageecr:BatchCheckLayerAvailability, ecr:GetDownloadUrlForLayer, ecr:BatchGetImage on one repository ARNthe image pull, from the repository we published for you
ListTheConfigPrefixs3:ListBucket, conditioned on ConfigPrefixthe init container. Scoped by an s3:prefix condition, because a list over the bucket enumerates every object name in it
ReadTheConfigPrefixs3:GetObject under the prefixthe init container reads the tree of 8.2
MountTheEffectLogAccessPointelasticfilesystem:ClientMount, ClientWrite, conditioned on one access point ARNthe effect log. Without the condition the same two actions mount the file system root
DecryptTheConfigObjectskms:Decrypt on one key, via S3 onlyan 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:

alarmwhat it meanswhat to do
ziffer-<env>-customer-executor-not-runningthe task is not there. It refuses at boot rather than coming up degradedread the log group: the first line names the variable, the file or the clause
ziffer-<env>-customer-executor-renewal-failedthe pull leaf is not being renewed. Nothing breaks until it expires, and an expired leaf cannot be renewed: it has to be re-enrolledact inside the day
ziffer-<env>-customer-executor-criticala refusal, with its clause id on the line beside itread 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 nameswhat happened
a ZIFFER_* variableit is missing or malformed. Every value is required and nothing has a default
ZIFFER_EXECUTOR_HOLD_WINDOW or ZIFFER_EXECUTOR_SAMPLE_RATEyou 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/zifferthe 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 refusalthe 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
LimitOutOfBoundsyour 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
HumanAssuranceAbsenta 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 nameyour 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 listthe quintet is incomplete. All five or none, and the refusal names the ones that are missing
the renewal pairit 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, under ZIFFER_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_LOG points 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:

  • ImageUri names 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.
  • EffectLogAccessPointArn is an access point on a file system that outlives the task, and DesiredCount is 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.

On this page