This document exists because the cost of changing certain surfaces goes from zero to real on the day the number lands. It states which those are, what the promise on each one is, and — for the deliberate break pass tracked by #14 — what was broken while it was still free and what was deliberately left alone.
A surface not listed here is not covered. That is the default, and it is meant to be: a promise nobody wrote down is one nobody can keep.
Neither is a feature behind [experimental]. Unattended review mode ships off
and refuses to start until a deployment enables it, and its behaviour, its
output, and its exit codes may change in a minor release. That is what the
switch is for: enabling one accepts a narrower promise than the rest of this
binary makes, and nothing else loses its usual contract.
The wire contract between a daemon and the control plane. A deployed plane and a deployed fleet have to agree on it, and every field anyone regrets needs a migration path.
Promised for 1.x: no field is removed or renamed, no field becomes
required that was not, and no @require predicate is tightened in a way that
refuses a write 1.0 accepted. New optional fields, new entities, and new
@access grants are allowed.
Not promised: the Cedar policy bundle. A forbid may be added at any time
in a patch release. Narrowing who may do something is a security fix, and
tying it to a major version would mean shipping known-wrong authorization on
purpose.
Three parts: the packet on disk (agent/src/enrollment/record.rs), the
redemption request body (agent/src/enrollment/redeem.rs), and the
AgentInstall record the plane keeps.
Promised for 1.x: a packet written by 1.0 is redeemable by any 1.x daemon and any 1.x plane.
This surface is the least forgiving of the three, and it is worth being
explicit about why. A shipped install cannot re-enroll without spending
another grant, so a format change strands machines rather than inconveniencing
them. Packet also carries #[serde(deny_unknown_fields)], which means the
freeze runs in both directions: a later daemon reading an unspent 1.0 packet
must still accept every field 1.0 wrote. Unspent packets sitting in a
provisioning share are exactly the population a format change hurts.
Two shipped editor extensions and every operator's config file.
Promised for 1.x:
- The JSON-RPC refusal codes -32014 through -32021 keep their meanings. A new refusal takes a new code.
- Process exit codes keep their meanings:
2refused to start,3a rejection,4an audit verify mismatch,1a malfunction. _garrison/statusonly gains fields. Every block it can omit today it may still omit, so a client that reads a field must keep treating absence as an answer rather than an error.- A
garrison.tomlvalid under 1.0 stays valid. Keys are added, not removed or repurposed, and an unknown key is a hard error by design — which is why removing one is a break rather than a tidy-up.
Changed in 1.3, within the promise rather than against it:
_garrison/complete now crosses the admission gates, so an install refused
from running a turn is refused a completion too. Its request and response
shapes are untouched and it still answers a refusal the way it answers every
other failure: an empty completion, never an error. A client written against
1.0 therefore needs no change, because "no suggestion" was always a reply it
had to handle. What changed is how often it is the true one, which is the
point: the alternative was a client learning about a revoked seat from a
suggestion it should never have been given.
The entry format, the hash pre-image, the chain, and the .trail sidecar.
Promised for 1.x: a trail written by 1.0 verifies under any 1.x reader,
with the same head hash. This is the one an auditor actually depends on, so it
is the one pinned by a test rather than by intent: agent/tests/audit_fixture.rs
holds a trail a real daemon wrote and fails if today's code disagrees with it
about a single byte. Regenerating that fixture is the visible cost of breaking
this promise, and it is deliberately awkward.
Added in 1.2, without moving a byte: the trail now also holds one entry per
attempted turn, not only per tool invocation. A turn entry is a new shape in
the same chain, distinguished by an entry_kind field that invocation entries
omit — and omission is the whole trick. Every field a turn entry adds, and the
discriminator itself, is absent from an invocation's serialized form and from
its hash pre-image, so a trail written by 1.0 still hashes to exactly what it
hashed to. agent/tests/audit_fixture.rs is what holds that claim honest: it
verifies a trail a 1.0 daemon wrote, unchanged.
Two later additions extend that shape on the same terms. A turn Garrison's own
admission gates refuse is sealed as a turn entry carrying the stable reason it
was refused. And a turn steered by AGENTS.md project instructions names them
in context_sources, by scope, path, and content hash, never by content. Both
fields serialize away entirely when they are empty, so they are absent from the
pre-image of every entry that predates them, and the fixture test proved that
for each addition rather than assuming it.
A reader that does not know about turn entries will still verify the chain,
because verification is over the sealed bytes. It will simply see entries whose
tool fields are absent. Code that reads those fields must treat them as
optional; in this repo, garrison_wire::audit::kind is the one place that
decides which kind an entry is, and an entry that names no kind is a tool call.
Note what the chain does not promise, and never did: a prefix of a valid chain is itself a valid chain, so truncation of the most recent entries is undetectable from the file alone. That is why the trail ships off the box.
Garrison compiles four first-party crates in statically. No dependency is resolved at runtime, so a new release of any of them does nothing to an already-shipped Garrison binary. A fix reaches an operator through a rebuild and a redeploy, never on its own. What the pin policy decides is only whether the next rebuild picks a change up silently.
| Crate | Requirement | Why |
|---|---|---|
acton-ai |
=0.37.0 (exact) |
It is 0.x, and garrison-wire re-exports its audit types as Garrison's own wire contract. An unreviewed bump would silently redefine what an audit entry is, which is surface 4 above. Exact makes it a reviewed commit — as 0.35 → 0.36 was, when it turned the tool fields optional to make room for turn entries, and as 0.36 → 0.37 was, when it added a public append path for host refusals and a context_sources field skipped when empty. The fixture test is what proved no existing byte moved either time. |
acton-service |
0.39 |
Plane-side only (hooks-service). For a 0.x crate, caret already caps below 0.40, so caret and tilde are the same requirement here. |
acton-service-client |
0.1.2 |
Ships inside the agent binary, so a fix here needs an agent release, not just a plane redeploy. |
acton-reactive |
9.2.1 |
Post-1.0 semver, patch float. |
Cargo.lock is committed, and task test and task check pass --locked, so
even the caret requirements are pinned until somebody runs cargo update.
The blast radius worth stating plainly: acton-service is the code that mints
install tokens. A token-forgery fix there is an enrollment-protocol emergency
for the control plane, and not an emergency for the agent binary on an
operator's laptop.
Every candidate was investigated against the code and then reviewed by a second pass whose brief was to refute it. What follows is the disposition of each: what was broken while it was still free, what was left alone, and why.
agent/ and wire/ both carried acton-ai as a path dependency to
../../acton-ai, which resolves through a .gitignored directory. A clean
clone could not build. The version = "0.35.0" beside the path was
decorative; Cargo.lock recorded acton-ai and acton-ai-macros with no
source and no checksum, so the audit types underpinning the tamper-evident
claim came from whatever sat in a sibling checkout on the build machine.
Now =0.35.0 from the registry, with the checksum in the lockfile. The
published crate was verified byte-identical to the local checkout before the
switch. Also fixed: acton-reactive = "9.1.0" in agent/, which was
unsatisfiable — published acton-ai 0.35.0 requires 9.2, so 9.1.0 could never
have been what resolved.
The plane validates iss against exactly one configured issuer. Four surfaces
still described an abandoned two-issuer model, two of them copy-pasteable:
schemas/credential.schema and docs/control-plane.md both claimed that
presenting an enrollment artifact as a session token is "a 401 before
authorization runs at all", and both gave a mint command
(--issuer garrison-enrollment --roles '') that produces an artifact which
401s at the middleware and would be 403'd on Redemption even if it got
through. hooks-service defaulted garrison.issuer to the second issuer, so a
deployment that omitted the key booted clean and then refused every enrollment
with a message naming the wrong side of the mismatch.
What actually separates the two token families is the role set: an artifact
carries enrollee, which grants write on Redemption and nothing else
anywhere in the bundle. Both documents now say so, and the mint commands now
match the only path that has ever worked — including --tenant-chain, without
which _tenant is never injected and the resulting rows are invisible to the
hooks service that has to adjudicate them.
The packet carried token_id in the clear beside the artifact because a
v4.local artifact is symmetric: the daemon cannot read its own sub, and the
plane's @require compared the request body against it. Switching to
v4.public was never Garrison's change to make.
The route taken instead is this repository's own idiom. Redemption.token_id
lost its required and its @require; the before_validate hook now fills it
from the authenticated principal's subject claim, exactly as it already fills
organization and as AuditEvent.operator is filled. Packet is the artifact
and nothing else, and the redemption body no longer carries the field at all.
This is stronger than what it replaced, not weaker. A rule comparing a
submitted token_id against principal.sub can only refuse a mismatch; a
client with no field to put a token id in cannot attempt one. What holds it
closed is the binding's required = true: an unreachable hook fails the
request rather than persisting a row that names no grant. Both refusal paths
in the hook stamp the field too, so a persisted refusal still says which grant
an unknown machine presented — the one fact that row exists to carry.
The route's open question was settled empirically before it was taken. It
depended on the hook's user_id being populated for an enrollee bearer,
which has no console User row. It is: probing the hook during the live
redemption in hooks-service/tests/directory_sync.rs — a real enrollee
artifact, posted to a real plane in a container — yielded
user_id = Some("tok_alice_1") against the token_id the @require was
comparing. That test now posts a body with no token_id in it and asserts the
hook filled the field on both the acceptance and the refusal.
The second decision the route implied was taken with it.
policies/custom/credential-lifecycle.cedar gained
garrison.redemption.append_only, a forbid on UpdateRedemption. Without
it the change would have removed a check on the update path without replacing
one: write covers update, and the hook no-ops on anything but a create.
Redemption rows are the evidence a security officer reads when an unknown
machine presents a revoked grant, so they are append-only now, like the audit
trail and the credential rows beside them.
Taken now because it could only be taken now. deny_unknown_fields means a
one-field daemon hard-fails on any unspent two-field packet, naming the file.
That is the right failure and it is a fleet-wide one, which is why it belongs
before the number lands rather than after.
Already done, in commit 8f9535d. The field is credential_kind on both
schemas and the concept has one name.
The proposal was to turn an untenanted enrollment row from an invisible write
into a 422. It would instead have refused every enrollment, accepted and
refused alike. Required-field validation runs against the raw request body
before tenant injection, before the rule phases, and before any hook is dialed;
the daemon deliberately does not send organization, because the hook resolves
it. schemas/audit.schema already records this invariant on the sibling field:
"a field the client is meant not to send cannot also be one the client must
send."
It would also have weakened the property it aimed to protect. required is a
presence check, so satisfying it means the daemon starts sending an
organization key — and hook merges leave fields they do not set untouched, so
a client-asserted tenant would survive into a persisted refusal. That inverts
enrollment.schema's own rule: a field the client cannot know is a field the
client cannot lie about.
The real defect behind the symptom was the documented mint command, which
omitted --tenant-chain. That is fixed above.
adjudicate compared the stored column against the plane's configured issuer,
and its doc comment read as though that refused a token minted for some other
purpose against the same key. It could not. The plane validates iss against
exactly one issuer, so every artifact reaching the hook was minted under that
one, and an attacker holding the signing key mints under it too. What the check
caught was a typo in a hand-written row.
The alternative was to re-found it on a custom claim projected onto
Forge::Principal and asserted in a @require. That route does not exist.
A principal_claims entry with no source key does read straight from the
presented token's custom map, with no User row involved — so the claim
reaches Cedar. But there are two different principal namespaces, and CEL's is
not Cedar's: principal_map in schema-forge-acton's rules.rs builds a fixed
five-key map (sub, email, username, roles, perms) from named Claims
fields and never reads claims.custom. A mapped principal claim is invisible to
@require. Verified in the source at v0.37.2, not inferred.
The Cedar-side version of the idea — a source-less purpose claim plus a
forbid on CreateRedemption — is expressible, and was declined on the merits
rather than on feasibility. required = true on a principal claim is enforced
against every bearer the plane accepts, so it would 401 console users and
service tokens, which carry no such claim by construction; that constraint is
already recorded under "Known gaps" for the directory claims. And with
required = false plus a guarding forbid it would still buy nothing: anyone
who can mint --roles enrollee can mint --custom-claim-string purpose=enrollment in the same breath. It is a second name for a fact the role
set already carries, and one more way for a provisioner to mint a broken token.
What actually separates an enrollment artifact from a session token is the role set, enforced by Cedar before any of this runs. That is the control; the column was decoration with a security-sounding comment on it. Dropped before the surface froze.
garrison.issuer in hooks-service's config stays. The install-token exchange
mints under it, which is a real use of a real value.
Every candidate from the break pass is now taken or declined on the record. The
issuer column and the enrollment packet were the two the owner was asked to
settle, and both are settled above.