SITUSDemoDashboardMailboxesPolicyMandatesGrantsReceiptsVerifyPasskeysDocs

Docs

What Situs Pilot 1 is, and how to build against it.

From docs/DECISIONS.md in the repository.

Decisions — Situs Pilot 1 demo

Each entry is decided. It says what was chosen, why, what was rejected, and when to revisit. Dated 2026-09-23.

D1. Presenting: a human signs the approval live

Decision. Use make demo-live in front of an audience. It stops at the $75,000 grant and waits until the approver runs situs-approve in a second terminal, or presses approve in the console. make demo stays unattended: it signs with keys/approver_jdoe.pem itself, and it is what tests, CI, and the recording use.

Why. The human signing the exact action hash is the moment the pitch turns on ("not a session, not a button"). Letting a script do it on stage hides it. An unattended path is still needed so the demo can be tested and recorded.

Rejected. A single mode that always waits for a person. It would make make demo untestable and break "fresh clone to demo with no manual step".

D2. The bypass holds a real bank credential, in one labeled file

Decision. make demo-bypass mints a bank token from the bank's shared secret inside demo/bypass.py. That file is the only code outside the bank and the enforcement point that reads the secret. tests/test_no_ambient_credential.py pins it by name, and tests/test_bypass_documented.py proves nothing else imports it.

Why. The bypass exists to show the world without Situs: an agent holding a working bank key. It must be a real credential the bank accepts, or the demo proves nothing.

Rejected. Giving the mock bank a second, static "legacy API key". That adds a second way into the bank that would also exist while Situs is running, weakening invariant 1 to make the contrast easier to stage.

Revisit if the bank ever needs a second credential type for a real integration.

D3. Every demo run signs a fresh Intent Contract, and make reset clears the slate

Decision. Each run signs a new contract, ic_acme_treasury_ops_<UTC timestamp>, valid for one hour. make reset stops the stack and deletes data/ and out/, keeping keys/. Run make reset && make up before showing the console to an audience.

Why. Contracts are immutable and the demo revokes its own contract in step 5, so reusing one id breaks the second run. Without a reset, the console accumulates every past run.

Rejected. Re-signing the fixture's contract id. That would mean overwriting a signed contract, which the authority correctly refuses.

D4. make up returns only when every service is healthy

Decision. Each image has a health check on /healthz. make up runs docker compose up --wait and, on failure, prints a hint that points at docker compose logs and the Colima rule in D6.

Why. Before this, make up reported success while services were still starting or crash-looping, so the first make demo failed with an unhelpful error.

D5. Model mode is supported and tested, but scripted mode is what we present

Decision. Present with the scripted agent. Model mode (a real Claude model behind the gateway) is tested two ways:

Why. A live model can phrase tool calls differently on any run, and a demo must not depend on network luck. The claim the demo makes, that a fooled agent doesn't matter, holds either way.

Revisit. Run ANTHROPIC_API_KEY=... uv run pytest tests/test_demo_model_mode.py once on the presenting machine before ever using model mode live.

D6. Docker via Colima, repo under the home directory

Decision. Keep the repo under $HOME. Colima shares only the home directory with its VM, so a checkout elsewhere, such as /tmp, gets empty keys/ and demo/ mounts and the services crash. Colima was started for this session and is left running, with the demo stack up, so the console can be explored. Stop it with make down && colima stop.

D7. The recording lives in the repo

Decision. demo/demo.cast is committed. make record regenerates it with asciinema from the real scripted demo and bypass, with pauses shortened so it plays in under two minutes. Play it with asciinema play demo/demo.cast.

Why. It is a fallback for a demo that can't run live, and a way to review the story without Docker.

D8. The code stays local until you choose where it goes

Decision. No git remote is configured and none was created. Publishing the code is an owner's decision.

Recommendation. Create a private GitHub repository and push: gh repo create situs --private --source . --push.

D9. make test prints its summary

Decision. The Makefile no longer adds a second -q, which had suppressed pytest's pass/fail summary. The exit code is still the signal for scripts.

D10. Gaps against the strategy doc's Pilot 1, and the order to close them

