262 lines
12 KiB
Markdown
262 lines
12 KiB
Markdown
|
|
# Architecture Blueprint — Stage 1
|
||
|
|
|
||
|
|
**Repo:** informed-decision
|
||
|
|
**Workplan task:** `INFD-WP-0001-T05`
|
||
|
|
**Status:** draft, pending review
|
||
|
|
**Governing ruling:** `GH-DEC-2026-012` (answered `INFD-IN-0001`)
|
||
|
|
**Declarations:** `layer.yaml`, `pep-stance.yaml`
|
||
|
|
**Companions:** `ProductRequirementsDocument.md`, `UseCaseCatalog.md`,
|
||
|
|
`EvidenceModel.md`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. What this component is
|
||
|
|
|
||
|
|
**PEP-shaped**, ruled by `GH-DEC-2026-012`. Browser-facing; causes a protected
|
||
|
|
side effect on the far side of a decision (an approver entry recorded against an
|
||
|
|
approval object). Companion §5 owed, statute §6.4 in full. Not an Engine: it
|
||
|
|
holds no state another layer reads at runtime for a verdict.
|
||
|
|
|
||
|
|
It also emits a **presentation claim** — one fact, *what was shown* — under the
|
||
|
|
three limits in `layer.yaml`. No second catalog row.
|
||
|
|
|
||
|
|
**Nothing in this component renders an authorization decision.** No route
|
||
|
|
answers "may this actor do X".
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. Call graph
|
||
|
|
|
||
|
|
```
|
||
|
|
browser (approver)
|
||
|
|
│
|
||
|
|
┌───────────┴────────────┐
|
||
|
|
│ informed-decision │ PEP-shaped
|
||
|
|
│ (this repository) │
|
||
|
|
└──┬────┬────┬────────┬──┘
|
||
|
|
auth-code + PKCE │ │ │ │ presentation records,
|
||
|
|
│ │ │ │ dispositions, stance
|
||
|
|
▼ │ │ ▼ applications
|
||
|
|
key-cape │ │ audit-core ◄──── independent
|
||
|
|
(identity)│ │ evidence path
|
||
|
|
│ │ (limit L3)
|
||
|
|
▼ ▼
|
||
|
|
access-engine approval-engine
|
||
|
|
(decision — (read + entry;
|
||
|
|
consumed, never /consume)
|
||
|
|
never made)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.1 `key-cape` — identity
|
||
|
|
|
||
|
|
Authorization-code + **S256 PKCE**, public client, exact registered redirect.
|
||
|
|
Access token only; `id_token` is never evidence. Scopes
|
||
|
|
`[openid, approval:read, approval:approve]`. **Never `approval:consume`.**
|
||
|
|
|
||
|
|
Token must carry `aud=approval-engine`, `principal_type: human`, non-empty `sub`
|
||
|
|
and `scope`, and an `assurance` object.
|
||
|
|
|
||
|
|
**`assurance` shape is `key-cape`'s**, adopted as published in their
|
||
|
|
`docs/approval-engine-auth-contract.md` — `level` (`aal1|aal2`), `methods`,
|
||
|
|
`mfa`, `source`, `at`. This blueprint **cites** that document rather than
|
||
|
|
restating the shape, so it cannot drift. `at` is authentication time, not token
|
||
|
|
mint time; consumers needing freshness compare `at` rather than assuming it.
|
||
|
|
|
||
|
|
**Open — blocking T07.** A human access token cannot carry `tenant:platform`
|
||
|
|
today: the tenant claim on human tokens resolves from the directory user record,
|
||
|
|
which no adapter populates, so tokens fall back to `tenant:coulomb`.
|
||
|
|
`approval-engine` compares by exact equality and refuses near-misses. Resolution
|
||
|
|
is registration-bound (preferred here) or directory-sourced; it is not this
|
||
|
|
repository's alone to decide. See §7.
|
||
|
|
|
||
|
|
### 2.2 `access-engine` — the decision, consumed
|
||
|
|
|
||
|
|
A decision is obtained **before an approval is rendered to a person**. A `200`
|
||
|
|
from `approval-engine` is not entitlement; that engine never answers "may this
|
||
|
|
actor do X".
|
||
|
|
|
||
|
|
Consuming a decision is not rendering one. There is no local fallback: if
|
||
|
|
`access-engine` is unreachable, the stance applies (§5).
|
||
|
|
|
||
|
|
**Declared gap, `GH-DEC-2026-010`.** Obligation 1 requires the decision be
|
||
|
|
*attributable* to `access-engine`. No consumer can satisfy that today — the
|
||
|
|
envelope is unsigned (`FLEX-WP-0024`). This surface records
|
||
|
|
`decision_attributable: false`. **The decision path is not validated and must
|
||
|
|
not be described as such** while that is open.
|
||
|
|
|
||
|
|
### 2.3 `approval-engine` — the object and the entry
|
||
|
|
|
||
|
|
| Route | Method | Scope |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `/v1/approvals/{id}` | GET | `approval:read` |
|
||
|
|
| `/v1/approvals/{id}/claim` | GET | `approval:read` |
|
||
|
|
| `/v1/approvals/{id}/entries` | POST | `approval:approve` |
|
||
|
|
| `/v1/approvals/{id}/consume` | POST | **never called** |
|
||
|
|
|
||
|
|
Paths containing `check` or ending in `/authorize` return 404 by design; there
|
||
|
|
is nothing there to call.
|
||
|
|
|
||
|
|
`POST /entries` **discards its request body** — approver identity, `assurance`
|
||
|
|
and `evidence_ref` come only from the verified token. So `view_hash` does not
|
||
|
|
ride into the entry. Correlation is `(approval_id, subject, approved_at)`.
|
||
|
|
|
||
|
|
**No inbox.** Get-by-id only; no list, no search, no "awaiting me". Approval ids
|
||
|
|
arrive from whatever requested the approval. This component must never poll.
|
||
|
|
|
||
|
|
Only `accept` becomes a `POST …/entries`. `return`, `discuss`, `escalate` and
|
||
|
|
the rest are memo-level and are not represented in that engine at all.
|
||
|
|
|
||
|
|
### 2.4 `audit-core` — evidence, on an independent path
|
||
|
|
|
||
|
|
**Limit L3 is the architectural constraint of this design.** The evidence copy
|
||
|
|
must reach `audit-core` independently of this repository, because here the actor
|
||
|
|
being audited and the evidence source are the same component. The copy that is
|
||
|
|
evidence must not be reachable only through the party it is evidence about.
|
||
|
|
|
||
|
|
Consequences, binding on implementation:
|
||
|
|
|
||
|
|
1. **The claim endpoint and the evidence path are separate.** Serving a
|
||
|
|
presentation record over this component's API does not discharge the evidence
|
||
|
|
obligation, and emitting evidence does not remove the need for a claim.
|
||
|
|
2. **A reader must be able to obtain the evidence copy without this component's
|
||
|
|
cooperation.** If the only route to a presentation record is an API this
|
||
|
|
component controls, an adversary who controls it controls the evidence about
|
||
|
|
it.
|
||
|
|
3. **Emission is not best-effort.** A presentation or disposition that can be
|
||
|
|
recorded here while its evidence is lost is a state change that never
|
||
|
|
happened as far as an auditor can tell. `approval-engine`'s local-outbox
|
||
|
|
reasoning applies unchanged and is adopted rather than rediscovered: the
|
||
|
|
queue is local, in the same transaction as the state change, with no
|
||
|
|
synchronous `audit-core` dependency inside it.
|
||
|
|
4. **Atomicity covers accident, not compromise.** It does not close the residual
|
||
|
|
in `layer.yaml`, and must never be described as doing so.
|
||
|
|
|
||
|
|
Open for implementation: whether the independent path is `audit-core` pull,
|
||
|
|
a separately-credentialed push, or a third party drain. **T08 must not ship
|
||
|
|
without one chosen**, because "we will add the independent path later" is how
|
||
|
|
limit 3 quietly becomes limit 3-in-principle.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Internal shape
|
||
|
|
|
||
|
|
```
|
||
|
|
informed_decision/
|
||
|
|
├── canonicalize.py view_hash / awareness_hash (governed, tested)
|
||
|
|
├── stance.py the shipped unreachable-engine map (tested == published)
|
||
|
|
├── memo/ Decision Memo object, versions, lifecycle
|
||
|
|
├── presentation/ presentation records; the only writer of view_hash
|
||
|
|
├── disposition/ verb legality, guards G_*, thread
|
||
|
|
├── evidence/ local outbox, bundle export
|
||
|
|
└── clients/ key-cape, access-engine, approval-engine, audit-core
|
||
|
|
```
|
||
|
|
|
||
|
|
**`presentation/` is the only writer of `view_hash`.** One writer, one
|
||
|
|
canonicalizer, one place to audit. A second path that computes a hash is a
|
||
|
|
defect, not an optimisation.
|
||
|
|
|
||
|
|
**`clients/approval_engine` must not expose a validity cache.** No memoisation
|
||
|
|
of "is this approval valid". The engine is asked.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Storage
|
||
|
|
|
||
|
|
Stage 1 holds: memos and their versions; presentations with both hashes and
|
||
|
|
acked highlight ids; dispositions with their coded reasons; thread entries;
|
||
|
|
the local outbox; stance applications.
|
||
|
|
|
||
|
|
Properties required:
|
||
|
|
|
||
|
|
- **Presentations are append-only.** A presentation is a record of an event that
|
||
|
|
happened. Editing one is falsifying evidence.
|
||
|
|
- **Memo versions are immutable once presented.** A change creates version *n+1*
|
||
|
|
and invalidates outstanding presentations (guard `G_PRES`).
|
||
|
|
- **The outbox is in the same transactional store**, written in the same
|
||
|
|
transaction as the state change it evidences.
|
||
|
|
- **No approval state.** Not cached, not mirrored, not derived.
|
||
|
|
|
||
|
|
Engine choice is deferred to implementation; the constraint is that it be a
|
||
|
|
single transactional store, so the outbox cannot separate from the mutation.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Failure modes — stance per dependency
|
||
|
|
|
||
|
|
Default is **fail closed**. Every stance in `pep-stance.yaml` is `fail_closed`,
|
||
|
|
and that is a conclusion: refusing to bind delays a decision, it does not remove
|
||
|
|
a capability anyone needs to keep a system running. There is no continuity
|
||
|
|
argument here of the kind that justifies `fail_open` elsewhere.
|
||
|
|
|
||
|
|
| Dependency | Unavailable | Surface behaviour |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `key-cape` | no authentication | No session. Nothing rendered. |
|
||
|
|
| `access-engine` | no decision | **Stance applies: `fail_closed`.** Not rendered, not bound. Recorded as a stance application, never as a decline. |
|
||
|
|
| `approval-engine` 5xx / `503` | no object, or store down | Fail closed. Never present success. |
|
||
|
|
| `audit-core` | evidence destination down | Rendering and binding continue; evidence queues in the **local** outbox. An `audit-core` outage must not block a binding act — the same reasoning that keeps it from blocking a revocation upstream. |
|
||
|
|
|
||
|
|
**A fail-closed outcome is never rendered as a decline by the approver.** No
|
||
|
|
disposition is recorded, because the human did not make one. Conflating the two
|
||
|
|
would put a refusal in the record that no person authored.
|
||
|
|
|
||
|
|
Engine response mapping — `409 duplicate_approver` is **success**, `409
|
||
|
|
conflict` is terminal, `503` is fail-closed — is specified in `PR-06` and is not
|
||
|
|
restated here.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Deployment
|
||
|
|
|
||
|
|
`approval-engine` has no Ingress and no external origin, which is why it could
|
||
|
|
not own the browser client. **This component has both, and that is the point.**
|
||
|
|
|
||
|
|
- Public HTTPS origin, TLS terminated at ingress.
|
||
|
|
- Exact registered redirect URI, scheme and path included. Redirects match
|
||
|
|
exactly at `/authorize`, so a near-miss fails closed.
|
||
|
|
- **`client_id` and the callback URI are deployment inputs, not implementation
|
||
|
|
details.** Once chosen they are stable; changing one silently breaks login
|
||
|
|
rather than degrading it.
|
||
|
|
- Outbound to `approval-engine` and `access-engine` on cluster-internal
|
||
|
|
addresses; no public exposure of either is implied by this component's.
|
||
|
|
|
||
|
|
The concrete origin is fixed in `T07` alongside the client registration, and is
|
||
|
|
gated on the tenant question in §7.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. Open and blocking
|
||
|
|
|
||
|
|
**O-01 — Human token tenant.** Blocks T07. Registration-bound (this
|
||
|
|
repository's stated preference, as the tenant is then a property of the surface
|
||
|
|
and its registration — which is exactly what the pre-sign binding slice commits)
|
||
|
|
versus directory-sourced (which makes tenant a property of the person and
|
||
|
|
changes it everywhere). Not ours alone; raised with `key-cape`,
|
||
|
|
`approval-engine` and `gate-house`. If registration-bound is chosen, the
|
||
|
|
condition that it holds *only* because registrations are static and
|
||
|
|
deployment-owned should be written into the contract, not left as reasoning in a
|
||
|
|
message.
|
||
|
|
|
||
|
|
**O-02 — The independent evidence path.** Its mechanism is unchosen. Must be
|
||
|
|
resolved before T08 ships.
|
||
|
|
|
||
|
|
**O-03 — Requester identity for an engine-originated memo.** `approval-engine`
|
||
|
|
does not model a requester. Likely the approval's `principal`; needs confirming
|
||
|
|
against the object.
|
||
|
|
|
||
|
|
**O-04 — `abstain`.** In the state tables and real in Mitzeichnung practice, but
|
||
|
|
with no obvious `approval-engine` counterpart. Stage 1 scope undecided.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. What would make this architecture wrong
|
||
|
|
|
||
|
|
- A route that answers whether an act is permitted.
|
||
|
|
- A validity cache for approvals.
|
||
|
|
- A second writer of `view_hash`.
|
||
|
|
- An evidence path reachable only through this component.
|
||
|
|
- `view_hash` nested inside, or recomputed from, `approval-engine`'s binding
|
||
|
|
digest — co-reference only (`GH-DEC-2026-012` R3).
|
||
|
|
- A polling loop against `approval-engine` synthesising an inbox.
|
||
|
|
- A fail-closed outcome recorded as an approver's decline.
|
||
|
|
- Describing the decision path as validated while `GH-DEC-2026-010` is open.
|