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:
tests/test_demo_model_mode.py::test_model_mode_chat_loop_runs_the_storyline_without_networkruns the whole model-mode chat loop against the gateway's scripted model, with no network.test_model_mode_runs_the_storyline_with_a_real_modelruns with Claude whenANTHROPIC_API_KEYis set. It was skipped in this session because no key was set.
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:
- 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.
- Confidential CPU VMs for authority, pep and broker "from day one", with HSM-held keys and admin separation. The build's
CLAUDE.mdscope lock forbids confidential-VM work, so the loop could not build it. Lift that line of the scope lock for the next phase and followdocs/DEPLOY.md. Until then receipts correctly sayL1-sim. - 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:
- Touch ID on the Mac (
make enroll-touchid): a P-256 key created inside the Secure Enclave that requires the currently enrolled fingerprint for every signature, with no password fallback.situs-approveandmake demo-liveuse it automatically once enrolled. The prompt says what is being signed ("approve USD 75000.00 to ven_northwind ..."). - A passkey registered in the console at
/passkeys: approve or revoke with Touch ID in the browser, or choose "use a phone" and scan the QR code to sign with Face ID on an iPhone. Registration only creates a pending key;uv run situs-keys passkey-accept <kid>on this Mac, then a service restart, makes it trusted. That step is the key ceremony.
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.
- App Attest assertions are verified the way Apple produces them. The draft said the signature is over
authenticator_data || sha256(JCS(client_data)).generateAssertionsigns nonce = sha256(authenticatorData || clientDataHash) with ECDSA P-256/SHA-256; the server verifies that. An iOS app that calls the Apple API as documented is unaffected. tpmattestation and packed self-attestation are refused. TPM statements need a per-vendor certificate stack and AIK checks that the pilot does not need; self-attestation proves a key signed its own registration, not where it lives. Either is 400attestation_invalid, never a lower level.packedchains to roots the deployment configures (none by default);applechains to Apple's WebAuthn root, shipped insitus_core/attestation_roots/beside the App Attest root.- Shapes the draft left open are now fixed: the enrollment-code and app-revocation bodies (
issued_atinside the replay window, anonce, single use); the passkey attestation object (format: "webauthn", challenge = the enrollment digest); the digest an attestation binds when a key is added by an existing key (the authorization digest); key removal keeps the key as revoked so later signatures saykey_revoked. - Two identical requests in one second are a replay. That follows from
(kid, ts, sig)with deterministic ed25519; the document now tells apps to keeptsstrictly increasing. - SSE events are signed in
data. The draft said every SSE event is signed but gave no field;datais the item plussignatureover{event, id, item}. - Added:
GET /approvals/{grant_id}(the draft says "poll the item" but had no route); error codes for malformed input (key_invalid,key_exists,key_not_found,app_invalid,app_exists,approver_invalid,decision_invalid,cursor_invalid,grant_not_found);decision,decided_at,app_idin the decision response;receipt_idandassurance_basisinresolution. - One approver per grant. Profiles carry
min_approvers: 1;approvals_remainingis always 0. Multi-approver grants would change the grant's approval block and are not in Pilot 1's scope. - The card is built from
describe()until plugins carry wording (Task 31), in neutral labels, so the authority stays plugin-neutral (invariant 10). Task 31 made it the plugin'sapproval_card.appwording (D31); the neutral labels are now the generic card. - Keys enrolled through the API live in
keys/enrolled/<kid>.json, signed by the authority. Every service already reloads its key ring fromkeys/; the pep needs these keys to re-verify the approval on the grant, and the receipt bundle embeds them. A process that can writekeys/still cannot add a signer without the authority's signature (the same reasoning as D11's endorsed passkeys). - Existing keys get fixed assurances: a key file is
software(verified), a Mac Touch ID key and an endorsed passkey arehardware(declared). The console's approve button and the Mac dialog keep posting toPOST /grants/{id}/approve, which now applies the same assurance check and records the same fields: no channel can lower a route's assurance (invariant 12), and the demo looks the same because the demo's routes ask forsoftware.
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):
- The storyline format (
situs.storyline/1):situs_core/plugins/layout.py(STORYLINE_SCHEMA),situs_core/plugins/storyline.py(new: load, cross-step references, the agent's opening turn),services/gateway/scripted.py(the stub plays the agent steps before the first human step, where it used to readscript),situs_core/plugins/scaffold.py(situs-plugin newwrites the new format; its covered tool takesgrant_id). Ordered steps, each with an actor (agent, approver, principal, auditor), the call, the expected status, reason or acknowledgment, dwell seconds and narration.
Generality bugs:
- 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_releasewould change the receipt format, so the words were not renamed. Instead core declares its senses insitus_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).sensitivityandrequester, and all of treasury's words, are scanned as before. Kept bytests/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 itemvocabulary_neutraland Task 31's generic-wording check use the samefind_terms. GET /v1/toolsneeded 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.pysends its headers; the developer guide shows?plugin=. Kept byservices/gateway/tests/test_plugin_tools.py::test_v1_tools_equals_the_plugin_file.SITUS_PROFILE_DIRwas one folder for every plugin, so the second plugin read the first one's signedactive.jsonand failed closed. It is now a root with a folder per plugin (situs_core/profiles.py). Kept bytests/test_profiles_and_mandates.py::test_profiles_are_kept_per_plugin.- "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.pylink_for,services/console/dashboard_page.py). Kept bytests/test_bank_standalone.py::test_the_console_links_the_bank_settings_and_shows_its_feed. - 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 bytests/test_docs_tab.py::test_the_guide_lists_every_reason_code_the_engine_can_return. - 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 throughPOST /approvals/{grant_id}from a principal-registered app with a key of the assurance the step names (a key file forsoftware; forhardware, an es256 key the player enrolls once throughPOST /approvers/{id}/keys, authorized by the key file, recorded asdeclared), the principal's revocation, and the auditor's offline verification; every step'sexpectis checked.cli/plugin_conformance.pyuses it. - There was no way to demo a plugin other than treasury.
cli/demo_story.pyis 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=recordsruns it andmake demostays 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:
- The demo counterparty pre-filled its admin token for loopback requests; behind Caddy every request is loopback.
SITUS_BANK_BEHIND_PROXY=1turns that off on the VM. - Every state-changing console request now refuses a missing or foreign
Origin(Sec-Fetch-Site: same-originas the fallback), before any handler runs; the allowed origins come fromSITUS_ORIGINS, and locally they are the console's own. - The VM's
.envwires its name:SITUS_RP_ID/SITUS_RP_IDS,SITUS_ORIGINS,SITUS_AUTHORITY_PUBLIC_URLandSITUS_AUTHORITY_PATH_PREFIXfor the Approval API. /admin/reseton any service is never reachable through Caddy (nor through the front door).situs-verifywithout--keyssays first, in yellow, that the keys are the receipt's own and unpinned, and names the--keys <url>/.well-known/keys.jsoncommand (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 audiencesitus:<hash of the decision it attests>(the decision without itsattestationfield), 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 staysL1-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.