Compared with Situs Strategy and Architecture v4.1 §13 and §26, the build meets the five core Pilot 1 components and the bypass, idempotency, valid-as-of and canonical-encoding requirements. Three things the doc puts in Pilot 1 are missing:

  1. Risk-officer certification in the console. The officer authors treasury.v1 limits and signs the certification in the product. The doc calls it "the pilot deliverable". Build it next. It needs no new infrastructure: a console form, a profile signature by the officer's key, the authority accepting only a signed profile, receipts citing its hash.
  2. Confidential CPU VMs for authority, pep and broker "from day one", with HSM-held keys and admin separation. The build's CLAUDE.md scope lock forbids confidential-VM work, so the loop could not build it. Lift that line of the scope lock for the next phase and follow docs/DEPLOY.md. Until then receipts correctly say L1-sim.
  3. Fail closed past the stale-trust window, wherever status is cached, and reserved receipt slots for shadow and delegation fields. Do both before receipt v0.2 has external readers.

The constitution is not edited here: changing the scope lock is the owner's call, and it reopens the build loop's queue. The gateway-instead-of-SDK capture and the extra approver signature on receipts are deliberate deviations and stay.

D11. Humans sign with Touch ID or a passkey; key files stay for tests

Decision. The treasurer and the approver sign on hardware:

Every receipt names the kind of key each human used, and its unproven list states the limit of each: a key file can be copied; a Secure Enclave key's residence was recorded at enrollment, not attested by Apple; a passkey may sync across the owner's devices. SITUS_HUMAN_KEYS=hardware make up makes the authority and pep refuse human key files.

Rejected. A USB security key as the primary signer (the TrustKey T110 stays a backup option), and synced passkeys as the only path (not device-bound).

Limits. A passkey is bound to the host name localhost in this build, so the iPhone flow depends on the phone accepting a localhost relying party through the QR hand-off. If it refuses, use Touch ID in the browser or on the terminal until the console has a real HTTPS host name.

D12. The full visibility proof is computed from the receipt, not reported by Situs

Decision. Every receipt gets a proof report: situs-verify <receipt> --report proof.html, the console's /receipts/<id>/proof, and out/<id>.proof.html after make demo. It answers 24 regulator questions under WHO, WHAT, WHERE, HOW MUCH, STATUS and INTEGRITY, each with the answer, who vouches for it, a strength of proof from 0 (not proven) to 4 (independent, hardware key), the gap, and the design-doc section it comes from. It draws the process in swimlanes with each step's proof, and the values that bind the steps (contract hash, action hash, idempotency key, evidence-chain links, receipt body hash, log leaf) underneath.

Why. A regulator must be able to see what is proven without trusting the party that made the receipt. The report reads only the receipt file and runs the offline verifier itself, so it is one self-contained HTML file with no scripts and no network.

Receipts now also carry the grant's policy decision whatever its outcome, and the model call before the action (bundle.evidence.model_event: model id, prompt and response hashes).

What the report shows is still missing (strength 0 or 1), in the order to close: four-eyes (principal and approver are the same person), an authenticated agent identity, binding the model call to the action, measured builds and hardware quotes for authority, pep and broker (L1), a certified venue and residency, independent publication of public keys, a public log witness, a trusted timestamp, and stated, enforced retention.

D13. The profile is edited by publishing a signed version, never in place

Decision. The Policy tab edits treasury.v1 (limits, approved vendors, approvers, lifetimes, market-data addresses) by publishing the next version (0.2 → 0.2.1 …) with a note, signed by the treasurer's key. It lands in data/profile/active.json, with every version kept under data/profile/versions/. The authority and the pep reload it when it changes, verify the hash and the principal's signature, and answer 503 to everything if it does not verify. A mandate cites the version it was signed under; once a newer version is in force the engine refuses it as contract_expired, so a receipt never cites rules it was not decided under. Publishing lowers the saved mandate if it no longer fits.

Why. A writable rule file anyone on the host can edit is weaker than a read-only fixture; a signed, versioned one is stronger than both, and the receipt's policy_version stays true.

Not done. Pilot 1 has one principal, so the treasurer signs the profile that bounds their own mandate. The doc's pilot deliverable wants a separate risk officer and a signed certification statement.

D14. A payment, not a proposal, is the unit: one invoice is paid once

Decision. Several agents may read the same mailboxes. Two layers stop a double payment. The console's common queue delivers each message once, however many agents fetch the mail. The authority, which trusts no agent, refuses a transfer as duplicate_action while another grant for the same payment is pending, active or redeemed in the tenant, under any mandate. A payment is the payee id and account, amount, currency and invoice reference (the memo, case and spacing ignored); the paying account is left out. A transfer's idempotency key is derived from the same fingerprint (idem_pay_…), so the bank moves that money once even if two grants exist.

