From docs/ARCHITECTURE.md in the repository.
Architecture — Situs Pilot 1
Situs puts a deterministic authority between an AI agent and the systems it can move money in. The agent proposes. A signed Intent Contract and a versioned, signed Trust Profile decide. A human signs the exact action when the amount requires it. One enforcement point holds the only bank credential, and every step leaves a signed fact that ends up in a portable Proof Receipt anyone can verify offline.
Services and ports
| Service | Port | Holds | Talks to |
|---|---|---|---|
| gateway | 8000 | JWT dev secret. No bank credential. | authority, pep, the model (or the scripted stub) |
| authority | 8001 | authority and situs_assembly keys. Contracts, grants, evidence chain, receipts, transparency log; the Approval API (docs/APPROVAL_API.md) and the approver keys it enrolls (keys/enrolled/, authority-signed). | webhooks to registered approver apps (optional Rekor) |
| pep | 8002 | pep key and the broker secret, the only way to mint a bank token | authority, bank |
| bank | 8003 | bank key and the broker secret, to verify tokens. A mock bank. | nothing |
| console | 8080 | reads the people's key files only to sign: approve (carried as a registered approver app), revoke, a mandate, a profile version | authority |
CLIs: situs-demo runs the storyline, situs-approve signs pending grants, situs-verify checks a receipt offline. Keys live in ./keys as ed25519 PEM files, created by make keys. Each service writes its own SQLite file under ./data. The authority writes receipts to ./out.
flowchart LR
agent([agent]) -- JWT + contract id --> gateway[gateway :8000]
gateway -- propose / present --> authority[authority :8001]
gateway -- execute / fetch --> pep[pep :8002]
pep -- redeem, evidence --> authority
pep -- broker token --> bank[(bank :8003)]
console[console :8080] --> authority
approver([approver]) -- signs action hash --> authority
treasurer([treasurer]) -- signs contract, revocation --> authorityThe seven steps
The storyline from SPEC §1 as it runs through the services. make demo plays it with the scripted agent. Steps 1 to 5 are the live flow. Step 6 is offline. Step 7 is the bypass, shown for contrast.
sequenceDiagram
autonumber
actor T as Treasurer (principal)
actor A as Agent
participant G as gateway :8000
participant AU as authority :8001
actor AP as Approver
participant P as pep :8002
participant B as bank :8003
actor V as Verifier (offline)
T->>AU: POST /contracts (Intent Contract signed by principal_jdoe)
rect rgba(200, 80, 80, 0.12)
Note over A,AU: Step 1. Injected invoice
A->>G: propose_transfer to 9988776655 (from the injected invoice)
G->>AU: POST /grants (canonical action)
AU-->>AU: deterministic policy: beneficiary_not_approved
AU-->>G: grant status denied
G->>AU: request event, receipt assembled (deny, no bank block)
G-->>A: denied. No grant, no transfer
end
rect rgba(80, 140, 200, 0.12)
Note over A,AP: Step 2. Legitimate transfer over the approval threshold
A->>G: propose_transfer 75000.00 to ven_northwind
G->>AU: POST /grants
AU-->>G: grant pending with action_hash
AP->>AU: GET /grants/{id}, recompute action_hash, sign it (situs-approve)
AU-->>AU: grant active, single use, 10 min, idempotency key
end
rect rgba(80, 180, 120, 0.12)
Note over A,B: Step 3. Execution through the enforcement point
A->>G: propose_transfer with grant_id
G->>P: POST /execute (grant and canonical action verbatim)
P-->>P: verify grant signature, expiry, action hash, approver signature
P->>AU: POST /grants/{id}/redeem
P->>AU: enforcement_decision event signed by pep
P->>B: POST /transfers via connector.json: 5 min broker token, idempotency key, action hash
B-->>P: acknowledgment signed by bank, naming the action hash and idempotency key
P->>AU: counterparty_ack event
G->>AU: request event, receipt with five signatures, chained, logged
G-->>A: executed
end
rect rgba(200, 160, 60, 0.12)
Note over A,P: Step 4. Retry storm
loop three times
A->>G: propose_transfer with the same grant_id
G->>P: POST /execute
P-->>G: grant_already_redeemed with the original bank acknowledgment
G-->>A: duplicate. One transfer exists
end
end
rect rgba(150, 100, 200, 0.12)
Note over T,AU: Step 5. Revocation
T->>AU: POST /contracts/{id}/revoke (signed by principal_jdoe)
A->>G: propose_transfer 18400.00 to ven_contoso
G->>AU: POST /grants
AU-->>G: denied, contract_revoked, with a receipt
end
Note over V: Step 6. situs-verify out/receipt.json runs nine checks with no network
Note over A,B: Step 7. make demo-bypass: the agent holds a raw bank key and calls the bank directly. The wire executes. No Situs in the path.Where each fact is signed
| Fact | Signed by | Receipt slot | Verifier check |
|---|---|---|---|
| Intent Contract (scope, limits, window) | principal principal_jdoe | signatures.principal over contract_hash | 3 |
| The exact action | approver approver_jdoe | signatures.approver over action_hash | 4 |
| Allow or deny at execution | enforcement point pep (the authority key for a policy denial that never reached the pep) | signatures.pep | 5 |
| Money moved (the counterparty's acknowledgment, bound to the action hash and idempotency key) | the counterparty, bank for treasury (kid from connector.json) | signatures.counterparty over counterparty_ack_hash | 6 |
| The assembly of the above | situs_assembly | signatures.situs over the receipt body | 2 |
| Order and inclusion | authority over the signed tree head | status.log, bundle.log_root_signature | 7, 8 |
Situs signs only the assembly. Editing a fact after the fact breaks the signature of the party that owns it, even if the assembly is re-signed. The demo shows this in step 6.
The enforcement point and its connector (receipt 0.3, Task 29)
The pep names no plugin's domain. It loads each plugin's declarations (façades, profile, connector.json) and never imports its rules.py. One generic HTTP connector (services/pep/connector.py) reads the connector file:
- the base URL comes from the environment variable it names;
- the credential comes from the secret broker, sent in the header the connector names;
- each op has a method, a path template filled from the canonical params, and a body map;
- the grant's idempotency key and action hash go in the headers it names;
- the answer is checked against the counterparty's key (
signature.kid, with the hash and signature field names it declares) before anything else; - for an op that acknowledges an action, the fields at the
ackpaths must name the grant's action hash and idempotency key. Otherwise the pep records what it received and refuses withcounterparty_ack_unbound.
The counterparty_ack event carries the signed body with the field names and ack paths, so the offline verifier re-reads the acknowledgment without the plugin.
Developer docs, schemas and the venue bundle (Task 35)
situs_core/devdocs.py is the developer guide as data; the console's Docs tab renders it with the stack's live values and make docs writes docs/DEVELOPERS.md from it. situs_core/schemas.py generates the JSON Schemas (/docs/schema/<name>) from the pydantic models the services validate with. The authority's GET /plugins lists each loaded plugin's declarations (name, version, tier, façades, covered façade, counterparty kind), never its rules. situs_core/trust_bundle.json is the venue bundle the verifier ships with; situs_core/venues.py turns it into the "known venue" / "unknown key" label.
Canonical bytes
Every hash is SHA-256 over RFC 8785 JSON Canonicalization Scheme bytes, in situs_core/canon.py. Amounts are decimal strings, never floats. Timestamps are RFC 3339 UTC with a trailing Z. The approver, the enforcement point, and the verifier each recompute action_hash from the canonical action themselves. None of them trusts a hash it was handed.
What this build does not prove
Every receipt carries a proven and an unproven array. In this build the gateway's measurement is software only. what.level_achieved is L1-sim, and the hardware quote fields what.quotes.cpu_tdx and what.quotes.gpu are present and empty. The model's identity is claimed. docs/DEPLOY.md describes what changes for a real pilot.