Postern
The open execution and entitlement protocol for packaged AI agents.
A small HTTP contract for running one as a local process, and for checking that whoever is running it is allowed to. Postern fixes the interface, not the agent — anything that can call HTTP can be a client, and anything that can serve HTTP can be an agent, in any language.
A postern is the small gate in a fortification that authorised people pass through. Present proof, pass through, then run.
This is a draft, and nothing in it is stable yet.
Version 0.1 is drafted in the open, deliberately: a specification benefits most from being read while changing it is still free. It is not released — there is no tag to pin to, and until there is, changes land under Unreleased rather than moving the version. Breaking changes will happen before 1.0, and VERSIONING.md says which of them move what. If you are building against it, please open an issue — knowing who depends on what is what lets us avoid breaking you.
Four verbs
An agent that speaks Postern can say what it takes and produce what it returns, over four HTTP endpoints. No SDK is required, and none is planned.
What inputs does this take, what does it return, which credentials does it need, which of its tools cost money.
Run it, give me the result.
Run it, show me as it goes.
Is it healthy, am I still allowed to run it.
Four verbs is a ceiling, not a starting point. Every published verb is a permanent
obligation, and a contract that stays compatible for a decade is worth more than a complete
one that fractures in two years. Extending within the four — new optional
describe fields, new stream event types — is ordinary work and is
welcome.
Run it locally
A container image and a Python package. Both are conveniences around the specification — nothing in Postern requires either, and neither is the protocol.
Sigrix's own runner — the reference implementation. It serves the four verbs on port 8787 for one agent, which it fetches at boot from the distributor that licensed it. One image for every listing; there is no per-listing build.
docker pull sigrix/runner:1.6.0
docker run --rm -p 8787:8787 -e SIGRIX_TOKEN=… sigrix/runner:1.6.0 owner/listing
an agent you unzipped yourself needs no image —
python -m sigrix_runtime.postern
inside the bundle is the same runner.
Points at a running runner and reports which of the three levels it actually meets, and which MUST rules it breaks getting there. It checks over the wire, so it reads a Go or Rust runner as well as a Python one — a test suite rather than an SDK.
pip install postern-conformance
postern-conformance http://127.0.0.1:8787
--execute
is opt-in and the rest is checked through refusals that execute nothing.the package is
postern-conformance; postern on PyPI is an
unrelated project.
Source ↗
Status
What Sigrix serves as a distributor, and whether it is answering. The schema row is the
one worth watching: publishing an $id on sigrix.io is a maintenance
obligation, not a label. Your own runner is not on this list — it runs on your machine,
so nothing here can attest it.
Specification, schemas, examples
Everything normative lives in one document. Everything machine-readable sits beside it.
Changes
Corrections made before the first tagged release. Amending the document in this window is not a version event — there is nothing pinned to protect yet — so they belong to the version being prepared.
- §4.1, §4.1.2, §4.2, §4.4, §2.3
2026-09-06
idempotent_retrymoves fromdescribe.capabilitiestostatus. §4.1 opens "capabilitiesdescribes the agent", and the field falsified it: whether a repeat executes the agent a second time is a fact about the runner serving it, likelevelandlimits. It is moved rather than withdrawn, which thecapabilities.streamingwithdrawal above it makes easy to conflate — that rested on two arguments and only one transfers.streamingwas also a second vocabulary for a fact §3 already stated, so a client acting on it broke a MUST; nothing else states whether a runner honours the header,limits.idempotency_retention_secondsbounds how long the promise lasts rather than whether it is made, and a client reading this one broke no rule. It sits besidelimitsand not inside it,limitscarrying bounds and a boolean being none. §4.1.2 keeps its pairing withwrite_tools— what an agent does that nothing can undo, and whether asking twice does it twice — and now says the two are read from two documents, both answerable at Level 1. A runner still emitting the field undercapabilitiesvalidates, that object being open, and means nothing by it; a client MUST read the answer fromstatus. - §4.2, §4.4, §4.5
2026-09-06
§4.2 says what an
Idempotency-Keydoes before the first execution is answered. The replay promise was keyed on a key already *answered*, so a repeat arriving mid-execution fell outside every sentence in the section — and that is the window a client retries in, since §4.2's own reasoning is that a connection dropping mid-runlooks the same whether the agent was halfway through or writing its last byte. A runner MAY hold the second request and answer it with the first execution's result, bounded bylimits.max_run_seconds; a client MUST NOT assume it will, the posture this section already takes toward the retention window; and §4.5's capacity refusal needed nothing added, being the one503whose retry lands on an answered key. It also states that one keyspace spans both verbs, which the replay rule assumed where it says what a replayed result looks like onstream. - §2, §2.1
2026-09-06
§2.1's envelope binds "a path this specification defines" rather than every non-2xx response. A runner binds a port, and §2 gives Postern's own paths the
/postern/v0/…prefix without scoping §2.1 to them — so read literally the rule reached every other response that port gives, and a request for a favicon was a conformance failure. It binds every request under the prefix, an unimplemented path among them, and nothing outside it, which is what the conformance checker already assumed. - §2.3
2026-09-06
Answering a preflight is responding to it, not admitting the origin. §2.3 pairs a MUST to answer
OPTIONSwith a SHOULD to answer a refused origin204rather than an error status, and left open whether a refusal answers the MUST — a runner could read it as requiring the origin be allowed. What the MUST forbids is leaving the method unhandled: a reset connection and the405a framework gives an unregistered method both reach the browser as a failed preflight, with nothing to tell the page a refusal from a runner that is not running. - §2.1, §4.4, §4.6
2026-09-06
A
424missing_credentialSHOULD carryerror.detail.missing, an array of the environment variable namesdescribedeclares and the runner's environment does not hold. §4.6 makes step 5 a MUST on the grounds that "a424names the variable to set; the500agent_errorthat follows a run started without it names nothing" — and until now onlymessagedid, which §2.1 makes prose a client shows a user rather than acts on. So the argument for the MUST was sound and the field it turns on did not exist. It is an array rather than one name because a runner reading its own environment againstdescribe's list meets every unset one at once, and because that is the cardinalitystatus.credentials.missingalready publishes — a scalar here would be one fact in two shapes. §4.5'serror.detail.max_run_secondsis already defined as "the same integerstatusdeclares", so echoingstatusis the established shape for adetailmember rather than a new one.examples/error.jsoncarried this member asdetail.env, a spelling nothing in this document defined. - §4.4
2026-09-06
status.credentialsis defined, which §4.4 showed in its own example and never said.satisfiedis true when every environment variabledescribedeclares (§4.1.3) is set, andmissingnames those that are not; the block is OPTIONAL. §4.6 already relied on it — it distinguishes publishing the satisfied set from performing the step-5 check, and cites §4.4 for an optionality §4.4 never stated — so this is the definition that citation pointed at. Every other member §4.4 carries it names:limits,updateand each ofentitlement's.credentialswas the one it drew and left unlabelled. - §4.3, §4.5
2026-09-05
§4.3 states that
donerepeatsstart'srun_id, and that a runner MUST NOT report two identifiers for one run. The rule was asserted instream-event.schema.json— whosestartdescription has always said it carries *"therun_idthat thedonepayload repeats"* — and demonstrated inexamples/stream.txt, where both name01JD8XW2Q9. The prose said only thatstartcarriesrun_id, so the correlation was documented everywhere except the document that binds it. It spans two events, which is why neither schema can reach it and why it is asserted the way §4.3'sdeltaconcatenation rule already is. - §1.5, §2.2, §4.1.4, §4.4, §5.1
2026-09-05
status.agent,status.entitlementanddescribe.outputare REQUIRED, which the sections reasoning about them assumed and the schemas did not carry. Every top-level member §4.4 marks, it marks OPTIONAL —limits,update— and the two it marks REQUIRED are conditional members insideentitlement, soagentandentitlementthemselves were unmarked rather than decided, andstatus.agentrequired nothing at all inside it, admitting{"agent": {}}whiledescriberequiredid,nameandversion. Each omission cost something stated elsewhere: §5.1 MUSTs a runner with no distributor to reportentitlement.stateasnot_required, and saying nothing is indistinguishable from a runner that has not implemented entitlement; §1.5's "one agent, one identifier, spelled one way in all four places" is false of astatusthat names none, and §2.2's identity rule cannot be checked against a runner that omits it — such a runner is not caught disagreeing with its owndescribe, it cannot be asked;runandstreamboth MUST carry anoutputwith atype, and §4.1'sdescribeis the only place a client can learn that type before it asks. Onlyagent.idis required withinstatus.agent. No example, fenced block or reference-runner path in this repository emitted a document any of this now refuses. - §1.5, §2.2
2026-09-05
§2.2 says no runner *path* carries an agent identifier, where it used to say none of the four verbs did. The narrower claim was false as written —
describecarriesagent.idand §1.5 says so — and only the addressing reading supports the conclusion drawn from it, that a client MAY treat a runner's port as its agent's address. The next sentence, contrasting with distributor paths that *address* an agent by identifier, always meant the same thing. - §4.1.1, §4.2
2026-09-05
describe.schema.jsonenforces the pairing §4.1.1 already stated between an input'stypeand itsdefault. The sentence fixing the value space — *text and select carry a string, number carries a number* — was prose only, so{"type": "number", "default": "not a number"}validated cleanly, as did a numeric default on a text input. Nothing in the repository violated it, which is why it went unseen.nullstays legal under every type, since it is what says no value was supplied. The two new branches are keyed positively on the types they constrain, never negatively on the ones they do not: this section anticipates a fourth type, and a branch reading *anything that is notnumber* would constrain it the moment it arrived.run-request.schema.jsondeliberately does not follow — the values there sit in a different document from the declarations that type them, so it admits the union of the three value spaces and §4.2 leaves the per-input check to the runner. - §1.5, §4.1.1, §4.2, §5.3
2026-09-05
§4.1.1 states the grammar an input
keyhas to satisfy.describe.schema.jsonhas enforced[A-Za-z0-9_.-]+from the first commit and §4.1.1 said only that a declaration MUST carry the member, so a key with a space in it was refused with no sentence to cite — and nothing asserted the pattern either way, which byvalidate.py's own doctrine made it a rule deletable without anything going red. The reason is now stated with it: a client renders a key as the *name* of a thing andlabelcarries the human-facing name, so the key does not have to, and excluding whitespace and non-ASCII keeps two keys that look alike alike. Its length stays unbounded, unlike an agent identifier's, because a key crosses no trust boundary — it travels between one runner and a client already talking to it, never to a distributor. The grammar binds the declaration and reachesrunthrough §4.2's "map keyed bydescribe's input keys" rather than being restated there. - §2.1, §4.1.3
2026-09-05
§4.1.3 states what a
credentials[]entry carries, and what its closure is for.describe.schema.jsonhas always closed that object, and it was the only statement of the entry's shape anywhere — §4.1.3 described the rule and never the record. The closure is not a list of today's members: §4.1.3's own claim that there is *nowhere in the protocol for a secret to travel* is true only while the object is closed, since an open one would makevaluebesideenvschema-valid, along with every other spelling of the leak the section forbids. It catches the structural half and not the whole of it — a value written intopurposeis a conforming shape carrying a nonconformant string, which only reading the field finds. The cost is named too: a member added here later is additive for runners and for any client that does not use an emit-side schema as an acceptance filter, and breaks only one that does. It is the only closed object in these schemas outsideerror.schema.json's root, whichschemas/README.mdnow records as a fourth place the emit/accept difference is load-bearing rather than as three. - §4.1.3, §4.4, §4.6
2026-09-05
A runner MUST perform the credential check, not merely order it. §4.6 placed the environment check last and gave
missing_credentialits producing rule, but bound only the sequence — so a runner that never inspected its environment never reached the condition, started the agent, and answeredagent_error. Two conforming runners therefore answered one request differently, a424naming the variable to set against a500naming nothing, and a client could not tell which it held. It is a MUST rather than a SHOULD because every runner can perform it:describedeclares credentials by environment variable name (§4.1.3) and every runner answersdescribe(§4.1), so the list is already in hand.status.credentialsstaying OPTIONAL is not the same obligation — it governs publishing the satisfied set, not checking one before a run. - §2.1, §2.3, §5.5, §7
2026-09-02
Added
unauthorized(401), the answer a runner gives when it requires inbound authentication of its own and a request does not satisfy it (§2.1, §7). §7 has always obliged a runner binding a non-loopback interface to authenticate its callers while specifying no scheme for it, which left the *refusal* undefined too — so such a runner had to answer either a code meaning something else or one outside §2.1's table, and a client met a second error shape at exactly the deployment where it had least context. The scheme stays unspecified; only the refusal is fixed. §5.5's "Postern defines no401" is narrowed to the distributor it was always about: its reasoning is that a401confirms a token was once real and so sorts guesses into piles, which needs a catalogue to enumerate, and a runner serving one agent behind an identifier-free path space (§2.2) has none. §2.3 gainsAuthorizationin the preflight'sAccess-Control-Allow-Headersfor such a runner — a browser cannot send a header its preflight did not admit, so without it a page is the one client kind that could never authenticate — and a runner requiring nothing SHOULD NOT name it, since the header is off the safelist and admitting it preflights adescribethat would otherwise go without one. - §2.1, §4.2, §4.6
2026-09-01
A runner's refusals are ordered: it decides what the request says before it inspects what it holds, so a
runthat both omits arequiredinput and meets a runner missing a credential is answeredbad_requestrather thanmissing_credential(§4.6). Both were correct under the previous text and nothing chose between them, which left §4.2's MUST untestable on any runner whose environment was incomplete — the ordinary state of one being brought up — and made a malformed request's answer depend on the operator's deployment.missing_credentialgains the producing rule it never had: it was the only code in §2.1's table that no section required, defined and never asked for. §4.5's capacity refusal is deliberately left unordered. - §2.1
2026-08-16
A client MUST tolerate an error
codeit does not recognise, so that adding a code stays an additive change. - §2.1, §4.3
2026-08-16
Added
not_implemented(501). A Level 2 runner answersstreamwith it rather than withunavailable, which is now 503 only. - §2.1, §5.6
2026-08-16
Added
withdrawn(410), so the withdrawn-agent response in §5.6 has a code and can be constructed at all. - §5.3, §5.4
2026-08-16
The entitlement check response now carries
checked_at, defined as the moment the distributor last consulted the authority rather than the moment it answered. A runner propagates it unchanged and MUST NOT re-stamp it, so the distributor's cache and the runner's cache share one deadline instead of stacking. - §4.4
2026-08-16
entitlement.checked_atis now REQUIRED instatuswhen the entitlement state isactiveorrevoked. - §4.3
2026-08-16
The
deltareconstruction rule applies only when adeltais emitted, so a Level 3 runner that cannot produce incremental text stays conformant by emitting none. - §2.1, §2.2
2026-08-18
A runner serves exactly one agent, stated normatively rather than left to be inferred from the absence of an identifier in its paths.
not_found's "no such agent" meaning is distributor-side only; on a runner the code can only mean an unimplemented path. Each code in the §2.1 table now says which side emits it. - §2.1
2026-08-18
The error envelope's root is closed by design — nothing sits beside
error, so the envelope has one extension point rather than two. The schema already asserted this; §2.1 now states it, with the reason and with the fact that it constrains what an implementation emits rather than licensing a client to reject what it receives. - §5.6
2026-08-18
The §5.6
410body carries the date access ends aserror.detail.access_ends_at. The closed root leavesdetailas the only place it can go, and the specification previously left it unplaced. - §2
2026-08-16
The subprocess discovery line is
POSTERN_PORT=<port>, replacing the mixed-case form. - §8
2026-08-16
Removed
verificationfrom theorg.sigrixmember list. - §3, §4.2, §4.3
2026-08-19
A runner answers *any* verb above its declared level with
501andnot_implemented. The rule was previously stated only for a Level 2 runner asked tostream, leaving a Level 1 runner asked torunwith no defined answer; it now sits in §3, so it also covers any level added later. - §4.1.1
2026-08-19
Narrowed input values to what the three declared types can produce.
run'sinputsmap and an input'sdefaultno longer admit a boolean, which none oftext,numberorselectyields. Adding a fourth type later is additive; withdrawing a value shape a runner had relied on would not be. - §4.2
2026-08-19
Removed
statusfrom therunresponse. Its only legal value wasok, because §2.1 routes every failure through a non-2xx error envelope, and the partial-result case it might have grown into cannot be carried by a value an older client would read as a complete result. - §2.1, §5.3, §5.5, §7
2026-08-19
§5.5's indistinguishability rule covers token state, not only agents. An unknown, revoked, or superseded token answers
404withnot_found, the same as a valid token presented for an agent the buyer may not have, and Postern defines no401— a status meaning "authenticate and try again" would confirm the token was once real. §5.3's success rule gains the failure branch it presupposed, and §7's "stop resolving" now names the answer it stops with. - §4.4, §5.4
2026-08-19
entitlement.stale_after_secondsis now REQUIRED forrevokedas well asactive, matchingchecked_at: it is required wherever a check actually happened. Without it a runner held a timestamp and no deadline, so §5.4's re-check rule could not be evaluated for arevokedanswer and the restoration §5.4 obliges a distributor to support could never be observed. - §4.1, §4.4
2026-08-19
agent_idhas a grammar: two parts of lowercase ASCII alphanumerics,-and., joined by one/, bounded at 128 characters and compared octet-for-octet with no folding or normalisation of any kind (§1.5). It was previously only a non-empty string, and the canonicalacme/market-research-crewdid not fit the single path segment the distributor endpoints gave it. It now occupies two segments and is never percent-encoded — the grammar admits no character a path requires encoding for — and a string that fills two segments without matching the grammar is answered400, an answer a distributor MUST be able to produce without consulting its catalogue, which is why it does not weaken §5.5 (§5.3.1, §5.6). The identifier carries no listing type, so a distributor dispatching on one resolves it itself and MUST test each branch out of band: §5.5 makes a missing branch indistinguishable from a correct refusal, so no client will ever report it (§5.5).agent.idcarries the pattern and the bound indescribe.schema.jsonandstatus.schema.json. - §5.3
2026-08-19
The entitlement check has a schema, and its response carries
posternlike every other success payload in the protocol. It was the only one without a version marker, and a distributor's version is inferable from nothing else — VERSIONING.md forbids reading it off the path prefix. Freezing the shape settled two things the prose had left loose:stale_after_secondsis sent whether or not the distributor caches, because §5.4's re-check deadline and §4.4'sstatusreport both need it and neither is conditional on a cache existing; andagent_idechoes the identifier the request addressed, octet-for-octet, so a mismatch is a failed check rather than something to reconcile. Thevalidate.pyskip over the §5.3 block is gone with it — that payload was checked by nothing until now. - §5.6
2026-08-19
Digest: sha-256=<base64>becomesRepr-Digest: sha-256=:<base64>:on a bundle response. RFC 3230 was obsoleted by RFC 9530 before this specification shipped, and the replacement is a structured field, so the colons are syntax rather than decoration.Repr-Digestrather thanContent-Digestbecause a client verifies the bundle it keeps, not the bytes of one hop. - §4.4, §5.3, §5.4, §5.7, §8
2026-08-19
A runner has defined behaviour when the distributor cannot be reached (§5.7). The check response declares
grace_secondsbesidestale_after_seconds, and a runner whose answer has expired with nothing answering keeps running untilchecked_at + stale_after_seconds + grace_seconds, reportingunknown— the state §4.4 has always listed and nothing in §5 produced. The honest upper bound in §5.4 is now the sum of the two rather than the first alone, and is stated as such; both terms are the distributor's own, so it can evaluate the sum before publishing either.0is a valid grace and means *stop at the window*, so strictness is declared rather than inferred from an absent field. §8 puts Sigrix's at 86400. - §5.4, §5.7
2026-08-19
§5.4's rule against persisting an
activeanswer across a restart is replaced. A runner MAY persist an answer, provided it persistschecked_atwith it and evaluates the deadlines against that value on load; a restart yields no fresh window. The old rule was written when the check returned no timestamp at all, so a persisted answer had no trustworthy expiry and discarding it was the only bound available. With the anchor returned and propagated unchanged (§5.3), discarding shortens nothing — a runner that can reach the distributor re-checks anyway — and costs the case §5.7 exists for, where a machine reboots with no network and cannot tell an entitlement it held five minutes ago from one it never had. - §5.7
2026-08-19
A
404from the check is an answer rather than an outage: no grace applies, the runner stops at once, reportsrevoked, and answersrunandstreamwith403not_entitled. It reportsrevokedeven though §5.5 stops it distinguishing a withdrawn entitlement from one that never existed or a token that no longer resolves — what the three have in common is all a client can act on. A runner that has never completed a check does not run at all, reportsunknownwith nochecked_at, and answers503unavailable. The rule under both: unreachable answersunavailable, refused answersnot_entitled. - §2.3, §7
2026-08-22
Browser clients have a defined answer: a runner MUST answer the
OPTIONSpreflight onrunandstream, and the origin policy behind it is the operator's, defaulting to refusal rather than toAccess-Control-Allow-Origin: *. The specification named a web UI as a client kind and said nothing about CORS, so a fully conforming runner could be unreachable from one — while the obvious remedy, a wildcard, would handrunand itswrite_toolsto every page the user visits. A runner MUST now also reject arunorstreambody whoseContent-Typeis notapplication/json:application/jsonis what makes the request preflight at all, and a runner acceptingtext/plainexecutes the agent for any origin without one, which is the whole of the preceding rule undone. - §4.2, §4.3, §4.5
2026-08-22
A run in flight has a defined life (§4.5). A runner SHOULD abort the agent when the client disconnects, on
runandstreamalike, and MUST NOT deliver an abandoned run's output anywhere else — there being no callback and no verb that takes arun_id, which is also why an abort cannot be reported and a reopenedstreamis a new run rather than a resumption. An abort is not a rollback: §4.1.2'swrite_toolsname things that may already have happened, and a retry without anIdempotency-Keybuys the work twice. Previously nothing said whether closing a laptop lid stopped an agent from spending money. - §2.1, §4.4, §4.5
2026-08-22
Added
run_timeout(504), and with it a runner's right to impose a maximum run duration.agent_errorandunavailableboth nearly fit and both mislead — one reports a working agent as broken, the other invites a retry into the same deadline. A runner imposing a limit MUST declare it asstatus.limits.max_run_secondsand MUST NOT declare one longer than it can enforce, and the refusal SHOULD carry it aserror.detail.max_run_seconds. - §4.4, §4.5
2026-08-22
Concurrency is the runner's to decide and discoverable rather than assumed: it MAY refuse an overlapping run with
503unavailable, needing no new code because the client's move is the one that code already asks for, and SHOULD declarestatus.limits.max_concurrent_runs.status.state: "running"observes that a run is in flight and promises nothing about admission — a client MUST be ready for503whateverstatuslast said, since the slot can go elsewhere between the two calls. - §2.1, §4.1.1, §4.1.4
2026-08-22
outputhas a section of its own (§4.1.4).textis the v0 output type by decision rather than by accident of the examples, matching what §4.1.1 already said for inputs — and an unrecognisedoutput.typenow has a receive-side rule, which is the part that changes the contract rather than recording it. It is deliberately not the rule the other four extensible surfaces use: an errorcode, astreamevent name, an inputtypeand avalidationmember are all things a client may ignore, andoutput.typeis what says how to readvalue, so ignoring it misreads a known rather than tolerating an unknown. A client MUST NOT presentvalueas text, MUST NOT report the run as failed — a200it cannot render is a run that succeeded — and SHOULD name the type it was given. The rule has to exist before a second output type can, or the addition breaks every client written against the closed set: the ordering the error-code enum already paid for. - §4.3
2026-08-22
stream's event payloads have schemas, and its rules about them are stated rather than implied by a table cell. Astepcarries at leastnameandstatus;latency_msis an elapsed time, so it is reported onfinishedand a runner MUST NOT emit it on astartedstep, where there is nothing yet to measure — a client receiving one anyway ignores it rather than rejecting the event.stream-event.schema.jsoncovers the three payloads this specification defines itself;doneanderrorcarry bodies §4.2 and §2.1 already define, and the SSE framing spans events, so neither is expressible there. - §7
2026-08-22
The plaintext-token prohibition has a loopback exception, on both halves: a distributor reachable only on loopback may serve plaintext, and a runner may send its token when the peer address is loopback — the one case where the network the TLS rule exists to protect is not there. The condition is the address connected to rather than the hostname configured, because a name is resolved by something the runner does not control, and resolving before connecting leaves a gap between the two answers. A runner SHOULD say when it takes the exception, to its operator rather than in
status. - §4.1.4, §4.3, §4.4
2026-08-23
output.typegainsbytes, for an agent whose result is a file rather than prose.valuecarries the artifact base64-encoded and stays a JSON string, so no envelope changes shape;media_typeis REQUIRED beside it and is an open RFC 6838 string, because an enum would need a specification revision per format. Abytesrun emits nodelta— §4.3's invariant is text-shaped, and base64 fragments would satisfy it while giving a client nothing but the encoding to print — so it reports progress withstepinstead.describe.output.examplestays text-only, and a runner bounding what it returns declareslimits.max_output_bytesinstatus, measured before base64. Additive: a client written against §4.1.4's receive-side rule survives it. - §5.7.3, §5.7.4
2026-08-23
A runner whose first-ever check answers
404reportsrevokedwith its own re-check cadence asstale_after_seconds. That fallback was already the rule, but reachable only by reading "its own re-check cadence" as the field. The clause that did name the field attached a SHOULD — reuse the distributor's last value — which a first check cannot satisfy, so the one case a plain misconfiguration produces was the one left unstated, and the conformant reading was the harder of the two to find. §5.7.4 also now answers whether a client can tell a runner-supplied number from a distributor's: it cannot, and does not need to, because the bound protects the runner's own operator underrevokedwhere it protects the distributor underactive, and only the second party gains by overstating it. §5.7.3 says that a404is a completed check and so not its case, andstatus.schema.json's two descriptions carry the same exception. - §4.3
2026-08-29
The
deltaconcatenation invariant gains the receive-side rule it was missing: where the accumulated deltas anddone'soutput.valuedisagree, a client SHOULD preferdone, and MUST NOT report the run as having failed on that ground. It was the one place a client could be surprised with nothing stated for it — every other one has a rule, and §4.1.4's is the near neighbour, separating a run that succeeded from a client that rendered it wrongly. A SHOULD rather than a MUST because a client writing deltas to standard output has already emitted them. The invariant itself is unchanged and still binds the runner. - §2.3, §4.1.2, §4.2, §4.5
2026-08-29
A runner that honours an
Idempotency-Keydeclares it, ascapabilities.idempotent_retryindescribe. §2.3 already varied a browser client's preflight by whether the runner honours the header, so the one answer was being advertised in a CORS header to a client with no protocol-level way to ask for it, and §4.5 had since made being charged twice a documented outcome of an ordinary disconnect. Declaring it forced fixing what honouring means, which the specification had never said: a repeat key MUST NOT execute the agent again and MUST be answered with the result of the first execution, a produced error included, while a request refused before the agent ran binds no key. Absent andfalseread identically, so a client written before the field is right about every runner that had not made the promise. §4.5 stops naming the key as the remedy for the disconnect it can do least about — the run it would deduplicate against was aborted and its output discarded — and says that answering a retry carrying that run's key is not the late delivery its discard rule forbids. A runner declaring the field MUST admit the header in its preflight, or the promise holds for every client kind except the browser. - §4.2
2026-08-31
run_idis unique per execution rather than per response, so a replayed idempotent answer carries therun_idof the execution it replays. The uniqueness MUST predates the replay rule by some distance and the two were never read together: a strict reader of the older sentence is pushed toward minting a fresh identifier for the replay, which names an execution that never ran and so has no line in any log — defeating the correlation the same sentence's SHOULD exists for. Nothing an implementer builds under either reading fails, which is why this needed saying rather than leaving to sense. - §2.1, §4.2, §4.4
2026-08-31
An
Idempotency-Keyidentifies a request rather than a caller, andidempotency_conflict(409) is what a runner answers when one is presented with differentinputs. #89 keyed the replay rule on the header alone, which answers the second request with a result computed for inputs the caller never sent — at200, in a valid envelope, undetectable on either side. That is the failure §4.1.4 argues hardest against, reached through a header a client adds in order to be careful, and worst on exactly the agent §4.1.2 warns about.bad_requestnearly fits and misleads: nothing in the body needs fixing, and400already answers a failedvalidation, so a client could not tell "your inputs are wrong" from "that key is spoken for" — refusals whose remedies are opposite. Inputs are compared by value on the decoded map, so re-serialising a request cannot manufacture a conflict, and a409executes nothing and so binds no key. Retention is the runner's to choose and a client MUST NOT assume a window, since a runner forgetting after a second satisfies every word of the replay rule; a runner that can state one SHOULD declarestatus.limits.idempotency_retention_seconds, the one member oflimitsbounding a promise rather than a run. - §3, §4.1
2026-08-31
capabilities.streamingis withdrawn. It appeared in §4.1's example and indescribe.schema.json— the one property there carrying nodescription— and no prose ever defined it, which leftcapabilitiesdocumenting one of its two booleans once #89 gaveidempotent_retrya treatment. Defining it was the alternative and would have frozen a contradiction: §3 makeslevelinstatusthe authority and forbids assuming a level read from anywhere else, so a client acting onstreamingbreaks a MUST, and a client that may not act on a field has decoration. Nothing bound the two, so{"level": 2}beside{"streaming": true}was a payload no rule refused. This is the same refusal of a second vocabulary that removedrun'sstatusfield and kept0out ofmax_concurrent_runs, and pre-1.0 is the only cheap moment for it — VERSIONING.md's additive-only rule binds after. It breaks no runner:capabilitiesis open, so one still emitting the field validates unchanged and merely means nothing by it. §4.1 now sayscapabilitiesdescribes the agent andlevelthe deployment, which is the line that keeps the field from being re-proposed. The conformance checker'sstreaming/levelagreement warning goes with it — that rule was the tool's own inference from §3, with no sentence to cite. - §4.4, §8
2026-09-02
statusgains an OPTIONALupdateblock, reporting what a runner learned when it asked its distributor whether a newer version of the agent exists: astateofnot_required,unreachable,currentorupdate_available, with the running version ascurrentand the reported one aslatest. It is present only where a check ran, so a runner configured for none omits it — a different fact from a check that ran and found no distributor. An unreachable check is explicitly not a failure: a runner MUST NOT refuse to start or to run because it could not tell, which is §5.7's posture for an entitlement it cannot re-check, and a client readsunreachableas *not known* rather than as *out of date*. It sits instatusrather thandescribefor the reasonlimitsdoes — the version a runner happens to be running, against a distributor it happens to be configured for, is a fact about the deployment. No distributor path is added: §5 fixes the two a runner must call to serve its agent at all, a version answer is neither, and a runner that never asks conforms fully — so howlatestis obtained is the distributor's to publish, and §8 records Sigrix's, unauthenticated because it names no buyer. - §4.1.4
2026-09-02
output.media_typeis bounded by the grammar it always claimed. Both schemas carried a pattern that was wrong in each direction at once: it refused every experimental type,x-custom/fooamong them, because the type half admitted no-, while accepting a subtype beginning!, which §4.2 of RFC 6838 forbids. Both halves are that RFC'srestricted-namenow. §4.1.4 also states what the pattern used to imply by accident — a runner emits the field in lower case, so two runners naming one format agree octet-for-octet, and a client MUST NOT reject a response over its case. - §4.6, §5.7.4
2026-09-02
§4.6 places the entitlement refusals, which it previously left out of its sequence entirely. They are step 2 — behind the level check, ahead of the media type, the inputs and the environment — so a runner that has been told no answers that rather than a
400naming something the caller could fix, which §5.7.4 already forbids it to imply. The general sentence is narrowed to the steps it was always about: *what the request says before what the runner holds* governs steps 3 to 5, and an entitlement is neither. Both orders conformed before, so a conformance checker could assert neither. - §4.4, §8
2026-09-02
version.schema.jsonfixes the shape of a version answer —postern, theagent_idechoed octet-for-octet, and aversionstring compared for equality only, with no ordering implied. It is the source of §4.4'sstatus.update.latest, and the first schema here whose *path* this specification does not define: §5 fixes the two distributor paths a runner must call to serve its agent at all, a version answer is neither, and §8 records where Sigrix serves it. Fixing the shape without fixing the path is the point — a second distributor offering the same answer answers it the same way, and a runner reads both with one parser. It joinsentitlement.schema.jsonas a distributor payload the conformance checker does not bundle, since a runner never emits one.
Read it while changing it is still free.
Issues and pull requests are welcome. CONTRIBUTING.md is worth reading first — in particular the part about how quickly you can expect an answer, which is written to be accurate rather than flattering. If you are implementing Postern in another language, say so on an issue; that is the most useful thing anyone can tell us right now.