Why. Single-use grants and the bank's idempotency key stopped a grant being spent twice, not a second grant for the same invoice: two agents, or one agent after a console restart, could each get one. The queue is agent-side and cannot be trusted with this; the authority can.

Limits. The fingerprint depends on the agent copying the invoice number into the memo. A model that rewrites the memo gets past it, and paying the same invoice twice on purpose needs a new reference. The storyline's stand-in payments use their own references for that reason.

D15. Plugin tiers are labels from a static bundle; listing is a pull request, not a service

Decision. Every loaded plugin has one of three tiers. local: any package the loader accepts and the bundle does not name; the verifier and the console call it an "unlisted plugin". listed: the package's exact hash is in situs_core/plugins_bundle.json, the static bundle shipped with core and the verifier; in the pilot our own review (situs-plugin check passes and a maintainer has read the package) counts as listing, so treasury is listed and records is listed once Task 34 lands. certified: the same bundle entry with tier certified, set by hand after an independent review of the package against the conformance suite and its regulatory map; nothing sets it automatically, and the bundle's own comment says so. A package is submitted by a pull request to a situs-plugins repository whose CI runs situs-plugin check; a merged pull request adds the entry (situs-plugin package <dir> --bundle listed) and the next release ships the bundle. There is no registry service, no upload endpoint and no marketplace.

Why. A receipt names the plugin that decided (what.plugin: name, version, package hash, tier). An auditor needs to know whether that package is one anyone has reviewed, offline, from the verifier they hold, without asking a server. Keying the bundle by package hash makes the label exact: one changed byte in a covered file makes a listed package unlisted again. The tier is a label, never a check that allows: an unlisted plugin still runs under core's checks and can only narrow what core allows (invariant 11), and the verifier resolves the tier from its own bundle, not from the tier the receipt claims.

Limits. A tier says the package was reviewed, not that its rules are right for a given tenant. The bundle is as current as the verifier that ships it; a newer listing needs a newer verifier. Editing a listed plugin (as Tasks 29 to 31 do to treasury) needs its bundle entry refreshed in the same change, or it shows as unlisted; tests/test_situs_plugin.py fails until it is.

D31. Words come from the plugin; the demo is the plugin's (Task 31)

Decision. Everything a person reads about a plugin's domain comes from the plugin. Its wording.json (now a required file) has four sections, each optional: report (the proof report: per finding id a question, answer, basis, gap, doc_ref and named variants, plus lanes, steps, bindings, headline and derived values), approval_card (per channel: email, app, sms, dialog, prompt for Touch ID, notice for an app notification, page for the console's approval page; and counterparty_names, where the profile keeps display names), labels (the words the console shows, by key, from column names to page copy) and reasons (human text per reason code). situs_core/plugins/wording_generic.json has every key core reads in neutral words, and a plugin's file is merged over it, object by object. Templates are strings with {path|filter} placeholders or lists of alternatives (situs_core/plugins/wording.py). The treasury storyline driver, its workload generator and its sample mailboxes are the treasury plugin's own program, plugins/treasury/demo/{story,workload}.py, found by name through cli/demo_program.py (SITUS_DEMO_PLUGIN, else the first plugin that ships a demo). The neutrality allowlist is empty and asserted empty.

Why. Invariant 10 says core names no plugin's domain; the demo must still read exactly as before. Moving the words, not rewording them, keeps both: the proof report and the approval email are byte-identical to before (tests/test_wording.py), and a second plugin gets its own words by writing a file, not by editing core. Wording is data, so the offline verifier can read it without running plugin code.

Limits. The report's header gained one line (plugin, version, package hash, tier), so the report snapshot is byte-identical apart from that line. Core words that are not in any plugin's vocabulary but still sound like treasury ("treasurer" in some console copy) were left for Task 35, which makes the overview and the Demo tab switch per plugin. The general layer of the overview (the five ideas, the hero) was reworded to be plugin-independent. A plugin's demo program is Python the console imports; it is demo code, never on the enforcement path (the pep still loads no plugin code).

D32. The Approval API as built (Task 32)

Numbered after the task, not the next free number, because Tasks 28 to 31 run in parallel branches and may add their own decisions. docs/APPROVAL_API.md changed in the same commit; an iOS app is built against it, so each change is listed here with its reason.

D34. What the records plugin found: the generality bugs, and the fixes (Task 34)

Context. Task 34 adds a second plugin, plugins/records/, with no vocabulary in common with treasury: a support agent releases regulated customer records (KYC files) to third parties. The allowed change set was the plugin, docker-compose, the Makefile, docs, tests and situs_core/plugins_bundle.json. Anything else that had to change is a place where the layer was not generic. Each one is listed here with what leaked and how it is kept from coming back.

