Two rulings landed and both corrected something. GH-DEC-2026-013 accepted our binding-versus-awareness argument, wrote it into the record as its §6, and did not change the outcome — it sharpened the defect. Two different facts share one field named tenant: the act-scope, a property of the act that our binding slice commits, and the principal's membership, a property of the person that approval-engine exact-matches. Gate House's correction stands: a binding slice that must commit the scope being entered should commit that scope, not borrow a membership claim to stand in for it. Our schema already does — binding.target IS the act-scope and is inside view_hash — so no field was added, only a statement (PR-08) and a provenance record (PR-09), since key-cape emits tenant as a bare string. key-cape had already implemented registration-bound tenancy on 2026-09-09, correct under both candidate rulings, so the fail-closed-at-first-use risk that made us withhold the client strings was already retired. IN-0002 closed. The one remaining input to T07 is the deployed origin. GH-DEC-2026-014 granted commitment-only evidence and bounded it. It satisfies non-alteration and NOT reconstructability, and must not be described otherwise anywhere. It also corrected our wording of the gap: we wrote that it leaves us able to erase the content, which understates it. Commitment-only moves integrity out of our control and leaves availability entirely inside it — the party that can withhold the content is the party the evidence is about. Limit 3's condition reduced, not removed. The grant carries a condition we did not propose and would not have thought of: the path must assert that committed content exists and where custody sits, so non-production is a finding attributable to the custodian rather than an unremarkable blank. A commitment with no assertion that something is being committed to is indistinguishable from a commitment to nothing. Booked as PR-53, and marked not-a-reversal-candidate. Recorded the meta-rule Gate House named, now in its third setting here: unknown versus absent in the stance map, directory-asserted versus registration-supplied in the tenant claim, erased versus never held in the evidence path. Wherever a system reaches one appearance by two routes, the record must say which route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3W1dQG7GFFM9d94jFx7iR Assistant: claude-code Assistant-Model: opus Assistant-Process: 1565372@bnt-lap001 Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568
303 lines
15 KiB
Markdown
303 lines
15 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.
|
|
|
|
**Resolved.** `key-cape` implemented registration-bound tenancy on 2026-09-09
|
|
(`329e48f`), deliberately correct under both candidate rulings: a declared zone
|
|
applies where the directory places the user nowhere, agreement passes, and a
|
|
declared zone **conflicting** with a directory assignment refuses issuance with
|
|
`403 tenant_binding` rather than relabelling. `GH-DEC-2026-013` then ruled
|
|
directory-sourced the terminal state and granted the registration-bound shape as
|
|
a **declared bounded gap** — admissible precisely because its distinguishing
|
|
case fails closed.
|
|
|
|
**Build to it knowing it is transitional.** Two obligations land here:
|
|
|
|
- **Do not declare a tenant the directory record does not carry** unless
|
|
prepared to say so in the record. Declaring one asserts a fact about a person
|
|
on this registration's authority, which the ruling permits only as a gap.
|
|
- **Record the tenant claim's provenance.** `key-cape` emits `tenant` as a bare
|
|
string, so a consumer cannot distinguish a directory-asserted tenant from a
|
|
registration-supplied one. `GH-DEC-2026-013` §5 requires the claim to carry
|
|
provenance; until it does, this surface records which route the value arrived
|
|
by rather than storing an undifferentiated string.
|
|
|
|
**And do not use the `tenant` claim as the act-scope.** `binding.target` is the
|
|
act-scope and is committed in `view_hash`. See `EvidenceModel.md` §8c.
|
|
|
|
### 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.
|
|
|
|
**Cadence is owed**, because `layer.yaml` declares presentation evidence
|
|
load-bearing (§9.6). The form differs from `approval-engine`'s: their classes are
|
|
genuinely low-volume, so a heartbeat is the answer. Ours are mixed —
|
|
presentations are high-volume (one per render), while dispositions and stance
|
|
applications are low-volume and are the security-relevant ones. Proposed:
|
|
reconciliation per class as the primary form, plus a heartbeat for the
|
|
low-volume classes, since a quiet month of dispositions is indistinguishable
|
|
from suppression by rate alone. Depends on `AUDIT-WP-0009` T04/T06; declared,
|
|
not claimed operating.
|
|
|
|
What travels is the open question — see `docs/evidence-path-design.md` and O-02.
|
|
**T08 must not ship without it 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.~~ Closed 2026-09-10** by `GH-DEC-2026-013` and
|
|
`key-cape` `329e48f`. Our binding-versus-awareness argument was accepted and
|
|
written into the ruling as its §6 — and it did not change the outcome, it
|
|
sharpened the defect: two different facts (act-scope, and the principal's
|
|
membership) were sharing one field. The condition we asked for is now in
|
|
`key-cape`'s `docs/tenant-claim-contract.md`, strengthened from "must be
|
|
revisited" to **void** if the dynamic-registration exclusion is lifted, and
|
|
enforced by a test asserting the capability and the exclusion together. See
|
|
§2.1 for the two obligations that land on us.
|
|
|
|
**O-02 — The independent evidence path.** *Payload ruled: commitment-only
|
|
granted for Stage 1 by `GH-DEC-2026-014`, on the condition that the record carry
|
|
an assertion that content exists and where custody sits, so non-production is a
|
|
finding attributable to us rather than an unremarkable blank. It satisfies
|
|
non-alteration, not reconstructability, and must not be described otherwise. The
|
|
registration and cadence remain with `audit-core`.* Design and decision request
|
|
written:
|
|
`docs/evidence-path-design.md`, filed as `INFD-IN-0003`. Read independence and
|
|
the local transactional outbox are settled; the open question is **what
|
|
travels**, because a presentation record carries the brief and packet material
|
|
actually shown to a human. Proposal is commitment-only (hashes, principal,
|
|
timestamps, acks, co-referenced approval id) for Stage 1, with the erasure
|
|
residual declared rather than papered over. Awaiting `audit-core` on the payload
|
|
and the sender registration, and `gate-house` only if the content question is
|
|
doctrine. 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.
|
|
- Describing commitment-only evidence as reconstructability
|
|
(`GH-DEC-2026-014`).
|
|
- Using the token's `tenant` claim as the act-scope, or storing it without its
|
|
provenance (`GH-DEC-2026-013` §5).
|