terraform plan, for your app's authentication surface.

autumn routes audit proves what your security surface is: every mounted route, classified from macro-expanded #[secured] / #[authorize] / #[public] code. This page is about the next question — what a pull request did to it, and whether a human agreed.

Code
### 🛡️ Security posture diff

**1 widening change — acknowledgment required.** This pull request makes part
of the app reachable by more callers than before.

| Change | Method | Path | Before | After |
|---|---|---|---|---|
| guard removed: gated → public | `GET` | `/admin/users` | `gated (roles: admin)` | `public` |

To acknowledge these exact changes, comment on this pull request with:

    /ack-posture 4f8a1c0d9e2b7a35 ceiling=GET|/admin/users|public|0:|0:|0|absent|0:|0

That comment is the only way past the gate, and it is recorded on the pull request, by a named human, at the moment the decision was made. "Who approved making this endpoint public?" now has an answer.


What blocks, and what doesn't

Only widening blocks. Everything else annotates, and a pull request that changes no posture posts nothing at all.

ChangeVerdict
New route, public or unclassifiedblocks
gated / framework → publicblocks
Role requirement dropped (#[secured("admin")] → #[secured])blocks
Role added — roles are OR-ed, so another role admits more callersblocks
Scope removed — scopes are AND-ed, so one fewer lets more tokens inblocks
#[authorize] binding removed, or a policy check droppedblocks
CSRF enforcement lost on a route (disabled, or newly exempt)blocks
A prefix added to security.csrf.exempt_pathsblocks
A security header stops being emittedblocks
Route removed, and a weaker route still answers its URLsblocks
Last route at a path removed, and a public route inherits the URLblocks
New gated route that takes a stricter route's URLs overblocks
New gated or framework-owned routeannotates
Route removed, gate strengthened, scope added, policy addedannotates
A prefix withdrawn from security.csrf.exempt_pathsannotates
A security header's value changed (e.g. a new CSP)annotates
Handler renamed, moved to another file, re-modulednothing at all

Two of those rows are worth dwelling on, because they run against intuition:

  • Adding a role widens. #[secured("admin", "editor")] admits either role. Adding editor lets a strictly larger set of people through.
  • Removing a scope widens. __check_secured_scopes requires all listed scopes. Dropping one lets a strictly larger set of tokens through.

Deleting a route can widen; adding one can too

The two conditional rows are the gate's least obvious behaviour, and they exist because a route is not a URL. The router matches a static segment before a dynamic one and mounts both happily, so /users/me and /users/{id} coexist with the static one winning.

  • Delete /users/me — gated — while a public /users/{id} remains, and that URL does not go away. It falls through to the public route, and the guard is gone. Reported as route_shadow_exposed.
  • Add /users/me — restricted to editor — beside a /users/{id} restricted to admin, and the new route takes that URL over. Editors reach what needed admin a moment ago, while the /users/{id} entry sits unchanged in both manifests. Reported as route_added_shadowing.

A path is a node, not a set of routes, so there is a third case. Every method at one path shares a single handler table, and while any method is mounted the path answers 405 for the rest. Delete the last route at /users/me and that node goes with it: POST /users/me, previously a 405, now reaches whatever less specific route covers it. Reported as route_path_exposed, and blocking only when that route is public — newly reachable but guarded annotates, exactly as a new guarded route does. While any method stays mounted at the path, though, the node keeps answering (with a 405) and nothing is inherited at all.

All of them are decided by precedence, not by overlap: a survivor that was already answering the URL gains nothing, so deleting a gated /users/{id} beside a public /users/me is an ordinary removal. Where several routes could take a deleted route's URLs, only the one that actually wins them counts — /records/me/{id} beats /records/{user}/private, so the latter's posture is irrelevant to that deletion. The same analysis covers #[authorize] bindings — a check that disappears from a URL that is still answered is authorization_binding_displaced — and it follows the methods a route really answers, so a WS route mounted as a GET, or a GET that also serves HEAD, is compared as the router will compare it.

And one row is deliberately weak: a security header's value changing is reported, never blocked. Whether one Content-Security-Policy is weaker than another is not decidable from the strings, and a gate that blocks on "the CSP changed" is a gate a team mutes by the end of the week.


Turning it on

The CLI has to be new enough. The scaffolded workflow installs the autumn release your app's autumn-web version tracks. If routes posture postdates that release, it probes forward through the next few releases and installs the first one that has it — for that run only — instead of staying stuck. That's a specific, bounded release, not a moving "latest": it doesn't drift further from your autumn-web version with every later, unrelated release. But it also doesn't resolve itself — the workflow still installs your pinned autumn-web version first, on every run, so publishing a newer compatible release upstream changes nothing here on its own. The fallback keeps firing until you raise your app's autumn-web version and rerun autumn upgrade --apply to pick up a pin that already has the command. Until then, the gate still runs (rather than staying red until you raise autumn-web yourself), just under a CLI that may not exactly match your autumn-web version. If nothing in range has the command, the first run fails, naming it. It fails rather than skipping on purpose: a gate that waves a pull request through because its own tooling is too old is worse than a red one.

New apps get it by default: autumn new scaffolds .github/workflows/posture-gate.yml alongside ci.yml.

Existing apps adopt it with the same one command that reconciles every other framework-owned file:

Shell
autumn upgrade          # preview — shows the workflow it would add
autumn upgrade --apply  # write it

Then commit a baseline, which is the manifest the gate diffs against. Build it with the flags in the workflow's POSTURE_AUDIT_FLAGS (see Which build the gate audits):

Shell
autumn routes audit --release --manifest security-posture.json
git add security-posture.json

Enabling the gate on a repository whose posture has not changed never breaks it. The first run finds no baseline on the base branch, blocks nothing, and says so.

Two settings finish the job:

  • Make Security posture diff a required check in Settings → Branches. Until it is required, the gate reports; it does not gate.
  • Put .github/workflows/ under CODEOWNERS review. Like every pull_request workflow on GitHub, this one runs from the pull request's own copy of itself, so a change to the gate is only as trusted as the review of that change. The workflow refuses to run a pull request that edits it — land workflow changes on their own, reviewed by someone who owns that path.

Deleting the committed manifest does not quietly turn the gate off either: a pull request that removes it fails the check, because a merged deletion would disarm the gate for every pull request after it. And a build that fails does not turn it off by omission — the verdict job runs even when the manifest job does not, and fails closed rather than being skipped (GitHub counts a skipped required check as satisfied).

If your app is a Cargo workspace member

When the git root is also the workspace root, autumn upgrade --apply writes the workflow at the git root and names your member:

Yaml
env:
  POSTURE_MANIFEST: apps/my-app/security-posture.json
  POSTURE_AUDIT_FLAGS: --release -p my-app

It writes ci.yml at the git root too, and runs it in the crate directory. Everything else in the gate works unchanged, because it only reads the manifest path.

One repository holds one posture-gate.yml. If another member already wrote it, autumn upgrade reports a conflict and does not overwrite it. A second member needs its own workflow. Copy the file and give it a new name. Then set POSTURE_MANIFEST and POSTURE_AUDIT_FLAGS for that member.

If the workspace root is below the git root, the gate cannot build the member from the git root. Adopt the gate by hand in that layout.

A nested git repository or submodule is its own git root. Its workflow stays in the member.

The deploy workflows that autumn release init writes also read the manifest path from one variable, POSTURE_MANIFEST. Move a deploy workflow to the git root. Then change the value of POSTURE_MANIFEST to the path above. If the file is missing, the deploy prints a ::warning:: and does not attest. The deploy does not fail, so apps that never adopted the gate still ship.


How a run works

The workflow is two jobs, and the split is the point:

  • manifest compiles the pull request and emits its posture manifest. Compiling runs the pull request's own build scripts and procedural macros, so this job is treated as untrusted: it holds no write permission and reaches no verdict.
  • posture never compiles anything. It downloads that manifest, diffs it with a CLI it installs itself, resolves acknowledgments, posts the comment, and decides.

So the machinery that decides — the diff, the acknowledgment check, the exit code — cannot be replaced by code the pull request ships. What that does not buy: the manifest itself is derived from building that code, and a build script can make an app dump whatever route list it likes. A build-derived manifest is only ever as trustworthy as your review of the build scripts and macros in the diff; no gate can assert that away.

Step by step:

  1. autumn routes audit $POSTURE_AUDIT_FLAGS --manifest security-posture.json builds this commit's manifest, with the build your image ships. That is the only build the gate pays for.
  2. The workflow fails if the committed security-posture.json describes a different posture than the one just built — a stale committed manifest would make the diff lie. The comparison is by posture digest, not by bytes, so a moved line number or a renamed handler never turns into a regenerate-and-commit chore; a real posture change does.
  3. The base branch's copy is read straight out of git (git show origin/<base>:security-posture.json). No second build.
  4. autumn routes posture diff compares the two and exits 0, 1, or 2.
  5. One comment is posted, or updated in place if it already exists.

So the manifest is your app's posture state file: it lives in the repository, it changes in the same pull request as the code, and it is the artifact a release signs.

Which build the gate audits

The manifest describes one binary. Two builds can mount different routes. A route behind #[cfg(not(debug_assertions))] is only in a release build. A route behind a feature is only in a build with that feature. Thus the gate must audit the build you deploy.

The workflow sets the build in one place:

Yaml
env:
  POSTURE_AUDIT_FLAGS: --release

The scaffolded Dockerfile builds --release. If you deploy with autumn build --embed, add --features embed-assets. POSTURE_AUDIT_FLAGS takes cargo build flags: --release, --profile, --features, --all-features, --no-default-features. When you change it, regenerate the baseline with the same flags. This change edits the gate, so make it in a separate pull request.

Two checks find a wrong build:

  • The manifest records its build in build. A non-default build is part of the posture digest. Thus a baseline made with other flags fails the staleness check, and the attested digest names the build.
  • If the base and head manifests come from different builds, routes posture diff reports build_changed as a widening. A diff across builds can hide a widening, so a person must acknowledge it. Expect this once, on the pull request that changes the baseline.

Acknowledging a widening

Comment on the pull request with the line the report prints:

Code
/ack-posture 4f8a1c0d9e2b7a35 ceiling=GET|/status|public|0:|0:|0|absent|0:|0  intentional: public status page for launch week

Rules worth knowing:

  • Who. The workflow asks GitHub for each commenter's repository permission (repos/{owner}/{repo}/collaborators/{login}/permission) and keeps only admin, write or maintain. It deliberately does not trust author_association: MEMBER means "member of the owning organization", which in an org with per-repository permissions includes people with no access to this repository at all. autumn itself has no GitHub identity and enforces no authorization — the workflow step is where that decision lives. A team that wants something narrower (excluding the pull request's own author, say — nothing stops a maintainer acknowledging their own widening today) edits that step. Note that editing it makes the file diverge from the scaffold, so autumn upgrade will report it as a conflict from then on rather than silently overwriting your version.

  • Re-running. Posting the comment does not re-run the check: pull_request does not fire on comments. After commenting, re-run the Security posture check from the Checks tab, or push a commit.

  • What it binds to. The report prints a marker with a ceiling= token. Paste it as is. The token holds the approved posture of each widened route: classification, roles, scopes, policy, CSRF, #[authorize] bindings and mTLS. The gate asks one question: is the route, now, no wider than that?

    • Narrowing keeps the acknowledgment valid. Add a policy check after you acknowledged a public route, and the gate does not ask again.
    • Removing a constraint blocks again. Acknowledge "public, behind a new mfa scope", drop mfa on the next push, and the route is wider than the ceiling.
    • Unrelated pushes keep it valid. A new widening needs a new acknowledgment.
    • The marker digest covers the ceiling text. A truncated or edited paste does not match. This catches mistakes. It is not a secret: anyone can compute the digest for a wider ceiling. Paste only the line the report printed. Do not paste a line from another author without a check.

    The gate uses the same rules as the diff: roles are OR-ed, scopes are AND-ed, an open classification is widest. A finding that names no single route at head (a build change, a header, a CSRF exemption prefix) has no posture to order, so it stays bound to the exact set.

    For an exact match, paste the second line of the report instead. It has the exact digest and no ceiling=. A different digest from the first line is normal. Any later change to the route, narrowing included, asks again.

  • One file per comment. The workflow writes each comment to its own file and passes the directory as --ack-dir. The diff parses each file alone, so an unclosed code fence in one comment cannot change another, and no comment text can fake a comment boundary.

  • Where it doesn't apply. Quoted lines (> /ack-posture …) and lines inside fenced code blocks never acknowledge anything, so quoting a colleague — which GitHub's reply button does for you — cannot approve a widening by accident.

  • Text after the digest and the ceiling= token is the reason. It is echoed back into the comment the gate posts, so the record reads as a decision, not a checksum.