Required by the task itself (not leaks, but core changes):

Generality bugs:

  1. Core's own English collided with the plugin's vocabulary. records declares record, recipient, release, purpose, sensitivity, requester. The neutrality scan found about 430 hits in about 50 core files: a grant record, the pep's key release (a receipt field), a key's purpose, an email's recipient. None names records' domain, and renaming key_release would change the receipt format, so the words were not renamed. Instead core declares its senses in situs_core/plugins/glossary.py: for those four words the scan allows the bare word and the listed compounds (grant_record, key_release, ...) and flags any other compound (record_id, release_record, recipient_address). sensitivity and requester, and all of treasury's words, are scanned as before. Kept by tests/test_core_neutral.py::test_a_glossary_word_is_allowed_bare_and_in_its_listed_compounds_only ::test_every_listed_glossary_compound_still_occurs_in_core (the compound lists only shrink) and ::test_the_glossary_changes_nothing_for_a_word_outside_it (for every word outside the glossary, treasury's included, the scan equals the plain matcher on every core file). The conformance item vocabulary_neutral and Task 31's generic-wording check use the same find_terms.
  2. GET /v1/tools needed a plugin name once two were loaded, so every agent that asked for its tools (the model-mode demo, the console's browser demo) broke. The gateway now answers an agent that sends its credentials and contract header with the tools of the plugin behind its own mandate (services/gateway/routes.py); cli/demo_story.py sends its headers; the developer guide shows ?plugin=. Kept by services/gateway/tests/test_plugin_tools.py::test_v1_tools_equals_the_plugin_file.
  3. SITUS_PROFILE_DIR was one folder for every plugin, so the second plugin read the first one's signed active.json and failed closed. It is now a root with a folder per plugin (situs_core/profiles.py). Kept by tests/test_profiles_and_mandates.py::test_profiles_are_kept_per_plugin.
  4. "The first plugin" was the demo's plugin. Plugins load in name order, so records came first, and the Dashboard's counterparty panel polled the records mock for a balance. The panel now shows the counterparty of the plugin the console's mandate is signed under (services/console/counterparty.py link_for, services/console/dashboard_page.py). Kept by tests/test_bank_standalone.py::test_the_console_links_the_bank_settings_and_shows_its_feed.
  5. The developer guide's reason codes were treasury's, typed in. Task 31 fixed the same thing independently (dev_guide.reasons()); after the merge its version stands. Kept by tests/test_docs_tab.py::test_the_guide_lists_every_reason_code_the_engine_can_return.
  6. The conformance suite's live run could not meet a hardware route, and did not use the approval API its item names. It approved every pending grant with the profile approver's key file through the legacy POST /grants/{id}/approve, and checked no step's outcome. cli/storyline_player.py (new) plays any plugin's storyline: the agent through the gateway, the approver through POST /approvals/{grant_id} from a principal-registered app with a key of the assurance the step names (a key file for software; for hardware, an es256 key the player enrolls once through POST /approvers/{id}/keys, authorized by the key file, recorded as declared), the principal's revocation, and the auditor's offline verification; every step's expect is checked. cli/plugin_conformance.py uses it.
  7. There was no way to demo a plugin other than treasury. cli/demo_story.py is treasury's presenter script. situs-demo --plugin <name> (cli/situs_demo.py) plays the plugin's own storyline with the same player; make demo PLUGIN=records runs it and make demo stays treasury.

Not changed: the pep (records runs through the one generic connector with only connector.json, as Task 29 promised), the authority's policy, grants and receipts, the approval API, the verifier (situs-verify has no flag naming a plugin; a records receipt passes all CHECK_COUNT = 9 checks), and situs_core/keys.py (the connector's signature.kid, recipient, joined KEY_IDS by itself and make keys created it).

D35. Docs as the self-service path; a static venue trust bundle (Task 35)

The docs are one source with two outputs. situs_core/devdocs.py holds the developer guide as data (pages of sections of blocks). The console renders it at /docs/api, /docs/plugins and /docs/approvals with the running stack's values (public URLs, every kid and fingerprint from /.well-known/keys.json, the plugins the authority lists at the new read-only GET /plugins); make docs writes docs/DEVELOPERS.md from the same data, and a test holds the file equal to what the source generates. The conformance items come from conformance_items.ITEMS, the approval routes from a list a test holds equal to the router, the verifier's checks from CHECK_TITLES: the pages cannot drift from the code they describe. devdocs.py is core and names no plugin; a plugin's own material (its quickstart, example mandate, worked example) is a block marked <!-- plugin-example: <name> -->, and the neutrality test scans DEVELOPERS.md with those blocks cut.

The quickstart is executable. tests/test_quickstart.py runs the enterprise snippet with bash, literally, against make up, for both plugins: make keys, situs-keys sign-mandate --register (new; fills id, window and the profile version in force, signs with the principal's key file), situs-keys agent-token (new; the token goes into a shell variable and is never printed), one chat completion, the plugin's injected scenario as the call a fooled agent makes, the receipt of its refusal, situs-verify --as quickstart. The call is the injected one so that the quickstart moves no money and changes no counterparty's state, and repeats cleanly.

Venues are labelled from a static bundle, like plugins (D15). situs_core/trust_bundle.json ships with the verifier like a root store; listing is a pull request with a signed venue.json. situs-verify prints VENUE <venue> <kid>: known venue (...) or unknown key before its verdict line; a label, never a check, never the exit code. An entry maps each decision kid to a pinned key fingerprint or null; the local development entry pins nothing (every checkout makes its own keys) and says "key not pinned". --as <who> prints who ran the verification, last. The "ten checks" of the task text are CHECK_COUNT = 9; the docs list them from CHECK_TITLES. Levels in the docs: L0 declared, L1 confidential VM attested, L2 adds a GPU quote (neither L1-attested nor L2 is built); L1-sim locally and L1-declared on GCP, rendered in the warning colour, never green.

Not chosen here: the license (a marked placeholder for the founder) and the Operating Rules (a draft, optional, not in force, not in this repository).

D38. A front door, not a proxy: situs.o11r.com on Cloud Run, the VM behind its own name (Task 38)

Context. The demo VM is stopped most of the time, and its only address was <ip-with-dashes>.sslip.io. The founder wanted a stable public name that works while the VM sleeps, and a way to wake it from a phone, without weakening anything the VM proves.

Decision. https://situs.o11r.com is a stateless Cloud Run service (situs-front, min instances 0) that serves the overview's story and the Docs as static pages, a status word, and a founder-only launcher. The VM keeps Caddy and its TLS and answers on its own name, https://situs-vm.o11r.com (DNS only). The front door redirects every VM page to the VM when it is awake and never proxies: it answers /api/* with a JSON pointer or demo_asleep, its only request to the VM is HEAD /healthz, and no VM body ever passes through it.

Why not a proxy. A proxy in front of the VM would terminate TLS off the VM and see every request and response in the clear: approvals, passkey assertions, receipts, the console's session. Passkeys (WebAuthn) are bound to the origin the browser talks to, so a proxy on another name would either break them or have to become the relying party, moving trust from the VM to Cloud Run. The Origin check on the console's writes would have to trust the proxy's origin too. And a receipt's keys would be served by something that is not the VM. With redirects, the browser talks TLS to the VM itself; the front door can be compromised without any effect on what a receipt proves, because it holds no key, no evidence and no session with the VM. The same reasoning keeps Cloudflare's proxy off both DNS records.

The launcher's powers are the minimum. A custom role with compute.instances.get, .start and .stop, bound on the one instance; objectAdmin on a control bucket that holds one object, the auto-stop deadline, which the VM's watcher reads and clamps to 8 h (the launcher has no setMetadata); secretAccessor on its two secrets. Sign-in is Google's OIDC code flow with PKCE, an allowlist of verified emails, a __Host- cookie (Secure, HttpOnly, SameSite=Strict), and a CSRF token plus an Origin check on every POST. Bring-up the first time, Destroy and Check stay on the founder's machine (/admin/cloud), which is switched off on the VM's console.

Nothing weaker, and some things stricter. Found by the audit and fixed here:

  1. The demo counterparty pre-filled its admin token for loopback requests; behind Caddy every request is loopback. SITUS_BANK_BEHIND_PROXY=1 turns that off on the VM.
  2. Every state-changing console request now refuses a missing or foreign Origin (Sec-Fetch-Site: same-origin as the fallback), before any handler runs; the allowed origins come from SITUS_ORIGINS, and locally they are the console's own.
  3. The VM's .env wires its name: SITUS_RP_ID/SITUS_RP_IDS, SITUS_ORIGINS, SITUS_AUTHORITY_PUBLIC_URL and SITUS_AUTHORITY_PATH_PREFIX for the Approval API.
  4. /admin/reset on any service is never reachable through Caddy (nor through the front door).
  5. situs-verify without --keys says first, in yellow, that the keys are the receipt's own and unpinned, and names the --keys <url>/.well-known/keys.json command (a file or an https URL); the exit code and the nine checks are unchanged. The pep now asks for the GCE identity token with audience situs:<hash of the decision it attests> (the decision without its attestation field), and the verifier's WHERE line requires that audience to name the pep-signed decision in the same receipt, which must carry the same slot. A token copied into another receipt is INVALID. The label stays L1-declared; receipts made off GCE are unchanged.

Consequence. Receipts made on the VM before Task 38 carry the bare situs audience and now read INVALID on the WHERE line (a label: their checks and verdict are unchanged). That is the tightening, not a regression. Kept by tests/test_frontdoor.py, tests/test_frontdoor_image.py, tests/test_gcp_front_iam.py, tests/test_front_hardening.py and tests/test_front_proof.py.

After Task 38 (merge). The public front door serves Task 35's story and the three developer pages as static files rendered at image build time from this checkout (plugins' declarations, the VM's public URLs, no kid, no receipt, no Try it box). /docs/examples and /docs/schema/* hold the VM's live data and its own models, so the front door redirects them to the VM. POST /docs/try is a state-changing console request under the same origin guard as every other.

D39. Push is a wake-up, not a channel: a content-free relay on the front door (Task 39)

Decision. An iPhone app in the background gets one kind of notification: "Situs: an approval is waiting", plus the grant_id in a custom key and mutable-content: 0. The app then fetches GET /approvals/{grant_id} from the authority on the VM and signs there, exactly as before. The APNs sender is the front door (POST /push on Cloud Run), not the authority: the authority posts {v, token, environment, topic, grant_id, approver_id, ts, nonce} to it, signed with the authority key in the webhook convention, fire-and-forget with a 2 s timeout.

Why not put the approval in the notification. A notification passes through Apple, sits on the lock screen and in the notification center, and a notification service extension could render or rewrite it. None of that is inside what a receipt proves. If the card or the amount were in the push, an approver could act on text the authority never signed for them, and the phone would show content that nobody verified. With a fixed text, the only thing a push can do is make the app ask the authority; every fact the person sees still comes from the item, over TLS, from the VM. A forged, replayed or stale push costs one needless fetch.

Why the relay and not the authority. The APNs key would otherwise sit on the VM with the signing keys and be one more secret the demo stack carries; and the authority would need outbound HTTP/2 to Apple. The front door already exists, holds no evidence and no approval key, and can hold the APNs key in Secret Manager, readable only by its own account. The relay knows a device token, a grant id and an approver id for the length of one request; it keeps only a five-minute nonce window and per-token counters in memory, and logs 8 hex of a hash of each.

What the relay checks. The authority key is pinned at deploy time (make gcp-front-push reads the VM's /.well-known/keys.json; the relay never fetches keys), the signature over the canonical request with path /push, ts within five minutes and equal to the signed ts, exactly the eight fields in their shapes, the topic is the app's, the nonce is new, 1 KB at most, six pushes per token per minute. Until the APNs key, key id, team id and topic exist it answers 503 push_not_configured. Terraform mounts the two APNs secrets and points the VM at /push only when push_enabled is true (default false), because a secret that does not exist, or has no version, fails a deploy.

The one exception to D38's "no private key in the front door". The APNs .p8 is a private key. It lives in one module (services/frontdoor/apns.py) that loads only an EC P-256 key from text handed in from Secret Manager, never from a file, and signs only the APNs provider token (an ES256 JWT of {alg, kid} and {iss, iat}). It cannot sign anything Situs trusts: no receipt, grant, approval or request verifies against it. tests/test_frontdoor_image.py keeps the old rule for every other module and checks this one more narrowly.

Consequences. Push adds no state to a grant, an event or a receipt: with the relay refusing, raising or hanging, the receipt's bytes equal those made with no relay (tests/test_push_authority.py). A missed push delays an approver until the app polls; it never blocks or changes a grant. Apps register one token each (PUT/DELETE /approvals/apps/{app_id}/push-token, app-authenticated, own app only); a token another live app holds is refused; revoking an app drops its token. Kept by tests/test_push_relay.py, tests/test_push_authority.py, tests/test_gcp_front_iam.py, tests/test_frontdoor_image.py and tests/test_gcp_deploy.py.