When the tool is wrong

Acknowledgment is the escape hatch, and it is the only one this gate provides. There is no flag that disables it, hides the diff, or excludes a route: a false positive is unblocked exactly the way a true positive is — by a named person saying so on the pull request, in public, with a reason. A wrongly blocked pull request is therefore always unblockable by the team alone, without waiting on a framework release.

(What no in-repo gate can defend against is the repository's own settings: an administrator can always dismiss a required check, and a pull request that rewrites the workflow could rewrite this one. The workflow refuses to run when it is itself edited, and CODEOWNERS on .github/workflows/ is what turns that refusal into a review.)

If the tool got it wrong, please also open an issue with the two manifests; the diff rules are meant to be exact, and a false positive is a bug, not a fact of life.


Recipe: watch it block on reddit-clone

examples/reddit-clone is in autumn's own posture fleet. Its manifest, examples/reddit-clone/security-posture.json, holds every route in the app. Each route has a classification. Post edit and delete, voting, subreddit creation and avatar upload are gated by #[secured]. The pages, feeds and auth forms are #[public]. The comment router is mounted with nest, and commentable::route_infos declares its two routes.

Now change one line in delete_post:

Diff
-#[secured]
+#[public]
 #[delete("/r/{sub_slug}/posts/{post_slug}")]
 pub async fn delete_post(

The code compiles. routes audit passes too: every route still carries a classification, and the audit cannot know that this one used to be stricter. Only a diff against the accepted posture can see that. The gate posts this on the pull request:

Markdown
### 🛡️ Security posture diff

**1 widening change — acknowledgment required.** This pull request makes part of the app reachable by more callers than before.

| Change | Method | Path | Before | After |
|---|---|---|---|---|
| guard removed: gated → public | `DELETE` | `/r/{sub_slug}/posts/{post_slug}` | `gated` | `public` |

To acknowledge these changes, comment on this pull request with:

```
/ack-posture 60c40354ed47391c ceiling=DELETE|/r/%01/posts/%01|public|0:|0:|0|on|0:|0
```

This comment approves these findings. It also approves any narrower posture on these routes. Unrelated pushes keep it valid. A new widening, or a wider route, needs a new comment.

For an exact match, use this line instead. Any later change asks again:

```
/ack-posture ead67427ebc94624
```

<sub>posture digest `4f8df19590b3afb15479ab90cb349a19ae3ff676eb4bcb1ff615f5ffb917b9af` • generated by `autumn routes posture diff`</sub>

<!-- autumn-posture-gate -->

The pull request stays red until someone with write access comments that /ack-posture line.

Do it yourself with two commands:

Shell
autumn routes audit --release --manifest head.json
autumn routes posture diff --base security-posture.json --head head.json

autumn's CI runs this demo on every publish-gate run. scripts/check-posture-demo.sh makes the widened manifest. It checks that the diff blocks it, and that the printed marker unblocks it. Then it compares both reports with the golden files in examples/reddit-clone/posture-demo/, and the block above with the blocked report. A change to the diff's wording or rules changes those files in the same pull request.


Verifying at deploy time

The posture manifest a release ships is signed by the same keyless Sigstore pipeline as everything else autumn ships — see Verify What You're Running. The scaffolded deploy workflows attest security-posture.json with actions/attest-build-provenance, binding those exact bytes to the repository, commit and CI run that produced them.

One command proves both halves at deploy time:

Shell
autumn routes posture verify \
  --manifest security-posture.json \
  --expect-digest 9b1c…  \
  --repo your-org/your-app
  • --expect-digest — the posture digest recorded when the change was acknowledged (the gate's comment prints it, and autumn routes posture digest --manifest security-posture.json re-derives it). This answers is this the posture a human approved? It is computed over the manifest's security-relevant content only, so regenerating the manifest does not invalidate it while the posture holds.
  • --repo — runs gh attestation verify under the hood. This answers did this file come out of our CI, unmodified?

It exits 0 only if every check that ran passed and at least one actually ran. Tamper with one byte of a route's classification and the digest check fails; substitute a manifest from somewhere else and the signature check fails; omit both --expect-digest and --repo and the run fails rather than reporting a pass it did not earn. --expect-digest takes the full 64-character digest — the 16-character short form exists for the comment marker a human types, not for a value a deploy script passes.

--skip-signature exists for genuinely offline hosts. It is reported as waived, never as passed, and a run that waives both halves fails: a verification that verified nothing is not a pass.

Prove the check is real, the same way the supply-chain guide does:

Shell
# Flip a route to public in a copy of the manifest…
sed 's/"gated"/"public"/' security-posture.json > tampered.json
autumn routes posture verify --manifest tampered.json \
  --expect-digest 9b1c… --skip-signature
# ✗ acknowledged-posture   does NOT match the acknowledged digest 9b1c… …
# exit status 1

Command reference

CommandWhat it does
autumn routes posture diff --base B.json --head H.jsonThe gate. Markdown by default; --format json / text.
… --ack-dir acks/One file per pull-request comment, each scanned alone for /ack-posture markers.
… --ack-file acks.txtComments in one file, divided by <!-- autumn:ack-source --> lines. Prefer --ack-dir.
… --ack <digest>The same acknowledgment, inline — for local runs.
… --allow-missing-baseNo baseline yet: report, block nothing.
… --output posture-diff.mdAlso write the report to a file.
autumn routes posture digest --manifest M.jsonThe posture digest a release records.
autumn routes posture verify --manifest M.json --expect-digest D --repo o/rDeploy-time proof.

Exit codes: 0 clean or acknowledged · 1 blocked (unacknowledged widening, or failed verification) · 2 usage or I/O error. CI can tell "this pull request widens the surface" from "the tool could not run".


What it cannot see

The diff is exact about what the manifest proves, and silent about what it doesn't. It inherits every boundary of the manifest's provenance classes:

  • Runtime enforcement. This is a review-time and deploy-time control. It never blocks a request, and it does not change what #[secured] or #[authorize] do.
  • Which policy answers an #[authorize] check. The manifest proves the (action, resource) binding; which impl Policy<Resource> is registered at boot is a runtime fact, disclosed as the dimension's runtime_caveat.
  • Plugin-contributed routes. They are not in the manifest yet; that composes with the plugin manifest work in #1601.
  • Anything the app doesn't mount through the route registry. A hand-rolled axum::Router merged in at the edges is invisible to routes audit, and so invisible here.
  • A renamed #[authorize] resource. Renaming Post to Article reads as one binding removed and another added, and no manifest can tell that from a genuine loss. It blocks, and the report names the pairing so you can acknowledge it in one step.
  • Middleware-imposed guards. A route guarded by a layer, such as RequireApiToken, is unclassified to routes audit. The audit then fails before this gate runs. Mount the group with scoped_guarded to declare the guard. The route is then gated with provenance: "declared". The audit cannot check that claim, and neither can this gate. Review the layer.
  • A build that lies. The manifest comes from compiling the pull request, so a build script or procedural macro in the diff can influence what the app reports about itself. The gate isolates the verdict from that code (see "How a run works"), not the measurement. Review build scripts as the privileged code they are.
  • Which URLs a CSRF exemption prefix actually covers. routes audit asks whether a route template matches a configured prefix, while the middleware asks it of the request path — so exempting /users/me leaves POST /users/{id} recorded as enforced. The gate therefore treats the prefix list itself as posture: adding a prefix blocks, whatever the per-route rows say. Expect the report to name the prefix rather than the routes it touches. The acknowledgment binds only to routes that serve a URL under the prefix. A route is left out when a more specific route wins every URL it would serve. The CSRF middleware cleans the request path first. A capture or a catch-all can hold .., . or %2e. For example, a request to /other/../api reaches POST /other/{id}/api. The middleware cleans that path to /api. The acknowledgment binds to such a route too. Three limits remain:
    • A %2f inside a capture is not modeled. A %2f can also hide ...
    • The gate does not rank these routes by router precedence. It can bind a route that no request reaches.
    • A route with many captures can have too many cleaned shapes to track. The gate then binds the route.
  • A build you do not audit. The gate audits the build in POSTURE_AUDIT_FLAGS. A route that exists only in another build is not in the manifest. Keep the flags the same as your image build. The build object records only the Cargo flags. It does not record RUSTFLAGS, CARGO_PROFILE_* variables or .cargo/config.toml settings, which can also change the build.

See also