Connect policy-gated browser review and audit runtime

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-11 00:31:03 +02:00
parent 2cc32168ac
commit 83849b75d4
35 changed files with 2381 additions and 83 deletions

View file

@ -4,13 +4,14 @@
> direction belongs in `INTENT.md`; the current stage belongs in `GOAL.md`;
> current work and gates belong in `workplans/`.
## Status — 2026-09-10
## Status — 2026-09-11
**The domain core, browser sign-in shell, Approval Engine HTTP adapter,
durable evidence store and Audit Core delivery adapter are implemented. The approval surface is not deployed.**
**The protected browser review, policy consumer, Approval Engine adapter,
durable evidence store and scheduled Audit Core delivery are implemented.
The approval surface is not deployed.**
What exists and is tested (258 tests, including explicit checks against the
actual Approval Engine and Audit Core implementations with synthetic identities):
What exists and is tested (344 tests and 12 Chromium checks, including actual
Flex Auth, Approval Engine and Audit Core with synthetic identity/custody):
- layer and stance declarations — `layer.yaml`, `pep-stance.yaml`,
`informed_decision/stance.py`, with published-equals-shipped asserted;
@ -26,24 +27,30 @@ actual Approval Engine and Audit Core implementations with synthetic identities)
profile, bounded server-side sessions, protected cookies and CSRF sign-out;
- `approval_http.py` and `http_transport.py` — get-by-id and human-entry
transport, declared-control checks, real entry correlation, no consume route
or automatic mutation retry. This adapter has no public browser mutation route;
or automatic mutation retry. Protected browser actions use this adapter;
- `store.py` / `records.py` — private durable packet/memo/presentation/
disposition storage, append-only acknowledgments, transactional outbox and
submission correlation, safe reservation and backup/restore;
- `audit.py` — idempotent Audit Core delivery, bounded retry/blocked states,
explicit per-class count/time-basis comparison. Heartbeats are generated
without hiding undelivered evidence; host scheduling remains pending.
without hiding undelivered evidence;
- `policy.py` / `review.py` / `ui.py` — fresh entitlement before rendering and
actions, exact response pins, immutable observations, required highlights,
accept/return/discuss/decline and visible original/unresolved entry state;
- `runtime.py` — explicit owner configuration, rotating credential-file readers,
30-second audit draining, heartbeat/reconciliation and delivery readiness.
Remaining: admitted policy package/caller and entitlement-before-render
integration, L3 review/acknowledgment/binding UI, policy observation persistence,
visible unresolved-entry recovery, scheduled independent audit delivery and
native deployment/custody proof. The legacy `evidence.Outbox` remains an
Remaining: native policy package/caller/assignment admission, registered human
login and deployed binding, independent production audit custody, packaging,
backup/restore and operator recovery admission. The legacy `evidence.Outbox` remains an
in-memory test double; the new `Store` supplies durable atomicity. Browser
sessions are ephemeral, with no approval state.
`/readyz` returns 503 until the protected approval path is connected.
The origin `decisions.coulomb.social` still serves an nginx placeholder.
Without owner runtime configuration `/readyz` returns 503. With it, readiness
requires recent audit delivery health; it does not assert native admission.
The origin's last observed deployment was an nginx placeholder on 2026-09-10.
See [browser-authentication.md](docs/browser-authentication.md) and
[durable-review-evidence.md](docs/durable-review-evidence.md).
[durable-review-evidence.md](docs/durable-review-evidence.md) and
[protected-browser-review.md](docs/protected-browser-review.md).
`INFD-WP-0001-T08` remains open for the live end-to-end proof, which is gated on
`APPROVAL-WP-0002-T01` and a deployed `approval-engine`.
@ -91,7 +98,7 @@ trail.
- The unreachable-engine stance map, built to v0.8 obligation 3, with
published-equals-shipped asserted by test (`tests/test_layer_conformance.py`).
**Built as domain operations with durable custody; protected HTTP integration remains:**
**Built with durable custody and protected HTTP integration:**
- The presentation record: what was rendered, to whom, when, in which locale and
UI release.
@ -102,10 +109,13 @@ trail.
- The browser-facing OIDC client: authorization-code + S256 PKCE against
`key-cape`, scopes `[openid, approval:read, approval:approve]`.
- An L3 approver surface calling `approval-engine`'s approval-entry mutation.
**Specified, not built:**
- An L3 approver surface calling `approval-engine`'s approval-entry mutation.
- The evidence bundle as an offline-verifiable export.
- German browser acceptance and the broader step/awareness UI. The current
browser profile refuses unsupported locale/step/level rather than overstating it.
## Out of Scope

View file

@ -1,7 +1,8 @@
# Browser authentication and Approval Engine transport
Implemented under `INFD-WP-0001-T08`. The service currently supplies sign-in and
sign-out. It does not yet render memos or expose an approval-entry route.
Implemented under `INFD-WP-0001-T08`. The service supplies sign-in and sign-out;
explicit owner configuration enables the protected memo and human-response
routes described in [protected-browser-review.md](protected-browser-review.md).
Install and exercise from the repository:
@ -25,8 +26,9 @@ The entrypoint listens on `127.0.0.1:8080` for a local reverse proxy. The public
origin/callback is fixed at `https://decisions.coulomb.social/auth/callback`;
Host, forwarded headers and return URLs cannot replace it. A development HTTP
listener does not replace this HTTPS callback registration. Deployment and
registration remain pending. `/healthz` checks this process; `/readyz` returns
503 because the protected approval path is incomplete.
registration rollout remain pending. `/healthz` checks this process; `/readyz`
returns 503 without owner review configuration and recent audit delivery health.
Readiness is not a declaration of native policy/login/custody admission.
## Identity boundary
@ -84,7 +86,7 @@ POST bodies are empty objects: Approval Engine derives identity and evidence
from its verified token and discards caller content. The adapter extracts
`(approval_id, subject_id, approved_at)` from the matching actual human entry,
never `updated_at` or local time. A `409 duplicate_approver` triggers a GET of
that original entry and returns `duplicate=True`. The eventual UI must preserve
that original entry and returns `duplicate=True`. The controller preserves
the original presentation correlation; it cannot attach a new presentation to
an old entry just because a duplicate exists. Other conflicts remain refusals.
@ -93,17 +95,12 @@ There is no automatic POST retry. Durable disposition processing must reconcile
the current engine entry and its correlation before reporting success or
offering a retry. A failed dependency must never be recorded as a human decline.
The client is an internal seam, not a sufficient binding flow. Before a browser
route can call it, T08 must connect:
The client is an internal seam. `review.py` now connects fresh PDP entitlement,
durable presentations, actor/version/acknowledgment guards, local responses,
the transactional outbox and the original-entry recovery rules. `runtime.py`
schedules bounded delivery and reconciliation. No consume route exists.
1. The access-engine entitlement decision before rendering a named memo.
2. Wire the durable presentation/version, actor match and required acknowledgments.
3. Wire persisted dispositions and the transactional outbox, including return/discuss.
4. Independent audit custody/delivery and entry-correlation reconciliation.
5. Native registered KeyCape login and the deployed Approval Engine proof.
The browser currently exposes no memo or entry route and no consume capability.
The legacy `evidence.Outbox` is an in-memory test double. `Store` now supplies
durable atomic state/evidence, and `audit.py` supplies delivery to the real
receiver contract; see [durable-review-evidence.md](durable-review-evidence.md).
Native custody, policy/controller integration and live admission stay in T08.
The legacy `evidence.Outbox` is an in-memory test double; `Store` supplies
durable atomic state/evidence. See [durable-review-evidence.md](durable-review-evidence.md).
Native policy/caller assignment, registered human login, deployment and
independent custody remain in T08. Disposable browser success does not close them.

View file

@ -1,9 +1,10 @@
# Durable review evidence and audit delivery
Implemented under `INFD-WP-0001-T08`. `store.py` replaces the domain tests'
in-memory outbox with a persistent internal store. It does not expose a browser
route, decide entitlement, or authorize an approval entry. `web.py` still has
no memo/entry routes and `/readyz` remains 503.
in-memory outbox with a persistent internal store. `review.py` and `web.py` now
connect it to the protected browser flow; the store itself never decides
entitlement. See [protected-browser-review.md](protected-browser-review.md)
for configuration, policy observations, schema v2 migration and readiness.
## Storage and recovery
@ -56,7 +57,7 @@ These are internal delivery records, not cached approval validity:
1. Record the human's accept intent and its evidence before an engine POST.
2. The controller must obtain a fresh applicable access-engine decision, verify
the session and compare the memo's carried digest with the live approval.
**That controller/policy integration is still pending.**
`review.py` supplies these checks and persists the exact observation before use.
3. `begin_submission` atomically reserves one attempt. A stale presentation
cannot start; a memo cannot be revised while its entry attempt is in flight
or unresolved. Network I/O happens outside the SQLite transaction.
@ -76,7 +77,8 @@ does not prove which presentation caused it. The store does not attach it to
the latest view or automatically POST again. A crash after reservation likewise
does not reopen dispatch. The records remain visible for explicit controller/
operator recovery; no recovery path in this slice fabricates that missing link.
The remaining T08 controller must handle this state visibly before shipping.
The controller and UI now expose this state and remove the submission button;
native operator recovery admission remains in T08.
## Audit Core contract
@ -103,8 +105,9 @@ and auditor identities. That is contract proof, not production custody proof.
Per-class heartbeat generation uses the existing 86,400-second declarations.
It does not emit “nothing to report” for a class with undelivered evidence.
The host must schedule heartbeat generation, bounded draining and monitoring;
no background service or cadence is installed by this source change.
The configured `AuditPump` schedules 30-second ticks, bounded draining and
five-minute reconciliation. The deployment owner must admit its private
storage, credentials and monitoring; no running service was installed here.
## Reconciliation has two time bases
@ -127,18 +130,20 @@ The GH-DEC-2026-014 commitment-only limitation remains.
```sh
INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine \
INFD_AUDIT_CORE_SOURCE=/home/worsch/audit-core \
INFD_FLEX_AUTH_BINARY=/absolute/path/to/flex-auth \
uv run python -m pytest -q
```
258 tests pass: 52 added tests cover transactional rollback, process death,
The prior 258-test milestone added 52 tests for transactional rollback, process death,
restore, actor/version/ack guards, concurrent clicks, original correlation,
ambiguous submissions, audit retry/refusal and actual receiver contracts.
The opt-in suites use the actual Approval Engine and Audit Core implementations
with synthetic identity/custody. They make no policy decision and spend nothing.
with synthetic identity/custody. The current 344-test suite also exercises the
actual Flex Auth HTTP server; 12 Chromium checks cover the complete disposable
browser flow. No native credential or paid provider is used.
Remaining under T08: an admitted PDP package/caller and exact read/bind request,
protected review/ack/accept/return/discuss routes, durable policy observations,
visible unresolved-entry recovery, scheduled audit delivery/reconciliation,
native registered human login and deployed-engine/custody proof. No Informed
Remaining under T08: native PDP package/caller/assignment admission against the
[exact consumer contract](flex-auth-review-contract.md), registered human login,
deployed-engine/custody proof and operator recovery/backup admission. No Informed
Decision policy package or registration was found in the checked Flex Auth
examples, registry and docs at `88b3543`; do not substitute a local allow rule.

View file

@ -0,0 +1,81 @@
{
"fixture_only": true,
"browser": "Chromium",
"checks_passed": 12,
"checks": [
"PKCE callback and secure browser session",
"Entitled page escapes memo and packet markup",
"Server refuses bypass of missing acknowledgments",
"Explicit acknowledgment unlocks the form",
"Complete attachment download stays behind entitlement",
"Return records a memo response without an engine entry",
"Mobile review fits the viewport",
"One actual approval entry, stable on browser reload",
"Audit receiver holds all commitments; unsigned decision gap retained",
"Real workload caller refusal hides review content",
"Sign-out clears the session; all forms carry the exact origin",
"No page errors or injected script execution"
],
"fixture_transport": "real HTTPS on ephemeral ports; synthetic issuer and TokenReview",
"native_human_login_proven": false,
"native_policy_admitted": false,
"factory_attempts": 0,
"paid_model_calls": 0,
"final_component_state": {
"entries": 1,
"intents": [
{
"disposition_id": "disp-8ad75d50-1222-4512-a74b-253a15e7d159",
"approval_id": "fixture",
"subject": "human-fixture",
"state": "confirmed",
"attempt": "980d4cd4-753e-40f7-8026-f21923a340fd",
"approved_at": "2026-09-10T22:29:05+00:00",
"presentation_id": "pres-5673438f-ad6c-4ce1-bf26-edfc9211e476",
"operation_id": "c60eaa0c-b627-4f59-8e84-d35fc69d4963"
}
],
"outbox_states": [
"delivered",
"delivered",
"delivered",
"delivered",
"delivered",
"delivered",
"delivered",
"delivered",
"delivered"
],
"policy_outcomes": [
"allow",
"allow",
"allow",
"allow",
"allow",
"allow",
"allow",
"allow",
"allow",
"allow",
"allow",
"caller_refused"
],
"decision_attributable": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"entry_posts": 1,
"consume_calls": 0,
"tokenreviews": 12
}
}

View file

@ -0,0 +1,93 @@
{
"schema": "informed-decision.protected-browser-review.v1",
"observed_at": "2026-09-10T22:29:50.412419+00:00",
"base_commit": "2cc32168acedb19698fa196911385745fbf897b1",
"contract_source_commits": {
"approval-engine": "a0a602976eef818f36dde35f76f7f2e589bd051b",
"audit-core": "5c0ad522fb36092aa7ec2e8d72f63a5a91853b5b",
"flex-auth": "88b354377c8e26b162f1234e673072f1c06dcd89",
"key-cape": "139994cfac28ff97163ce4bf263f2bb035bbe0a7"
},
"source_task": "INFD-WP-0001-T08",
"source_task_status": "progress",
"verification": {
"command": "INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine INFD_AUDIT_CORE_SOURCE=/home/worsch/audit-core INFD_FLEX_AUTH_BINARY=<compiled-flex-auth> make check",
"tests_passed": 344,
"tests_failed": 0,
"tests_skipped": 0,
"new_tests": 86,
"schema_json_valid": true,
"flex_binary_sha256": "c7a1f35aa2cd7cf2733272923ace7fc4b4e55171db4ef8f3d0d6629183dcf1fa",
"browser_checks_passed": 12,
"browser_receipt": "2026-09-11-browser-result.json",
"components": "actual Flex Auth HTTP server with caller enforcement and Approval Engine/Audit Core APIs; synthetic issuer, TokenReview, assignments and credentials"
},
"implemented": [
"fresh pinned PDP checks before render, download and each action, with separate workload caller",
"immutable policy observations and original act binding snapshot; decision_attributable remains false",
"English organizational approval UI with explicit acknowledgments and accept/return/discuss/decline",
"original confirmed entry and visible unresolved state; no automatic mutation retry",
"owner runtime configuration, rotating token file callbacks, scheduled bounded audit delivery and readiness",
"atomic SQLite schema v1-to-v2 upgrade; old UI-release/profile refusal"
],
"browser_findings_fixed": [
"no-referrer produced Origin:null on real form POST; same-origin pages preserve exact-origin CSRF checks"
],
"component_findings_fixed": [
"native caller plain-text 401/403 must remain caller_refused instead of policy_unavailable"
],
"source_sha256": {
"SCOPE.md": "b4568fe033b443d66833d1501fd5b04e01c9d6e95b44bae9666134e914ae758a",
"docs/browser-authentication.md": "9354f8a95914cb353b93256df9e85c455bcf425107f507dd8f1ba6206026794a",
"docs/durable-review-evidence.md": "f7f4a15faa479f3a132120a6fae27837bd2673afda93df7dbd29e8171a7911d0",
"docs/evidence/2026-09-11-browser-result.json": "f405f5d11ae695a9ca8098f6c6a5f125042a62c3af9e87f21bc5e266b6f5a397",
"docs/evidence/2026-09-11-recorded-desktop.png": "89f6e5396094155f2b1e04599f0a2b2d040f4dce004afa4dda752f234eef566a",
"docs/evidence/2026-09-11-review-desktop.png": "e050eceb62cb389d516e4a889c67b1610df3f5539d04efdbafa4874700e90ca3",
"docs/evidence/2026-09-11-review-mobile.png": "3a0b5b1e37b3a54c4eb34688b04bb38b18181b94106b062150fc1b70aa4f3f8a",
"docs/flex-auth-review-contract.md": "3324b7b022fb5397d02520310bf43e0bfbff8c919e95ba9fecc94334dae8caf9",
"docs/protected-browser-review.md": "28f5666a795997db6b6483ab6c3e0c6dab4cc7df948e57f1e5d02e5c45968317",
"informed_decision/approval_http.py": "203f462ff78f1409f2f924d4723197fdd502087c7f6da319f46ffffce2a9d478",
"informed_decision/http_transport.py": "fc9c1fd05cc89120d70bfcdb4c60fe244b81c8054d21505df8cfa9c5a229f7bc",
"informed_decision/memo.py": "697058863a4e6de2c143a2433eaaf5e15e5e7375dc5459af6911b146567e8b5d",
"informed_decision/oidc.py": "35b37bae67968c2179b22af8151afe2981d5468debf4488771eaf3121b75e221",
"informed_decision/policy.py": "7bf814a8332ff054bf5f1f651d8816a408138b1f197b17da0abb7f87411e8812",
"informed_decision/review.py": "c2d2aa1cf48d10d85b3606acf2830ab22701c1274221e5e2760d29901adfd786",
"informed_decision/runtime.py": "6bc6a74657f526743cca6c6cdc0a86fff43d8282e0b867dc9288344636abe5eb",
"informed_decision/store.py": "7a58b36d409cb4161b65985545e13573a5196779b4c6e95ca03ce45c6fada808",
"informed_decision/ui.py": "69fb7fc1794c8842f42e7f0f5a261c4cf64176053ef0a532db46ee6566621219",
"informed_decision/web.py": "00300dc25af3e272db7de748c255f8b1aa0b0182a54f2573e874ed5865da0fd8",
"pyproject.toml": "26e77b2da1ce7d6f1878fb6ca46fdb2acebbad0f6e17f5528d5a1f1d01defe42",
"tests/browser_fixture.py": "31feaf6e3351d9560498ffac2f06cae602f0762a01474bcff2c919c33db3cfcf",
"tests/browser_review.mjs": "0285f2cff70e5d569f31c7e99c8b3a5b5ce98b3eb51c2203de3f2e958f2ac684",
"tests/fixtures/review-policy/fixtures.json": "4c84a411383bfdbd32e15763ce2e934eefec570d93bc68389d6c81d11f3d4243",
"tests/fixtures/review-policy/pin.json": "91c77e4daa0a1c8c394e14e6b0c9d9c493ff21389fefa592656aa363c1104611",
"tests/fixtures/review-policy/policy.md": "cd0a5a79db840e1dad7cabece3976d1683387c71d8a5507476f49b421d180c24",
"tests/fixtures/review-policy/registry.json": "cd358b139f5987dcd0b4c8ac9fffee7d86951b15a22216f6ec21240a51ec41cf",
"tests/review_components.py": "415ef229aedd4def7af737274b4d341ddf4f40a912a8103a017d901b2cdecf2e",
"tests/test_browser_auth.py": "c70e24495d2f968c0a48e4082032666b4f4325f60d7e736c5a27d0fb99ef0f33",
"tests/test_http_transport.py": "d3dcf8051a7acfd152a583f7a865327ae60d880b9b07abc5f5fa9c693e95354e",
"tests/test_policy_client.py": "8ab6f8a06e8b28b4933e0d8d23650c9781a5365343fc4926080c7c2d5e2962ed",
"tests/test_review_component.py": "cd28100fbc66b64703f065c2a3638486043bee5e3e4a6e75de19eb58235f0d47",
"tests/test_review_controller.py": "df3ddfb60dd06728ee871225df96da13462d69f648f214a5ea1b040388c78d53",
"tests/test_review_runtime.py": "e230d9f590b86675b4d42f978cfd2bc769de404b3f5ad510a400ff2fcec62bbd",
"workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md": "49f68a11c301b436655144d4819e827d0ec036fdb2b4463d2679ce8c36cc3b3a"
},
"limits": [
"fixture-only policy; native caller/package/assignment admission pending",
"unsigned decisions remain unattributable under GH-DEC-2026-010 / FLEX-WP-0024",
"no native registration rollout, human login, deployment or audit custody proof",
"English organizational approve profile only; German and wider product acceptance remain T08",
"uncertain entry causation remains unresolved; no reset/retry shortcut",
"audit accepted_at and source occurred_at counts do not prove completeness or reconstructability",
"production packaging, private state, backup/restore and recovery admission pending"
],
"browser_routes_implemented": true,
"native_policy_admitted": false,
"native_human_login_proven": false,
"production_audit_custody_proven": false,
"deployed": false,
"factory_attempts": 0,
"paid_model_calls": 0,
"remaining_live_task": "INFD-WP-0001-T08",
"factory_gate_task": "HFACT-WP-0001-T03"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View file

@ -0,0 +1,104 @@
# Flex Auth review consumer contract
Source integration under `INFD-WP-0001-T08`; **production admission pending**.
This is the concrete consumer request for owner review. It does not grant a
role, register a caller or publish an allow policy. The checked Flex Auth source
at `88b354377c8e26b162f1234e673072f1c06dcd89` has no Informed Decision
production package in the examined examples, registry or docs.
## Caller and subject
`POST /v1/check` uses an independently provisioned workload credential, expected
to be a projected Kubernetes service-account token for audience `flex-auth`.
Flex Auth must admit the exact caller/system binding with TokenReview. The
fixture exercises `system:serviceaccount:informed-decision:review` bound to
system `informed-decision`; **that spelling is a proposal until the deployment
and policy owners admit it**. A human `approval-engine` token is never the
caller credential. Wrong service accounts and human-token substitution fail
before evaluation in the actual component test.
The subject comes from the verified KeyCape human session, including verified
roles and unchanged assurance facts. Membership tenant and act scope remain
distinct. The tenant provenance values are the consumer's typed
`directory-asserted` or `registration-supplied`; human provenance is
`authentication-derived`. Unknown routes refuse before a request is made.
The production package must deliberately interpret the admitted transitional
tenant route and action-specific MFA freshness; this surface does not infer a
mandate from membership, the named recipient match or a successful login.
## Closed request profile
```json
{
"id": "fresh-uuid-for-each-check",
"tenant": "tenant:platform",
"subject": {
"id": "VERIFIED-HUMAN-SUBJECT",
"type": "human",
"tenant": "tenant:platform",
"attributes": {
"tenant_source": "registration-supplied",
"principal_type_source": "authentication-derived",
"assurance": {"mfa": {"satisfied": true, "method": "totp", "at": "ISSUER-TIMESTAMP"}},
"roles": ["VERIFIED-ISSUER-ROLE"]
}
},
"action": "read",
"resource": {
"id": "memo:EXACT-MEMO-ID",
"type": "decision-memo",
"system": "informed-decision",
"tenant": "tenant:platform"
},
"context": {
"memo_version": 1,
"approval_id": "EXACT-APPROVAL-ID",
"approval_binding_digest": "CARRIED-NATIVE-SHA256-DIGEST"
},
"policy_version": "OWNER-ADMITTED-VERSION"
}
```
The assurance object above is illustrative; `build_request` carries the verified
issuer object without synthesizing stronger facts. Supported actions are
`read`, `acknowledge`, `accept`, `return`, `discuss`, `decline`. Read covers memo
rendering, original presentation retrieval and packet download. Other verbs
always get their own decision. Only accept can cause an Approval Engine POST.
No `consume`, wildcard action, `view_hash`, presentation id, acknowledgment
state or presentation claim is supplied. This prevents the renderer's evidence
from becoming an input to its own permission decision.
The owner must provide the exact package/version/digest, resource/subject
assignment source, admissible caller, scope and positive/negative fixtures.
The consumer does not guess an operator group or ship a local allow fallback.
The separately pending CCR-2026-0019 reader group does not automatically confer
permission to read or approve a memo.
## Required response and refusal behavior
The response is `flex-auth.decision-record.v1`, with matching request id,
submitted request digest, core actor/resource/action/context binding and exact
package/version/digest pins. The evaluated digest and registry snapshot digest
are observed from the owner; registry enrichment is not repeated locally.
`submitted_digest` implements this consumer's bounded Go JSON request profile,
not a second approval-act canonicalizer. Actual Go checks cover enrichment,
Unicode and HTML escaping; the native approval digest is always carried.
Only an allow with a current TTL and no obligations is supported. Maximum use
is 30 seconds from dispatch, bounded by the returned expiry and not-before;
there is no decision cache. A declared obligation needs explicit implementation
before it can be admitted. Deny, unsupported effects/obligations, stale or
mismatched responses and transport/caller failures all refuse. HTTP 401/403
remains `caller_refused` even when the native caller gate returns plain text.
Each obtained observation is append-only in the private evidence database,
before content exposure or an act. Diagnostic response text and bearer tokens
are not retained. Policy linkage is private content, not part of the emitted
presentation claim. The envelope remains unsigned, so
`decision_attributable=false`; native deployment cannot close `FLEX-WP-0024`.
`tests/fixtures/review-policy/` is deliberately a **fixture-only** package, with
one synthetic human/memo/approval and no production assignment. Its native
package pin proves the consumer seam and cannot be used as admission evidence.
Owner return must include admitted policy/caller pins and deployed allow/refusal
receipts for the exact native human/act before T08 can claim live binding.

View file

@ -0,0 +1,166 @@
# Protected browser review
Implemented under `INFD-WP-0001-T08`, release `0.2.0`. The configured service
now presents one named memo, records explicit highlight acknowledgments and
accept/return/discuss/decline responses, and submits an accepted request to
Approval Engine. This source has passed a disposable Chromium exercise; native
policy, human login, custody and deployment admission remain open.
This first browser profile accepts English, organizational `approve` memos
only. Other locales, weaker steps and AES/QES levels refuse before rendering,
so an English interface cannot attest to a German presentation or imply a
signature level it does not implement. German UI acceptance (PR-60), broader
steps and awareness controls remain T08 product work; the domain model retains
their vocabulary. The initial factory browser proof uses this bounded profile.
## Request and evidence path
1. KeyCape login verifies the human, paired token claims and MFA facts. The
browser holds only an opaque session cookie. The human access token is used
only for Approval Engine and never as the Flex Auth workload credential.
2. `review.py` checks the named recipient and requests a fresh Flex Auth `read`
decision before exposing memo/packet content or calling Approval Engine.
The [consumer contract](flex-auth-review-contract.md) defines all six actions.
3. The immutable policy observation is persisted before use. The returned
package/version/digest, submitted request digest, core binding and lifetime
must match. Unsupported effects/obligations and unavailable callers refuse.
Registry enrichment is observed, never reproduced by this consumer.
4. The live Approval Engine object must declare `human_control=true` and match
the memo's carried act digest. The requesting party and named human approver
remain distinct. The presentation privately retains the observed binding.
5. Every acknowledgment or response obtains another applicable decision. Accept
requires the current memo, required acknowledgments, a live human session,
current audit readiness and an open matching approval. Immediately before
the engine POST, the adapter rechecks the binding and the controller checks
policy/session freshness and reserves the single durable attempt.
6. Acceptance stores the original engine entry correlation. Reload does not
POST again. A lost response, in-flight attempt or unknown duplicate remains
visible and cannot acquire a new presentation link. Return, discussion and
decline record local human responses and never create an engine entry.
The governed canonicalizer remains the sole writer of `view_hash`. No
presentation/acknowledgment fact is submitted to its own permission decision.
Policy observations stay in private content, outside the presentation claim.
`decision_attributable=false` remains explicit: checking the current unsigned
envelope is not GH-DEC-2026-010 decision-path validation (`FLEX-WP-0024`).
The renderer requires `memo.ui_release=informed-decision@0.2.0`. An older memo
must receive a new immutable version; it is not silently relabelled or rendered
as though the old interface produced the current view. Schema v1 databases
migrate atomically to v2 by adding append-only policy observations, preserving
old evidence and submission records.
## Browser surface
| Route | Behavior |
| --- | --- |
| `GET /` | Sign-in or named memo identifier form; no approvals inbox |
| `GET /review?memo_id=...` | Entitled current memo; existing intent retrieves its original view |
| `GET /presentations/<id>` | Entitled historical presentation and current submission result |
| `GET /presentations/<id>/packet/<index>` | Fresh read permission and full attachment bytes |
| `POST /presentations/<id>/ack` | Explicit acknowledgment of selected highlights |
| `POST /presentations/<id>/act` | Accept, return, discuss or decline with an operation id |
Forms require the exact fixed origin and session CSRF token, with bounded bodies
and rejection of duplicate/unknown fields. Session expiry and actor/version
checks apply server-side, including when a user bypasses a disabled button.
The page names the complete request and packet as the object of acceptance.
Highlights do not narrow its scope. Untrusted memo text is escaped, previews
are bounded text, and downloads are attachments with `nosniff`.
Pages use `Referrer-Policy: same-origin`, while authentication paths and
downloads use `no-referrer`. The Chromium test caught that using `no-referrer`
on a form page produces `Origin: null` and rejects legitimate submissions.
Missing, foreign and null form origins remain refused. Cross-origin referrers,
inline scripts, framing and caching remain prohibited. There is no telemetry
or client-side token storage. The reverse proxy must still omit auth queries,
bearer headers and cookies from its logs.
## Owner configuration and audit readiness
Set `INFD_REVIEW_CONFIG` to an absolute, owned, regular 0600 JSON file. Its
parent evidence directory must already exist with mode 0700. Without this
configuration, the entrypoint remains the login shell and `/readyz` stays 503.
The following is a shape example, **not an admitted configuration**; the
placeholders intentionally do not pass validation:
```json
{
"schema": "informed-decision.review-runtime.v1",
"evidence_db": "/OWNER-PRIVATE-PATH/review.sqlite",
"approval_origin": "https://APPROVAL-OWNER-ORIGIN",
"policy": {
"origin": "https://FLEX-OWNER-ORIGIN",
"package": "OWNER-ADMITTED-PACKAGE",
"version": "OWNER-ADMITTED-VERSION",
"package_digest": "OWNER-ADMITTED-SHA256",
"caller_token_file": "/OWNER-PROJECTED-PATH/flex-caller-token"
},
"audit": {
"origin": "https://AUDIT-OWNER-ORIGIN",
"sender_token_file": "/OWNER-PRIVATE-PATH/audit-sender-token"
}
}
```
`INFD_KEYCAPE_ISSUER` remains mandatory. Credential files are read afresh when
needed, so projected rotation does not require storing a token in configuration
or SQLite. Runtime loading provisions nothing. Fixed internal `.svc` origins
are supported explicitly; public cleartext origins and redirects are refused.
The server remains one Waitress process, loopback port 8080, four threads.
Multi-replica sessions/storage and deployment packaging require separate work.
The audit thread ticks every 30 seconds, generates the declared per-class
heartbeats, drains at most ten records per tick and writes a private
`audit-reconciliation.json` at most every five minutes. A successful drain and
reconciliation with no retained delivery debt make readiness current for 90
seconds. Errors or blocked records clear readiness; accept refuses while it is
closed. This is a process/delivery health signal, not an assertion of native
policy admission or authorization. Dispositions still persist transactionally.
Credential/schema refusals need operator repair and explicit requeue; no failed
event is discarded. Source `occurred_at` and receiver `accepted_at` are retained
as different count windows, without an automatic loss or completeness claim.
An independent auditor reads the receiver in component tests. Production
sender/receiver custody, backup/restore and reconciliation procedures remain
admission work under T08 and `AUDIT-WP-0009-T11`.
Uncertain entry recovery must preserve causation. Finding a later entry is
insufficient because Approval Engine stores no caller presentation/operation
id. Inspect the original immutable intent and independent evidence; never reset
an unresolved attempt merely to make the button available. If causation cannot
be established, keep it unresolved and obtain an owner-directed disposition of
the approval. This UI offers no shortcut that fabricates the missing link.
## Reproduce the component and browser checks
Build the checked Flex Auth source with its own `go build ./cmd/flex-auth`, then
set explicit source paths and the resulting binary:
```sh
export INFD_APPROVAL_ENGINE_SOURCE=/path/to/approval-engine
export INFD_AUDIT_CORE_SOURCE=/path/to/audit-core
export INFD_FLEX_AUTH_BINARY=/absolute/path/to/flex-auth
make check
```
With the Python dev dependencies, Playwright and Chromium installed, start
`PYTHONPATH=. python tests/browser_fixture.py /tmp/infd-browser-UNIQUE` and run
`node tests/browser_review.mjs /tmp/infd-browser-UNIQUE` in another terminal.
`INFD_PLAYWRIGHT_PACKAGE` can name an installed Playwright package, and
`INFD_CHROMIUM_EXECUTABLE` can name its Chromium executable. The driver stops
the fixture and writes the result plus desktop/mobile screenshots.
The harness uses real local HTTPS, browser redirects/forms/cookies, the actual
Flex Auth server with enforced caller authentication, and the actual Approval
Engine/Audit Core APIs with separate SQLite stores. Its issuer, TokenReview,
policy assignments, identities, credentials and act are synthetic. Browser
hostnames resolve to loopback; only the fixture certificate's public key is
trusted by the override. The harness control endpoints live only in tests.
Exact production callback/registration strings remain covered separately.
The result covers login, escaped content, acknowledgment bypass refusal,
explicit acknowledgment, packet download, return, mobile fit, a single accepted
entry/reload, receiver delivery, workload caller refusal, sign-out and absence
of script execution. It proves no native human login, production policy/custody,
deployed UI or factory execution. See the dated JSON evidence for exact sources.

View file

@ -57,8 +57,12 @@ class ApprovalHTTPClient:
def get_approval(self, approval_id: str) -> dict:
return self._call("GET", approval_id)
def add_entry(self, approval_id: str) -> EntryResult:
self.get_approval(approval_id) # Require the declared human-control object before mutation.
def add_entry(self, approval_id: str, *, expected_binding_digest=None, before_post=None) -> EntryResult:
current = self.get_approval(approval_id)
if expected_binding_digest is not None and current["binding"]["digest"] != expected_binding_digest:
raise ApprovalEngineError(409, "binding_changed")
if before_post is not None:
before_post() # Recheck session/policy and reserve AFTER the last read.
duplicate = False
try:
data = self._call("POST", approval_id, "/entries")

View file

@ -64,9 +64,18 @@ class JSONTransport:
raise TransportError("upstream redirect refused")
raw = response.read(262145)
if len(raw) > 262144:
if status >= 400:
return status, {} # Refusal status is known; discard the body.
raise TransportError("upstream response too large")
result = json.loads(raw)
try:
result = json.loads(raw)
except (ValueError, UnicodeError):
if status >= 400:
return status, {} # Flex Auth's caller gate uses plain HTTP errors.
raise
if not isinstance(result, dict):
if status >= 400:
return status, {}
raise TransportError("upstream object required")
return status, result
except (URLError, OSError, ValueError, HTTPException):

View file

@ -189,7 +189,7 @@ class Memo:
packet: tuple[PacketItem, ...] = ()
highlights: tuple[Highlight, ...] = ()
locale: str = "en"
ui_release: str = "informed-decision@0.1.0"
ui_release: str = "informed-decision@0.2.0"
#: Co-reference to the act this memo presents.
approval_id: str | None = None
#: approval-engine's binding.digest over the five act fields, CARRIED here
@ -276,8 +276,8 @@ class Memo:
*on whose behalf* the approval was issued; ours is the person being
bound the approver. Different roles, so dropping ours would remove
*who was shown this* from `view_hash` and gut the promise this
repository exists to make. Raised with approval-engine rather than
assumed; if the two are the same field, this drops too.
repository exists to make. Approval Engine confirmed these distinct
roles in docs/approval-claim.md (a0a6029); this field remains.
"""
out: dict = {"principal": self.binding.principal.as_document()}
if self.approval_binding_digest is None:

View file

@ -39,6 +39,7 @@ class HumanSession:
expires_at: float
access_token: str = field(repr=False)
csrf: str = field(default_factory=lambda: secrets.token_urlsafe(32), repr=False)
roles: tuple[str, ...] = ()
class KeyCapeLogin:
@ -168,7 +169,7 @@ class KeyCapeLogin:
assert_human_control_dischargeable(human)
session = HumanSession(access["sub"], Claim(access["tenant"], tenant_route), human,
dict(assurance), min(identity["exp"], access["exp"], self.clock() + 900),
tokens["access_token"])
tokens["access_token"], roles=tuple(access["roles"]))
with self._lock:
self._prune()
if len(self._sessions) >= self.capacity:

184
informed_decision/policy.py Normal file
View file

@ -0,0 +1,184 @@
"""Consume Flex Auth decisions for this surface; never evaluate local policy.
The submitted digest follows Flex Auth's published Go wire contract. It is
not the approval act digest, which this package only carries unchanged.
"""
from dataclasses import dataclass
from datetime import datetime
import hashlib
import json
import re
import time
import uuid
from .http_transport import JSONTransport, TransportError, fixed_origin
from .oidc import HumanSession
DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
ACTIONS = frozenset({"read", "acknowledge", "accept", "return", "discuss", "decline"})
CONTRACT = "flex-auth.decision-record.v1"
class PolicyError(RuntimeError):
"""Only bounded codes, never tokens or upstream diagnostic text."""
def _sorted(value):
if isinstance(value, dict):
return {k: _sorted(value[k]) for k in sorted(value)}
if isinstance(value, list):
return [_sorted(v) for v in value]
if value is None or type(value) in (str, bool):
return value
if type(value) is int and abs(value) <= 2**53:
return value
raise ValueError("request profile contains an unsupported value")
def submitted_digest(request):
"""Exact submitted tuple: Go struct order, sorted maps, HTML escaping.
Supports this consumer's closed JSON profile (no floats or CARING input).
Real Flex Auth tests pin this against api.RequestDigest and enrichment.
"""
def ref(value, fields):
return {k: _sorted(value[k]) for k in fields if value.get(k)}
material = {"tenant": request["tenant"],
"subject": ref(request["subject"], ("id", "type", "tenant", "attributes")),
"action": request["action"],
"resource": ref(request["resource"], ("id", "type", "system", "tenant", "attributes"))}
if request.get("context"):
material["context"] = _sorted(request["context"])
encoded = json.dumps(material, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
for char, escaped in (("<", r"\u003c"), (">", r"\u003e"), ("&", r"\u0026"),
("\u2028", r"\u2028"), ("\u2029", r"\u2029")):
encoded = encoded.replace(char, escaped)
return "sha256:" + hashlib.sha256(encoded.encode()).hexdigest()
def build_request(session: HumanSession, memo, action, policy_version):
if action not in ACTIONS:
raise ValueError("unsupported review action")
return {"id": str(uuid.uuid4()), "tenant": "tenant:platform",
"subject": {"id": session.subject, "type": "human", "tenant": session.tenant.value,
"attributes": {"tenant_source": session.tenant.route.value,
"principal_type_source": session.principal_type.route.value,
"assurance": dict(session.assurance), "roles": list(session.roles)}},
"action": action,
"resource": {"id": "memo:" + memo.id, "type": "decision-memo",
"system": "informed-decision", "tenant": "tenant:platform"},
"context": {"memo_version": memo.version, "approval_id": memo.approval_id,
"approval_binding_digest": memo.approval_binding_digest},
"policy_version": policy_version}
def _timestamp(value):
if not isinstance(value, str):
raise ValueError("timestamp required")
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("timezone required")
return parsed.timestamp()
@dataclass(frozen=True)
class Observation:
request_json: str
decision_json: str | None
outcome: str
usable_until: float
response_sha256: str | None
def require_current(self, now):
if self.outcome != "allow":
raise PolicyError(self.outcome)
if now >= self.usable_until:
raise PolicyError("decision_expired")
class PolicyClient:
def __init__(self, origin, token_provider, *, package, version, package_digest,
transport=None, clock=time.time, allow_internal_http=False):
self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http)
if (not isinstance(package, str) or not package or not isinstance(version, str) or not version
or not isinstance(package_digest, str) or not DIGEST.fullmatch(package_digest)):
raise ValueError("exact policy package, version and digest pins are required")
self.package, self.version, self.package_digest = package, version, package_digest
self.token_provider = token_provider
self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http)
self.clock = clock
def check(self, request):
# Round-trip before dispatch: the observation always records the exact
# submitted object, even if another caller mutates its input later.
raw = json.dumps(request, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
request = json.loads(raw)
submitted = submitted_digest(request)
started = self.clock()
try:
token = self.token_provider() # Workload credential, NOT the browser token.
if (not isinstance(token, str) or not token or not token.isascii()
or len(token) > 32768 or any(ord(c) < 33 or ord(c) > 126 for c in token)):
raise ValueError()
except (OSError, ValueError):
return Observation(raw, None, "caller_unavailable", 0, None)
try:
status, data = self.transport.request("POST", self.origin + "/v1/check",
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"},
body=raw.encode())
except TransportError:
return Observation(raw, None, "policy_unavailable", 0, None)
# Retain no diagnostic/reason text, including on transport/auth failures.
try:
response_digest = "sha256:" + hashlib.sha256(json.dumps(data, sort_keys=True,
ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
except (TypeError, ValueError, UnicodeError):
return Observation(raw, None, "invalid_decision", 0, None)
if status != 200:
return Observation(raw, None, "caller_refused" if status in (401, 403) else "policy_unavailable", 0, response_digest)
try:
if data.get("contract_version") != CONTRACT or data.get("request_id") != request["id"]:
raise ValueError()
if not isinstance(data.get("id"), str) or not data["id"] or len(data["id"]) > 256:
raise ValueError()
binding, provenance = data["binding"], data["provenance"]
if (binding["submitted_request_digest"] != submitted
or not DIGEST.fullmatch(binding["request_digest"])
or binding["tenant"] != request["tenant"] or binding["action"] != request["action"]
or binding.get("context", {}) != request.get("context", {})):
raise ValueError()
# Registry may enrich attributes; core actor/resource identity must
# still name this request. Never reproduce registry enrichment.
for key, names in (("subject", ("id", "type", "tenant")),
("resource", ("id", "type", "system", "tenant"))):
for name in names:
if binding[key].get(name) != request[key].get(name) or data[key].get(name) != binding[key].get(name):
raise ValueError()
if (provenance["policy_package"] != self.package or provenance["policy_version"] != self.version
or provenance["policy_package_digest"] != self.package_digest
or data.get("matched_policy_version") != self.version
or not DIGEST.fullmatch(provenance["registry_snapshot_digest"])
or not isinstance(provenance.get("evaluator"), str) or not provenance["evaluator"].startswith("flex-auth/")):
raise ValueError()
now = self.clock()
decided = _timestamp(provenance["decision_time"])
if decided > now + 30 or decided < started - 30:
raise ValueError()
safe = {k: data[k] for k in ("id", "contract_version", "request_id", "effect",
"matched_policy_version", "subject", "resource", "binding", "provenance")}
effect = data.get("effect")
if effect not in {"allow", "deny", "redact", "audit_only", "not_applicable"}:
raise ValueError()
until = 0
outcome = "policy_denied" if effect == "deny" else "unsupported_effect"
if effect == "allow":
life = data["lifetime"]
until = min(_timestamp(life["expires_at"]), started + 30)
if life["kind"] != "ttl" or now >= until or _timestamp(life.get("not_before", provenance["decision_time"])) > now:
raise ValueError()
safe["lifetime"] = life
outcome = "allow" if data.get("obligations", []) == [] else "unsupported_obligations"
return Observation(raw, json.dumps(safe, ensure_ascii=False, separators=(",", ":")), outcome, until, response_digest)
except (KeyError, TypeError, ValueError, AttributeError):
return Observation(raw, None, "invalid_decision", 0, response_digest)

176
informed_decision/review.py Normal file
View file

@ -0,0 +1,176 @@
"""Protected review orchestration. Policy comes only from the injected PDP client."""
from dataclasses import dataclass
import time
from .approval_client import ApprovalEngineError
from .disposition import Actor, ActorKind, Verb
from .memo import BindingLevel, StepKind
from .oidc import HumanSession
from .policy import PolicyError, build_request
from .provenance import Route, assert_human_control_dischargeable
from .store import Conflict
UI_RELEASE = "informed-decision@0.2.0"
class ReviewError(RuntimeError):
def __init__(self, status, code):
super().__init__(code)
self.status, self.code = status, code
@dataclass(frozen=True)
class ReviewPage:
memo: object
presentation: object
binding: dict
documents: dict
intent: dict | None
stale: bool
engine_status: str
dispositions: tuple = ()
class ReviewController:
def __init__(self, store, policy, approval_factory, *, clock=time.time):
self.store, self.policy, self.approval_factory = store, policy, approval_factory
self.clock = clock
def _session(self, session):
if not isinstance(session, HumanSession) or session.expires_at <= self.clock():
raise ReviewError(401, "session_expired")
assert_human_control_dischargeable(session.principal_type)
if session.tenant.value != "tenant:platform" or session.tenant.route not in (Route.DIRECTORY, Route.REGISTRATION):
raise ReviewError(403, "wrong_identity")
def _memo(self, session, memo):
self._session(session)
# Stage 1 is a named approver, not a mandate discovery mechanism. This
# structural match never grants entitlement; a PDP allow is still owed.
if memo.binding.principal.id != session.subject or memo.binding.principal.kind != "person":
raise ReviewError(403, "wrong_recipient")
if not memo.approval_id or not memo.approval_binding_digest:
raise ReviewError(409, "missing_act_binding")
if memo.ui_release != UI_RELEASE:
raise ReviewError(409, "renderer_changed")
if (memo.locale != "en" or memo.binding_level is not BindingLevel.ORGANIZATIONAL
or memo.step_kind is not StepKind.APPROVE):
raise ReviewError(409, "unsupported_review_profile")
def _authorize(self, session, memo, action):
self._memo(session, memo)
request = build_request(session, memo, action, self.policy.version)
observation = self.policy.check(request)
policy_id = self.store.observe_policy(observation)
try:
observation.require_current(self.clock())
except PolicyError:
if observation.outcome == "policy_unavailable":
self.store.record_unreachable(memo.id, "access-engine")
raise ReviewError(403 if observation.outcome == "policy_denied" else 503, observation.outcome) from None
self._session(session)
return policy_id, observation
def _approval(self, session, memo):
current = self.approval_factory(session).get_approval(memo.approval_id)
if current["binding"]["digest"] != memo.approval_binding_digest:
raise ReviewError(409, "binding_changed")
return current
def open(self, session, memo_id):
self._session(session)
memo = self.store.memo(memo_id)
policy_id, observation = self._authorize(session, memo, "read")
current = self._approval(session, memo)
observation.require_current(self.clock())
self._session(session)
intent = self.store.intent_for(memo.approval_id, session.subject)
if intent is not None:
# Recover the original view instead of assigning an existing entry
# or uncertain attempt to a freshly rendered presentation.
return self.load(session, intent["presentation_id"])
p = self.store.present(memo.id, principal_sub=session.subject, tenant=session.tenant,
principal_type=session.principal_type, expected_version=memo.version,
policy_id=policy_id, approval_binding=current["binding"])
saved, p, docs = self.store.retrieve_presentation(p.id)
return ReviewPage(saved, p, current["binding"], docs, None, False, current["status"])
def _presentation(self, session, presentation_id, *, current=False):
self._session(session)
p = self.store.presentation(presentation_id)
if p.principal_sub != session.subject:
raise ReviewError(403, "wrong_recipient")
memo = self.store.memo(p.memo_id, p.memo_version)
self._memo(session, memo)
if current and self.store.memo(memo.id).version != memo.version:
raise ReviewError(409, "stale_presentation")
return memo, p
def load(self, session, presentation_id):
memo, p = self._presentation(session, presentation_id)
_, observation = self._authorize(session, memo, "read")
current = self._approval(session, memo)
content = self.store.presentation_content(p.id)
binding = content.get("approval_binding")
if not isinstance(binding, dict) or binding.get("digest") != memo.approval_binding_digest:
raise ReviewError(409, "missing_act_binding")
observation.require_current(self.clock())
self._session(session)
saved, p, docs = self.store.retrieve_presentation(p.id)
return ReviewPage(saved, p, binding, docs,
self.store.intent_for(memo.approval_id, session.subject),
self.store.memo(memo.id).version != memo.version, current["status"],
tuple(self.store.dispositions_for(p.id)))
def acknowledge(self, session, presentation_id, highlight_ids):
memo, p = self._presentation(session, presentation_id, current=True)
policy_id, observation = self._authorize(session, memo, "acknowledge")
observation.require_current(self.clock())
return self.store.acknowledge(p.id, Actor(session.subject, ActorKind.PERSON),
highlight_ids, policy_id=policy_id)
def act(self, session, presentation_id, verb, *, operation_id, reasons=(), note=None):
if verb not in (Verb.ACCEPT, Verb.RETURN, Verb.DISCUSS, Verb.DECLINE):
raise ReviewError(400, "unsupported_action")
memo, p = self._presentation(session, presentation_id, current=True)
policy_id, observation = self._authorize(session, memo, verb.value)
if verb is Verb.ACCEPT:
original = self.store.intent_for(memo.approval_id, session.subject)
if original is not None and (original["presentation_id"] != p.id or original["state"] != "prepared"):
return original["presentation_id"]
current = self._approval(session, memo)
entries = current.get("entries", [])
if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries):
raise ReviewError(503, "invalid_approval_response")
if any(e.get("subject_id") == session.subject for e in entries):
raise ReviewError(409, "existing_entry_unlinked")
if current["status"] not in ("requested", "approved"):
raise ReviewError(409, "act_unavailable")
observation.require_current(self.clock())
self._session(session)
d = self.store.record_disposition(p.id, Actor(session.subject, ActorKind.PERSON), verb,
operation_id=operation_id, reasons=reasons, note=note, policy_id=policy_id)
if verb is not Verb.ACCEPT:
return p.id
attempt = None
def before_post():
nonlocal attempt
observation.require_current(self.clock())
self._session(session)
attempt = self.store.begin_submission(d.id, policy_id=policy_id)
try:
result = self.approval_factory(session).add_entry(memo.approval_id,
expected_binding_digest=memo.approval_binding_digest, before_post=before_post)
except ApprovalEngineError:
if attempt is None:
raise
self.store.finish_submission(d.id, attempt) # May have committed; never POST again.
except Conflict:
if attempt is not None or self.store.submission(d.id)["state"] == "prepared":
raise
# Another request won the durable reservation. Its original view
# now exposes the in-flight/result state; no second POST occurs.
else:
self.store.finish_submission(d.id, attempt, result)
return p.id

View file

@ -0,0 +1,130 @@
"""Owner-configured runtime and scheduled durable audit delivery.
This loads explicit configuration; it neither provisions credentials nor admits
a deployment. Projected caller tokens and Audit Core sender custody have owners.
"""
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import stat
import tempfile
import threading
import time
from .approval_http import ApprovalHTTPClient
from .audit import AuditCoreSink, OutboxWorker
from .policy import PolicyClient
from .review import ReviewController
from .store import Store
def token_file(path):
path = Path(path)
if not path.is_absolute():
raise ValueError("credential file path must be absolute")
def read():
with path.open("rb") as handle:
value = handle.read(32769)
if len(value) > 32768:
raise ValueError("credential file too large")
return value.decode("ascii").strip()
return read
class AuditPump:
"""One process, bounded batches, 30s ticks; never drops a failed record."""
def __init__(self, store, sink, *, clock=time.time):
self.store, self.worker, self.clock = store, OutboxWorker(store, sink), clock
self._stop = threading.Event()
self._thread = None
self._lock = threading.Lock()
self._last_ok = None
self._last_reconciled = 0
def ready(self):
with self._lock:
return self._last_ok is not None and self.clock() - self._last_ok < 90
def tick(self):
try:
self.store.queue_heartbeats()
result = self.worker.run_once(limit=10)
now = self.clock()
if now - self._last_reconciled >= 300:
stamp = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat(timespec="microseconds")
report = self.worker.reconcile(stamp(now - 86400), stamp(now))
# Report the two time bases, not an invented loss/completeness
# result. A retained private snapshot is for operator inspection.
directory = self.store.path.parent
fd, temporary = tempfile.mkstemp(prefix=".reconciliation-", dir=directory)
try:
with os.fdopen(fd, "w") as handle:
json.dump(report, handle, sort_keys=True)
handle.write("\n"); handle.flush(); os.fsync(handle.fileno())
os.replace(temporary, directory / "audit-reconciliation.json")
finally:
if os.path.exists(temporary):
os.unlink(temporary)
self._last_reconciled = now
healthy = not result["retrying"] and not result["blocked"] and not any(
row["state"] != "delivered" for row in self.store.outbox())
with self._lock:
self._last_ok = now if healthy else None
except Exception:
# Keep readiness closed and retry next tick. Never log response
# bodies, file paths, bearer tokens or a fabricated human decline.
with self._lock:
self._last_ok = None
def start(self):
if self._thread is not None:
raise RuntimeError("audit delivery already started")
def run():
while not self._stop.is_set():
self.tick()
self._stop.wait(30)
self._thread = threading.Thread(target=run, name="infd-audit-delivery", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=6)
@dataclass(frozen=True)
class Runtime:
controller: ReviewController
pump: AuditPump
@classmethod
def from_file(cls, filename):
path = Path(filename)
info = path.lstat()
if (not path.is_absolute() or not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600
or info.st_size > 16384):
raise ValueError("runtime configuration must be an owned private 0600 file")
data = json.loads(path.read_text())
if (set(data) != {"schema", "evidence_db", "policy", "approval_origin", "audit"}
or data["schema"] != "informed-decision.review-runtime.v1"
or set(data["policy"]) != {"origin", "package", "version", "package_digest", "caller_token_file"}
or set(data["audit"]) != {"origin", "sender_token_file"}):
raise ValueError("invalid review runtime configuration")
if not Path(data["evidence_db"]).is_absolute():
raise ValueError("absolute evidence database path required")
from .http_transport import fixed_origin
approval_origin = fixed_origin(data["approval_origin"], allow_internal_http=True)
policy = data["policy"]
client = PolicyClient(policy["origin"], token_file(policy["caller_token_file"]),
package=policy["package"], version=policy["version"], package_digest=policy["package_digest"],
allow_internal_http=True)
sink = AuditCoreSink(data["audit"]["origin"], token_file(data["audit"]["sender_token_file"]),
allow_internal_http=True)
store = Store(data["evidence_db"])
controller = ReviewController(store, client, lambda session: ApprovalHTTPClient(
approval_origin, session, allow_internal_http=True))
return cls(controller, AuditPump(store, sink))

View file

@ -27,7 +27,7 @@ from .provenance import Claim, Route, assert_human_control_dischargeable
from .stance import resolve
from .records import disposition_from, dumps, memo_from, presentation_from
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
_SCHEMA = """
CREATE TABLE documents (digest TEXT PRIMARY KEY, media_type TEXT NOT NULL, content BLOB NOT NULL);
CREATE TABLE memos (id TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(id,version));
@ -99,8 +99,19 @@ class Store:
triggers += (f"CREATE TRIGGER immutable_{table}_{operation} BEFORE {operation} ON {table} "
"BEGIN SELECT RAISE(ABORT,'immutable evidence'); END;\n")
db.executescript("BEGIN IMMEDIATE;" + _SCHEMA + triggers + "PRAGMA user_version=1; COMMIT;")
elif version != SCHEMA_VERSION:
elif version not in (1, SCHEMA_VERSION):
raise StoreError("unsupported evidence schema version")
if version in (0, 1):
db.executescript("""BEGIN IMMEDIATE;
CREATE TABLE policy_observations (id TEXT PRIMARY KEY, at TEXT NOT NULL,
request TEXT NOT NULL, decision TEXT, outcome TEXT NOT NULL,
usable_until REAL NOT NULL, response_sha256 TEXT,
decision_attributable INTEGER NOT NULL CHECK(decision_attributable=0));
CREATE TRIGGER immutable_policy_observations_UPDATE BEFORE UPDATE ON policy_observations
BEGIN SELECT RAISE(ABORT,'immutable evidence'); END;
CREATE TRIGGER immutable_policy_observations_DELETE BEFORE DELETE ON policy_observations
BEGIN SELECT RAISE(ABORT,'immutable evidence'); END;
PRAGMA user_version=2; COMMIT;""")
@contextmanager
def _connection(self):
@ -168,9 +179,35 @@ class Store:
raise EvidenceUnavailable("informed-decision cannot produce the named memo version")
return memo_from(json.loads(row["body"]))
def memo(self, memo_id):
def memo(self, memo_id, version=None):
with self._connection() as db:
return self._memo(db, memo_id)
return self._memo(db, memo_id, version)
def observe_policy(self, observation):
request = json.loads(observation.request_json)
_text(request["id"], "policy observation id")
with self._transaction() as db:
db.execute("INSERT INTO policy_observations VALUES (?,?,?,?,?,?,?,0)",
(request["id"], _now(), observation.request_json, observation.decision_json,
observation.outcome, observation.usable_until, observation.response_sha256))
return request["id"]
def policy_observations(self):
with self._connection() as db:
return [dict(r) for r in db.execute("SELECT * FROM policy_observations ORDER BY rowid")]
def _guard_policy(self, db, policy_id, memo, subject, action):
if policy_id is None:
return # Existing internal domain API; the browser always supplies one.
row = db.execute("SELECT * FROM policy_observations WHERE id=?", (policy_id,)).fetchone()
if row is None or row["outcome"] != "allow" or row["usable_until"] <= time.time():
raise Conflict("a current recorded policy observation is required")
request = json.loads(row["request"])
if (request["action"] != action or request["subject"]["id"] != subject
or request["resource"]["id"] != "memo:" + memo.id
or request["context"] != {"memo_version": memo.version, "approval_id": memo.approval_id,
"approval_binding_digest": memo.approval_binding_digest}):
raise Conflict("policy observation does not name this act")
def _presentation(self, db, presentation_id):
row = db.execute("SELECT body FROM presentations WHERE id=?", (presentation_id,)).fetchone()
@ -202,7 +239,8 @@ class Store:
db.execute("INSERT INTO outbox(id) VALUES (?)", (commitment.id,))
return commitment.id
def present(self, memo_id, *, principal_sub, tenant, principal_type, awareness=None):
def present(self, memo_id, *, principal_sub, tenant, principal_type, awareness=None,
expected_version=None, policy_id=None, approval_binding=None):
_text(principal_sub, "principal")
if (not isinstance(tenant, Claim) or tenant.value != "tenant:platform"
or tenant.route not in (Route.DIRECTORY, Route.REGISTRATION)
@ -210,17 +248,26 @@ class Store:
raise ValueError("platform tenant and principal provenance are required")
with self._transaction() as db:
memo = self._memo(db, memo_id)
if expected_version is not None and memo.version != expected_version:
raise Conflict("memo changed after the entitlement check")
self._guard_policy(db, policy_id, memo, principal_sub, "read")
if approval_binding is not None and approval_binding.get("digest") != memo.approval_binding_digest:
raise Conflict("observed act binding does not match this memo")
presentation = render(memo, principal_sub=principal_sub, tenant=tenant,
principal_type=principal_type, awareness=awareness)
content = {"memo": json.loads(dumps(memo)), "presentation": json.loads(dumps(presentation)),
"binding_document": memo.binding_document(), "awareness_document": memo.awareness_document(awareness)}
if policy_id is not None:
content["policy_observation_id"] = policy_id
if approval_binding is not None:
content["approval_binding"] = approval_binding # Observed act, never approval validity/state.
db.execute("INSERT INTO presentations VALUES (?,?,?,?)",
(presentation.id, memo.id, memo.version, dumps(presentation)))
commitment = commit_presentation(presentation, custody="informed-decision:presentations:" + presentation.id)
self._event(db, commitment, content)
return presentation
def acknowledge(self, presentation_id, actor: Actor, highlight_ids):
def acknowledge(self, presentation_id, actor: Actor, highlight_ids, *, policy_id=None):
ids = frozenset(highlight_ids)
with self._transaction() as db:
p = self._presentation(db, presentation_id)
@ -229,6 +276,7 @@ class Store:
raise DispositionRefused("G_ACTOR", "actor did not receive this presentation")
if memo.version != p.memo_version:
raise DispositionRefused("G_PRES", "presentation is stale")
self._guard_policy(db, policy_id, memo, actor.sub, "acknowledge")
if ids - {h.id for h in memo.highlights}:
raise DispositionRefused("G_ACK", "unknown highlight")
fresh = ids - p.acked_highlight_ids
@ -239,11 +287,11 @@ class Store:
db.execute("INSERT INTO acknowledgments VALUES (?,?,?)", (p.id, highlight_id, at))
p = replace(p, acked_highlight_ids=p.acked_highlight_ids | fresh)
c = commit_presentation(p, custody="informed-decision:presentations:" + p.id)
self._event(db, c, {"presentation": json.loads(dumps(p))},
self._event(db, c, {"presentation": json.loads(dumps(p)), "policy_observation_id": policy_id},
{"event_kind": "acknowledgment", "acknowledged_at": at})
return p
def record_disposition(self, presentation_id, actor, verb, *, operation_id, reasons=(), note=None):
def record_disposition(self, presentation_id, actor, verb, *, operation_id, reasons=(), note=None, policy_id=None):
_text(operation_id, "operation id")
if note is not None and (not isinstance(note, str) or len(note) > 8192):
raise ValueError("disposition note is too large or invalid")
@ -258,6 +306,7 @@ class Store:
return disposition_from(json.loads(existing["body"]))
p = self._presentation(db, presentation_id)
memo = self._memo(db, p.memo_id)
self._guard_policy(db, policy_id, memo, actor.sub, verb.value)
d = record(memo, p, verb, actor, reasons=tuple(reasons), note=note)
if verb in BINDING_VERBS:
if p.principal_type is None:
@ -272,11 +321,12 @@ class Store:
if verb is Verb.ACCEPT:
db.execute("INSERT INTO submissions(disposition_id,approval_id,subject) VALUES (?,?,?)", (d.id, memo.approval_id, actor.sub))
c = commit_disposition(d, custody="informed-decision:dispositions:" + d.id)
self._event(db, c, {"disposition": json.loads(dumps(d)), "presentation": json.loads(dumps(p))},
self._event(db, c, {"disposition": json.loads(dumps(d)), "presentation": json.loads(dumps(p)),
"policy_observation_id": policy_id},
{"submission_state": "prepared"} if verb is Verb.ACCEPT else None)
return d
def begin_submission(self, disposition_id):
def begin_submission(self, disposition_id, *, policy_id=None):
"""Reserve one external attempt, AFTER the caller's fresh policy check.
No network occurs in this store. A crash after this reservation stays
@ -290,6 +340,7 @@ class Store:
p = self._presentation(db, d.presentation_id)
if self._memo(db, p.memo_id).version != p.memo_version:
raise DispositionRefused("G_PRES", "presentation is stale")
self._guard_policy(db, policy_id, self._memo(db, p.memo_id), d.actor.sub, "accept")
attempt = str(uuid.uuid4())
db.execute("UPDATE submissions SET state='in_flight',attempt=? WHERE disposition_id=?", (attempt, disposition_id))
return attempt
@ -323,6 +374,27 @@ class Store:
row = db.execute("SELECT * FROM submissions WHERE disposition_id=?", (disposition_id,)).fetchone()
return dict(row) if row else None
def intent_for(self, approval_id, subject):
with self._connection() as db:
row = db.execute("SELECT s.*,d.presentation_id,d.operation_id FROM submissions s "
"JOIN dispositions d ON d.id=s.disposition_id WHERE s.approval_id=? AND s.subject=?",
(approval_id, subject)).fetchone()
return dict(row) if row else None
def dispositions_for(self, presentation_id):
with self._connection() as db:
return [disposition_from(json.loads(r[0])) for r in db.execute(
"SELECT body FROM dispositions WHERE presentation_id=? ORDER BY rowid", (presentation_id,))]
def presentation_content(self, presentation_id):
with self._connection() as db:
row = db.execute("SELECT content FROM evidence WHERE class=? AND "
"json_extract(envelope,'$.data.presentation_id')=? ORDER BY rowid LIMIT 1",
(EventClass.PRESENTATION.value, presentation_id)).fetchone()
if row is None:
raise EvidenceUnavailable("informed-decision cannot produce the presentation content")
return json.loads(row[0])
def retrieve_presentation(self, presentation_id):
"""Internal custody retrieval, not a browser export/entitlement route."""
with self._connection() as db:

122
informed_decision/ui.py Normal file
View file

@ -0,0 +1,122 @@
"""Server-rendered review; no scripts, telemetry or browser-held bearer token."""
from html import escape
from urllib.parse import quote
import uuid
from .disposition import Verb, legal_verbs
STYLES = """
:root{color-scheme:light;font-family:system-ui,sans-serif;color:#172d35;background:#f4f5ef}
*{box-sizing:border-box}body{margin:0}a{color:#145c65}header{background:#173d43;color:white;padding:1.25rem max(1.25rem,calc((100vw - 1080px)/2));display:flex;justify-content:space-between;align-items:center;gap:1rem}header a{color:inherit;text-decoration:none;font-weight:700}header small{color:#d2e8df}main{max-width:1080px;margin:2.5rem auto;padding:0 1.25rem}h1{font-size:clamp(1.7rem,3vw,2.6rem);line-height:1.2;max-width:850px}h2{font-size:1.2rem;margin-top:0}h3{font-size:1rem}p,li{line-height:1.6}.eyebrow{font-size:.75rem;letter-spacing:.1em;text-transform:uppercase;color:#486a6f}.grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(230px,1fr);gap:1.25rem;align-items:start}.card{background:white;border:1px solid #d9dfd7;border-radius:12px;padding:1.5rem;margin-bottom:1.25rem}.notice{padding:1rem 1.25rem;border-left:4px solid #356f63;background:#e3eee7;margin:1.25rem 0}.warning{border-color:#a26814;background:#fff1d6}dl{margin:0}dt{font-size:.8rem;color:#5e7375;margin-top:1rem}dt:first-child{margin-top:0}dd{margin:.3rem 0;overflow-wrap:anywhere}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.88rem;line-height:1.5;background:#f3f5f0;padding:1rem;border-radius:6px}button{font:inherit;font-weight:600;border:1px solid #1f6460;border-radius:7px;padding:.7rem 1.1rem;background:#23665d;color:white;cursor:pointer}button.secondary{background:white;color:#244d50}button:disabled{opacity:.45;cursor:not-allowed}input,select,textarea{font:inherit;max-width:100%;padding:.65rem;border:1px solid #9badaa;border-radius:5px}input[type=checkbox]{width:1.2rem;height:1.2rem;vertical-align:middle;margin-right:.6rem}label{display:block;line-height:1.5;margin:.7rem 0}textarea{display:block;width:100%;min-height:90px}.muted{font-size:.9rem;color:#567074}.brief{white-space:pre-wrap}.actions{display:flex;flex-wrap:wrap;gap:.7rem}.highlight{padding:.9rem 0;border-bottom:1px solid #e3e8df}.highlight:last-of-type{border-bottom:0}details{margin:1rem 0}summary{cursor:pointer;font-weight:600}.hash{overflow-wrap:anywhere;font-family:monospace;font-size:.8rem}.record{padding:.7rem 0;border-bottom:1px solid #e3e8df}:focus-visible{outline:3px solid #b67e19;outline-offset:3px}footer{padding:1.5rem 0;color:#567074;font-size:.85rem}@media(max-width:740px){.grid{grid-template-columns:1fr}main{margin:1.5rem auto}header{align-items:start;flex-direction:column}}
"""
def text(value):
return escape(str(value), quote=True)
def document(title, content, subject=None):
return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
f'<title>{text(title)} · Informed Decision</title><link rel="stylesheet" href="/assets/review.css">'
'</head><body><header><a href="/">Informed Decision</a>'
f'<small>{text(subject) if subject else "Human review"}</small></header><main>{content}'
'<footer>An approval records a human decision. Execution has its own permission checks.</footer>'
'</main></body></html>')
def hidden(name, value):
return f'<input type="hidden" name="{text(name)}" value="{text(value)}">'
def error_page(message, subject=None):
return document("Review unavailable", '<h1>Review unavailable</h1><p>'+text(message)
+'</p><p><a href="/">Return to Informed Decision</a></p>', subject)
def review_page(page, session):
memo, p = page.memo, page.presentation
base = "/presentations/" + quote(p.id, safe="")
csrf = hidden("csrf", session.csrf)
missing = memo.required_ack_ids - p.acked_highlight_ids
legal = legal_verbs(memo.step_kind)
intent = page.intent
blocked = page.stale or page.engine_status not in ("requested", "approved")
accepted = intent and intent["state"] == "confirmed"
uncertain = intent and intent["state"] in ("in_flight", "unresolved")
if accepted:
notice = '<div class="notice" role="status"><strong>Approval entry recorded.</strong> This record retains the original review and acknowledgments. It does not authorize execution.</div>'
elif uncertain:
notice = '<div class="notice warning" role="status"><strong>Submission outcome is not confirmed.</strong> The entry may have reached Approval Engine. Do not submit another approval. This original record is retained for operator recovery.</div>'
elif page.stale:
notice = '<div class="notice warning" role="status">This is an earlier memo version. It cannot be used for a new action.</div>'
elif blocked:
notice = '<div class="notice warning" role="status">This approval is no longer open for a new entry.</div>'
else:
notice = '<div class="notice">Review the complete request and packet. Acknowledging highlights does not narrow what you accept.</div>'
binding = page.binding
facts = ''.join(f'<dt>{label}</dt><dd>{text(binding.get(key, "Not supplied"))}</dd>' for key, label in
(("action", "Action"), ("target", "Act scope"), ("actor", "Executing identity"),
("principal", "Requesting party"), ("reason", "Reason")))
highlights = ''
for index, h in enumerate(memo.highlights):
acked = h.id in p.acked_highlight_ids
control = (f'<span>✓ Acknowledged</span>' if acked else
f'<label><input type="checkbox" name="h{index}" value="{text(h.id)}">I have reviewed this highlight</label>')
highlights += (f'<div class="highlight"><p><strong>{text(h.note)}</strong></p>'
f'<p class="muted">Document: {text(h.item_id)} · {"Required acknowledgment" if h.required_ack else "Optional acknowledgment"}</p>'
+ (control if not blocked and not accepted and not uncertain else ('<p>Acknowledged</p>' if acked else '<p>Not acknowledged</p>')) + '</div>')
ack_button = '' if blocked or accepted or uncertain else '<button class="secondary" type="submit">Record acknowledgments</button>'
highlights = (f'<section class="card"><h2>Highlights</h2><form method="post" action="{base}/ack">'
+ csrf + highlights + ack_button + '</form></section>') if memo.highlights else ''
packets = ''
for index, item in enumerate(memo.packet):
body = page.documents[item.item_id]
# Browser display is escaped text; every original byte remains available
# through an entitled attachment response, never as executable HTML.
try:
preview = body.decode("utf-8") if len(body) <= 65536 else None
except UnicodeDecodeError:
preview = None
packets += (f'<details><summary>{text(item.label)}</summary>'
+ (f'<pre>{text(preview)}</pre>' if preview is not None else '<p>Open the complete attachment to review this document.</p>')
+ f'<a href="{base}/packet/{index}">Download complete document</a><p class="hash">{text(item.hash)}</p></details>')
packet = '<section class="card"><h2>Complete packet</h2>'+ (packets or '<p>No attachments.</p>') + '</section>'
terms = ''.join(f'<h3>{label}</h3><p class="brief">{text(value)}</p>' for label, value in
(("Terms", memo.binding.terms), ("Justification", memo.binding.justification)) if value)
forms = ''
if not blocked and not accepted and not uncertain:
for verb, label in ((Verb.ACCEPT, "Accept the complete request"), (Verb.RETURN, "Return for improvement"),
(Verb.DISCUSS, "Request discussion"), (Verb.DECLINE, "Decline")):
if verb not in legal:
continue
operation = intent["operation_id"] if intent and verb is Verb.ACCEPT else str(uuid.uuid4())
fields = csrf + hidden("verb", verb.value) + hidden("operation_id", operation)
if verb is Verb.RETURN:
fields += '<label>Reason <select name="reason" required><option value="">Choose a reason</option><option value="clarification_needed">Clarification needed</option><option value="wrong_scope">Scope needs correction</option><option value="missing_information">Information missing</option></select></label>'
if verb in (Verb.RETURN, Verb.DISCUSS):
fields += '<label>Your note <textarea name="note" maxlength="8192"></textarea></label>'
disabled = ' disabled' if missing and verb in (Verb.ACCEPT, Verb.DECLINE) else ''
forms += (f'<details{" open" if verb is Verb.ACCEPT else ""}><summary>{label}</summary>'
f'<form method="post" action="{base}/act">{fields}<p>'
+ ('Acceptance covers the entire request, including the complete packet.' if verb is Verb.ACCEPT else
'This records your response on the memo.')
+ f'</p><button type="submit"{disabled}>{label}</button></form></details>')
if missing:
forms = '<p class="muted">Record all required acknowledgments before accepting or declining.</p>' + forms
records = ''.join(f'<div class="record"><strong>{text(d.verb.value.capitalize())}</strong> · {text(d.at)}'
+ (f'<p>{text(d.note)}</p>' if d.note else '') + '</div>' for d in page.dispositions)
evidence = (f'<details><summary>Evidence details</summary><p>Memo {text(memo.id)} · version {memo.version}</p>'
f'<p>Presentation {text(p.id)}</p><p class="hash">View: {text(p.view_hash)}</p>'
f'<p class="hash">Act: {text(memo.approval_binding_digest)}</p>'
f'<p>Account zone: {text(session.tenant.value)} ({text(session.tenant.route.value)}). This is separate from the act scope.</p></details>')
content = (f'<p class="eyebrow">Decision review · version {memo.version}</p><h1>{text(memo.question)}</h1>'
+ notice + '<div class="grid"><div><section class="card"><h2>The request</h2>'
f'<p><strong>{text(memo.requested_act)}</strong></p><p class="brief">{text(memo.brief)}</p>{terms}</section>'
+ highlights + packet + '<section class="card"><h2>Your response</h2>' + (forms or '<p>No new approval submission is available from this record.</p>')
+ (f'<h3>Recorded responses</h3>{records}' if records else '') + '</section></div>'
+ f'<aside class="card"><h2>What this act covers</h2><dl>{facts}<dt>Person being bound</dt>'
f'<dd>{text(memo.binding.principal.display_name)} · {text(session.subject)}</dd></dl>{evidence}</aside></div>')
return document(memo.question, content, session.subject)

View file

@ -1,4 +1,4 @@
"""Browser login shell. Protected memo rendering/binding is not wired yet."""
"""Browser login and protected review routes, enabled by owner configuration."""
from __future__ import annotations
@ -7,9 +7,18 @@ from http.cookies import CookieError, SimpleCookie
import json
import os
import secrets
import re
import sqlite3
from urllib.parse import parse_qs
from .oidc import KeyCapeLogin, LoginError, ORIGIN
from .approval_client import ApprovalEngineError
from .disposition import DispositionRefused, Verb
from .policy import PolicyError
from .provenance import HumanControlNotDischargeable
from .review import ReviewError
from .store import Conflict, EvidenceUnavailable, StoreError
from .ui import STYLES, document, error_page, review_page
FLOW_COOKIE = "__Host-infd-flow"
SESSION_COOKIE = "__Host-infd-session"
@ -19,24 +28,30 @@ def _cookie(name: str, value: str, seconds: int) -> tuple[str, str]:
return "Set-Cookie", f"{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={seconds}"
def _one(query: str) -> dict[str, str]:
if len(query) > 8192:
def _one(query: str, *, limit=8192, fields=16) -> dict[str, str]:
if len(query) > limit:
raise ValueError("request too large")
values = parse_qs(query, keep_blank_values=True, max_num_fields=16)
values = parse_qs(query, keep_blank_values=True, max_num_fields=fields, errors="strict")
if any(len(v) != 1 for v in values.values()):
raise ValueError("duplicate parameter")
return {k: v[0] for k, v in values.items()}
class App:
def __init__(self, login: KeyCapeLogin):
def __init__(self, login: KeyCapeLogin, review=None, *, readiness=lambda: False):
self.login = login
self.review, self.readiness = review, readiness
def __call__(self, environ, start_response):
path = environ.get("PATH_INFO", "/")
# Chromium sends Origin: null for a form under no-referrer. Review
# pages need same-origin so legitimate POSTs satisfy the exact-origin
# CSRF check. Authentication URLs and downloads never send a referrer.
referrer_policy = "no-referrer" if path.startswith("/auth/") or "/packet/" in path else "same-origin"
headers = [
("Cache-Control", "no-store"), ("Pragma", "no-cache"),
("Referrer-Policy", "no-referrer"), ("X-Content-Type-Options", "nosniff"),
("Content-Security-Policy", "default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"),
("Referrer-Policy", referrer_policy), ("X-Content-Type-Options", "nosniff"),
("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"),
("Strict-Transport-Security", "max-age=31536000"),
]
try:
@ -53,8 +68,37 @@ class App:
headers.append(_cookie(FLOW_COOKIE, "", 0))
except (ValueError, CookieError, UnicodeError):
status, body, content_type = 400, "Invalid request.", "text/plain"
raw = body.encode()
headers += [("Content-Type", content_type + "; charset=utf-8"), ("Content-Length", str(len(raw)))]
except ReviewError as exc:
messages = {
"session_expired": "Your session expired. Sign in again to continue.",
"invalid_form": "This form could not be verified. Reopen the review before submitting again.",
"policy_denied": "The permission service refused this review action.",
"wrong_recipient": "This review is addressed to another person.",
"stale_presentation": "The memo changed. Open its current version before taking an action.",
"binding_changed": "The approval no longer matches this memo. A revised review is required.",
"renderer_changed": "This memo names an earlier review interface. A new memo version is required before showing it here.",
"unsupported_review_profile": "This review interface supports English organizational approvals. The memo requires a different review profile.",
"existing_entry_unlinked": "An approval entry already exists, but this review cannot prove its original presentation. Operator recovery is required; no new entry was submitted.",
"act_unavailable": "This approval is no longer open for a new entry.",
}
status, body, content_type = exc.status, error_page(messages.get(exc.code,
"The review could not complete its required checks. Reload after the service is available.")), "text/html"
except DispositionRefused as exc:
status, body, content_type = 409, error_page({
"G_ACK": "Record all required highlight acknowledgments before accepting or declining.",
"G_REASONS": "Choose a reason when returning this memo.",
"G_PRES": "The memo changed. Open its current version before taking an action.",
}.get(exc.guard, "This action is not available for the current review.")), "text/html"
except HumanControlNotDischargeable:
status, body, content_type = 403, error_page("A verified human session is required."), "text/html"
except EvidenceUnavailable:
status, body, content_type = 404, error_page("The requested review or its evidence is unavailable."), "text/html"
except Conflict:
status, body, content_type = 409, error_page("The review changed or already has a submission. Reload the original record."), "text/html"
except (PolicyError, ApprovalEngineError, StoreError, sqlite3.Error):
status, body, content_type = 503, error_page("The required service or evidence store is unavailable. A submission may be unresolved; reopen the original review before taking another action."), "text/html"
raw = body if isinstance(body, bytes) else body.encode()
headers += [("Content-Type", content_type + ("; charset=utf-8" if content_type != "application/octet-stream" else "")), ("Content-Length", str(len(raw)))]
start_response(f"{status} {'OK' if status < 400 else 'ERROR'}", headers)
return [raw]
@ -63,7 +107,11 @@ class App:
if method == "GET" and path == "/healthz":
return 200, '{"status":"ok"}', "application/json", []
if method == "GET" and path == "/readyz":
if self.review is not None and self.readiness():
return 200, '{"status":"ready"}', "application/json", []
return 503, json.dumps({"status": "incomplete", "reason": "approval_path_not_connected"}), "application/json", []
if method == "GET" and path == "/assets/review.css":
return 200, STYLES, "text/css", []
if method == "GET" and path == "/auth/start":
url, browser = self.login.start()
return 303, "", "text/plain", [("Location", url), _cookie(FLOW_COOKIE, browser, 300)]
@ -76,6 +124,48 @@ class App:
return 303, "", "text/plain", [("Location", "/"), _cookie(FLOW_COOKIE, "", 0),
_cookie(SESSION_COOKIE, new_sid, 900)]
session = self.login.session(sid)
if self.review is not None and (path == "/review" or path.startswith("/presentations/")):
if session is None:
raise ReviewError(401, "session_expired")
if method == "GET" and path == "/review":
params = _one(environ.get("QUERY_STRING", ""))
if set(params) != {"memo_id"} or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}", params["memo_id"]):
raise ValueError("invalid memo id")
page = self.review.open(session, params["memo_id"])
return 200, review_page(page, session), "text/html", []
match = re.fullmatch(r"/presentations/(pres-[a-f0-9-]{36})(?:/(ack|act|packet/([0-9]{1,3})))?", path)
if not match:
return 404, "Not found.", "text/plain", []
presentation_id, route, index = match.groups()
if method == "GET" and (route is None or index is not None):
page = self.review.load(session, presentation_id)
if index is not None:
number = int(index)
if number >= len(page.memo.packet):
return 404, "Not found.", "text/plain", []
item = page.memo.packet[number]
return 200, page.documents[item.item_id], "application/octet-stream", [
("Content-Disposition", f'attachment; filename="review-document-{number + 1}.bin"')]
return 200, review_page(page, session), "text/html", []
if method == "POST" and route in ("ack", "act"):
params = self._form(environ, session)
if route == "ack":
if any(k != "csrf" and not re.fullmatch(r"h[0-9]{1,3}", k) for k in params):
raise ValueError("unexpected acknowledgment field")
self.review.acknowledge(session, presentation_id, [v for k,v in params.items() if k != "csrf"])
target = presentation_id
else:
if set(params) - {"csrf", "verb", "operation_id", "reason", "note"}:
raise ValueError("unexpected action field")
if not re.fullmatch(r"[a-f0-9-]{36}", params.get("operation_id", "")):
raise ValueError("invalid operation id")
if params.get("verb") == "accept" and not self.readiness():
raise ReviewError(503, "audit_delivery_unavailable")
target = self.review.act(session, presentation_id, Verb(params.get("verb")),
operation_id=params["operation_id"], reasons=(params["reason"],) if params.get("reason") else (),
note=params.get("note") or None)
return 303, "", "text/plain", [("Location", "/presentations/" + target)]
return 405, "Method not allowed.", "text/plain", [("Allow", "POST" if route in ("ack", "act") else "GET")]
if method == "POST" and path == "/auth/logout":
if environ.get("HTTP_ORIGIN") != ORIGIN or not session:
return 403, "Invalid sign-out request.", "text/plain", []
@ -93,22 +183,48 @@ class App:
if method == "GET" and path == "/":
if session:
content = (f"<p>Signed in as {html.escape(session.subject)}.</p>"
"<p>Decision review is being prepared. No approval has been recorded.</p>"
+ ('<h1>Open a decision review</h1><p>Enter the memo identifier supplied with your review request.</p>'
'<form method="get" action="/review"><label>Memo identifier <input name="memo_id" required maxlength="256"></label>'
'<button type="submit">Open review</button></form>' if self.review is not None else
'<p>Decision review is being prepared. No approval has been recorded.</p>') +
'<form method="post" action="/auth/logout">'
f'<input type="hidden" name="csrf" value="{session.csrf}">'
'<button type="submit">Sign out</button></form>')
else:
content = '<p><a href="/auth/start">Sign in with KeyCape</a></p>'
return 200, ('<!doctype html><html lang="en"><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>Informed Decision</title><body><h1>Informed Decision</h1>'
+ content + '</body></html>'), "text/html", []
return 200, document("Informed Decision", content, session.subject if session else None), "text/html", []
return 404, "Not found.", "text/plain", []
def _form(self, environ, session):
if environ.get("HTTP_ORIGIN") != ORIGIN:
raise ReviewError(403, "invalid_form")
length = int(environ.get("CONTENT_LENGTH") or 0)
if not 0 < length <= 65536 or environ.get("CONTENT_TYPE", "").split(";")[0] != "application/x-www-form-urlencoded":
raise ValueError("invalid body")
raw = environ["wsgi.input"].read(length)
if len(raw) != length:
raise ValueError("truncated body")
params = _one(raw.decode(), limit=65536, fields=256)
csrf = params.get("csrf", "")
if not csrf.isascii() or not secrets.compare_digest(csrf, session.csrf):
raise ReviewError(403, "invalid_form")
return params
def main():
from waitress import serve
# Waitress does not log request targets; a proxy must also omit callback
# query strings and cookies. No debug traceback middleware belongs here.
app = App(KeyCapeLogin(os.environ["INFD_KEYCAPE_ISSUER"]))
serve(app, host="127.0.0.1", port=8080, threads=4)
login = KeyCapeLogin(os.environ["INFD_KEYCAPE_ISSUER"])
runtime = None
if os.environ.get("INFD_REVIEW_CONFIG"):
from .runtime import Runtime
runtime = Runtime.from_file(os.environ["INFD_REVIEW_CONFIG"])
runtime.pump.start()
app = App(login, runtime.controller if runtime else None,
readiness=runtime.pump.ready if runtime else lambda: False)
try:
serve(app, host="127.0.0.1", port=8080, threads=4)
finally:
if runtime:
runtime.pump.stop()

View file

@ -1,6 +1,6 @@
[project]
name = "informed-decision"
version = "0.1.0"
version = "0.2.0"
description = "Presentation and binding surface for decisions — the Decision Memo and its evidence."
requires-python = ">=3.11"
dependencies = ["PyJWT[crypto]>=2.10,<3", "waitress>=3,<4"]

116
tests/browser_fixture.py Normal file
View file

@ -0,0 +1,116 @@
"""Local-only browser exercise over actual components and synthetic identity.
Run with all three INFD_* component source/binary variables, and pass an output
directory. The fixture controls below are NOT part of informed_decision.web.
"""
import json
import os
from pathlib import Path
import sys
import threading
import base64
import hashlib
import ssl
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlsplit
from wsgiref.simple_server import WSGIRequestHandler, make_server
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import NameOID
from informed_decision.oidc import KeyCapeLogin
from informed_decision.runtime import AuditPump
from informed_decision.web import App
from test_browser_auth import ISSUER, IssuerFixture, signing_key
from test_approval_component import component
from test_durable_component import receiver
from test_review_controller import review
from review_components import flex_component
def main():
directory=Path(sys.argv[1]);directory.mkdir(mode=0o700,parents=True,exist_ok=True)
key=signing_key.__wrapped__()
class Quiet(WSGIRequestHandler):
def log_message(self,*args):pass
tls_server=make_server('127.0.0.1',0,lambda e,s:[],handler_class=Quiet)
public_origin=f'https://decisions.coulomb.social:{tls_server.server_port}'
issuer_origin=f'https://keycape.test:{tls_server.server_port}'
# Test-only ports, real HTTPS and redirects. Exact production strings stay
# pinned by the separate registration/login contract tests.
import informed_decision.oidc as oidc_module
import informed_decision.web as web_module
import test_browser_auth as browser_auth
import test_approval_component as approval_component
oidc_module.ORIGIN=web_module.ORIGIN=public_origin
oidc_module.CALLBACK=browser_auth.CALLBACK=public_origin+'/auth/callback'
browser_auth.ISSUER=approval_component.ISSUER=issuer_origin
now=datetime.now(timezone.utc)
name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'Browser fixture')])
certificate=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1))
.not_valid_after(now+timedelta(hours=1)).add_extension(x509.SubjectAlternativeName([
x509.DNSName('decisions.coulomb.social'),x509.DNSName('keycape.test')]),critical=False).sign(key,hashes.SHA256()))
cert_path=directory/'browser-cert.pem';cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
key_path=directory/'browser-key.pem';key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));key_path.chmod(0o600)
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert_path,key_path)
tls_server.socket=context.wrap_socket(tls_server.socket,server_side=True)
spki=base64.b64encode(hashlib.sha256(key.public_key().public_bytes(serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)).digest()).decode()
engine_fixture=component.__wrapped__(key,directory);engine_parts=next(engine_fixture)
sink,receiver_transport,backend=receiver.__wrapped__(directory)
controller,_,_,memo,engine,transport=review.__wrapped__(engine_parts,directory)
issuer=IssuerFixture(key);login=KeyCapeLogin(issuer_origin,transport=issuer)
original_start=login.start
def start():
url,browser=original_start()
issuer.params={k:v[0] for k,v in parse_qs(urlsplit(url).query).items()}
return url,browser
login.start=start
with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls):
controller.policy=policy
pump=AuditPump(controller.store,sink);pump.tick()
assert pump.ready()
app=App(login,controller,readiness=pump.ready)
def fixture(environ,start_response):
path=environ.get('PATH_INFO')
if path=='/authorize':
params={k:v[0] for k,v in parse_qs(environ.get('QUERY_STRING','')).items()}
assert params['code_challenge_method']=='S256'
assert params['redirect_uri']==oidc_module.CALLBACK
start_response('303 See Other',[('Location',oidc_module.CALLBACK+'?state='+params['state']+'&code=synthetic-browser-code')])
return [b'']
if path=='/_fixture/status':
pump.tick()
intents=[controller.store.intent_for('fixture','human-fixture')]
payload={'entries':len(engine.get('fixture').entries),'intents':intents,
'outbox_states':[r['state'] for r in controller.store.outbox()],
'policy_outcomes':[r['outcome'] for r in controller.store.policy_observations()],
'decision_attributable':[r['decision_attributable'] for r in controller.store.policy_observations()],
'entry_posts':sum(m=='POST' for m,_ in transport.calls),
'consume_calls':sum(url.endswith('/consume') for _,url in transport.calls),
'tokenreviews':len(calls)}
raw=json.dumps(payload).encode();start_response('200 OK',[('Content-Type','application/json')]);return [raw]
if path=='/_fixture/refuse-caller':
policy.token_provider=lambda:'synthetic-wrong-caller'
start_response('200 OK',[('Content-Type','application/json')]);return [b'{}']
if path=='/_fixture/stop':
threading.Thread(target=server.shutdown,daemon=True).start()
start_response('200 OK',[('Content-Type','application/json')]);return [b'{}']
return app(environ,start_response)
tls_server.set_app(fixture)
tls_thread=threading.Thread(target=tls_server.serve_forever,daemon=True);tls_thread.start()
with make_server('127.0.0.1',0,fixture,handler_class=Quiet) as server:
(directory/'server.json').write_text(json.dumps({'origin':f'http://127.0.0.1:{server.server_port}',
'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id,'fixture_only':True})+'\n')
print('Browser fixture ready',flush=True)
try:server.serve_forever()
except KeyboardInterrupt:pass
finally:
tls_server.shutdown();tls_server.server_close();tls_thread.join(timeout=5)
engine_fixture.close()
if __name__=='__main__':main()

117
tests/browser_review.mjs Normal file
View file

@ -0,0 +1,117 @@
// Automated browser contract exercise. All identities and grants are synthetic.
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url);
const {chromium}=require(process.env.INFD_PLAYWRIGHT_PACKAGE || 'playwright');
const directory=process.argv[2];
const fixture=JSON.parse(await fs.readFile(path.join(directory,'server.json'),'utf8'));
const checks=[];
const browser=await chromium.launch({headless:true,executablePath:process.env.INFD_CHROMIUM_EXECUTABLE,args:[
'--no-proxy-server',
'--host-resolver-rules=MAP decisions.coulomb.social 127.0.0.1, MAP keycape.test 127.0.0.1',
'--ignore-certificate-errors-spki-list='+fixture.certificate_spki
]});
const context=await browser.newContext({viewport:{width:1360,height:1000},acceptDownloads:true});
const errors=[];
const navigation=[];
const formOrigins=[];
try {
// Both test names resolve to the disposable HTTPS server. All redirects,
// browser cookies and form Origins travel through real HTTP/TLS. Only this
// fixture certificate's public key is trusted by the browser override.
const page=await context.newPage();page.setDefaultTimeout(10000);
page.on('pageerror',error=>errors.push(String(error)));
page.on('request',request=>{if(request.method()==='POST')formOrigins.push({
path:new URL(request.url()).pathname,origin:request.headers().origin || null});});
page.on('response',response=>navigation.push({path:new URL(response.url()).pathname,status:response.status()}));
page.on('dialog',async dialog=>{errors.push('Unexpected script dialog');await dialog.dismiss();});
await page.goto(fixture.public_origin+'/');
await page.getByRole('link',{name:'Sign in with KeyCape'}).click();
await page.getByRole('heading',{name:'Open a decision review'}).waitFor({timeout:10000});
const cookies=await context.cookies();
const session=cookies.find(c=>c.name==='__Host-infd-session');
assert(session?.secure && session?.httpOnly && session?.sameSite==='Lax');
checks.push('PKCE callback and secure browser session');
await page.getByLabel('Memo identifier').fill(fixture.memo_id);
await page.getByRole('button',{name:'Open review',exact:true}).click();
await page.getByRole('heading',{name:'The request',exact:true}).waitFor();
const accept=()=>page.getByRole('button',{name:'Accept the complete request',exact:true});
assert(await accept().isDisabled());
assert.equal(await page.locator('img,script').count(),0);
await page.screenshot({path:path.join(directory,'review-desktop.png'),fullPage:true});
checks.push('Entitled page escapes memo and packet markup');
await accept().evaluate(button=>button.disabled=false);
await accept().click();
await page.getByText('Record all required highlight acknowledgments before accepting or declining.').waitFor();
let state=await (await fetch(fixture.origin+'/_fixture/status')).json();
assert.equal(state.entries,0);assert.equal(state.entry_posts,0);
checks.push('Server refuses bypass of missing acknowledgments');
await page.goBack();
await page.getByLabel('I have reviewed this highlight').check();
await page.getByRole('button',{name:'Record acknowledgments',exact:true}).click();
assert(await accept().isEnabled());
checks.push('Explicit acknowledgment unlocks the form');
await page.locator('summary').filter({hasText:'Whole fixture document'}).click();
const [download]=await Promise.all([
page.waitForEvent('download'),page.getByRole('link',{name:'Download complete document',exact:true}).click()
]);
const bytes=await fs.readFile(await download.path());
assert(bytes.includes(Buffer.from('Full fixture terms.')));
checks.push('Complete attachment download stays behind entitlement');
await page.locator('summary').filter({hasText:'Return for improvement'}).click();
const returned=page.locator('form').filter({has:page.locator('select[name="reason"]')});
await returned.locator('select').selectOption('clarification_needed');
await returned.locator('textarea').fill('Synthetic browser return: clarify the fixture scope.');
await returned.getByRole('button',{name:'Return for improvement',exact:true}).click();
await page.getByText('Synthetic browser return: clarify the fixture scope.').waitFor();
state=await (await fetch(fixture.origin+'/_fixture/status')).json();assert.equal(state.entries,0);
checks.push('Return records a memo response without an engine entry');
await page.setViewportSize({width:390,height:844});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
await page.screenshot({path:path.join(directory,'review-mobile.png'),fullPage:true});
checks.push('Mobile review fits the viewport');
await page.setViewportSize({width:1360,height:1000});
await accept().click();
await page.getByText('Approval entry recorded.',{exact:true}).waitFor();
assert.equal(await page.getByRole('button',{name:'Accept the complete request',exact:true}).count(),0);
await page.screenshot({path:path.join(directory,'recorded-desktop.png'),fullPage:true});
await page.reload();await page.getByText('Approval entry recorded.',{exact:true}).waitFor();
state=await (await fetch(fixture.origin+'/_fixture/status')).json();
assert.equal(state.entries,1);assert.equal(state.entry_posts,1);assert.equal(state.consume_calls,0);
assert(state.outbox_states.every(s=>s==='delivered'));
assert(state.decision_attributable.every(value=>value===0));
checks.push('One actual approval entry, stable on browser reload');
checks.push('Audit receiver holds all commitments; unsigned decision gap retained');
await fetch(fixture.origin+'/_fixture/refuse-caller');
await page.reload();await page.getByRole('heading',{name:'Review unavailable'}).waitFor();
assert(!(await page.content()).includes('private-brief-sentinel'));
state=await (await fetch(fixture.origin+'/_fixture/status')).json();
assert.equal(state.policy_outcomes.at(-1),'caller_refused');assert.equal(state.entries,1);
checks.push('Real workload caller refusal hides review content');
await page.goto(fixture.public_origin+'/');
await page.getByRole('button',{name:'Sign out',exact:true}).click();
await page.getByRole('link',{name:'Sign in with KeyCape'}).waitFor();
assert(!(await context.cookies()).some(c=>c.name==='__Host-infd-session'));
assert(formOrigins.every(form=>form.origin===fixture.public_origin));
checks.push('Sign-out clears the session; all forms carry the exact origin');
assert.deepEqual(errors,[]);
checks.push('No page errors or injected script execution');
const receipt={fixture_only:true,browser:'Chromium',checks_passed:checks.length,checks,
fixture_transport:'real HTTPS on ephemeral ports; synthetic issuer and TokenReview',
native_human_login_proven:false,native_policy_admitted:false,factory_attempts:0,paid_model_calls:0,
final_component_state:state};
await fs.writeFile(path.join(directory,'browser-result.json'),JSON.stringify(receipt,null,2)+'\n');
process.stdout.write(JSON.stringify({checks_passed:checks.length,entries:state.entries,entry_posts:state.entry_posts,fixture_only:true})+'\n');
} finally {
if(checks.length<12 && context.pages()[0]) {
const page=context.pages()[0], url=new URL(page.url());
await fs.writeFile(path.join(directory,'browser-debug.json'),JSON.stringify({
path:url.pathname,body:await page.locator('body').innerText(),navigation,formOrigins,
cookie_names:(await context.cookies()).map(c=>c.name),errors},null,2));
await page.screenshot({path:path.join(directory,'browser-debug.png'),fullPage:true});
}
await browser.close();
await fetch(fixture.origin+'/_fixture/stop').catch(()=>{});
}

View file

@ -0,0 +1,58 @@
[
{
"id": "synthetic-allow",
"request": {
"id": "fixture-pin",
"tenant": "tenant:platform",
"subject": {
"id": "human-fixture",
"type": "human",
"tenant": "tenant:platform"
},
"action": "read",
"resource": {
"id": "memo:memo-1",
"type": "decision-memo",
"system": "informed-decision",
"tenant": "tenant:platform"
},
"context": {
"memo_version": 1,
"approval_id": "fixture",
"approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"policy_version": "v1"
},
"expect": {
"effect": "allow"
}
},
{
"id": "synthetic-refusal",
"request": {
"id": "fixture-pin",
"tenant": "tenant:platform",
"subject": {
"id": "human-fixture",
"type": "human",
"tenant": "tenant:platform"
},
"action": "consume",
"resource": {
"id": "memo:memo-1",
"type": "decision-memo",
"system": "informed-decision",
"tenant": "tenant:platform"
},
"context": {
"memo_version": 1,
"approval_id": "fixture",
"approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"policy_version": "v1"
},
"expect": {
"effect": "deny"
}
}
]

6
tests/fixtures/review-policy/pin.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"package": "informed-decision.fixture",
"version": "v1",
"package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a",
"fixture_only": true
}

50
tests/fixtures/review-policy/policy.md vendored Normal file
View file

@ -0,0 +1,50 @@
---
id: informed-decision.fixture
name: Synthetic review API contract fixture
namespace: informed-decision:decision-memo
version: v1
status: ready
package: flexauth.informed_decision.fixture
allow_ttl: 60s
actions: [read, acknowledge, accept, return, discuss, decline]
owner: fixture-only
fixtures: [fixtures.json]
caring:
profile: caring-0.4.0-rc2
enforce: false
activation:
mode: local
---
# Synthetic component fixture, never a production policy
Only the locally signed human-fixture and memo:memo-1 can exercise this test.
No production approver, group, mandate or permission is established here.
No presentation claim, view hash, acknowledgment or local outcome is input.
```rego
import rego.v1
decision := {"effect": "allow", "reason": "synthetic_review_fixture"} if {
input.tenant == "tenant:platform"
input.subject.id == "human-fixture"
input.subject.type == "human"
input.resource.system == "informed-decision"
input.resource.type == "decision-memo"
input.resource.id == "memo:memo-1"
input.action in {"read", "acknowledge", "accept", "return", "discuss", "decline"}
input.context.approval_id == "fixture"
} else := {"effect": "deny", "reason": "fixture_scope_refused"} if {
true
}
```
```rego test
package flexauth.informed_decision.fixture_test
import rego.v1
import data.flexauth.informed_decision.fixture
test_unknown_request_denied if {
fixture.decision.effect == "deny" with input as {}
}
```

View file

@ -0,0 +1,18 @@
{
"subjects": [
{
"id": "human-fixture",
"type": "Human",
"display_name": "Synthetic browser reviewer",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [],
"tenant": "tenant:platform",
"metadata": {
"source": "synthetic-contract-fixture"
}
}
]
}

View file

@ -0,0 +1,79 @@
"""Disposable actual Flex Auth HTTP process, with synthetic Kubernetes custody.
The evaluator and inbound caller authenticator are real. TokenReview and its
identities are fixtures; this does not admit any production caller or policy.
"""
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import socket
import subprocess
import threading
import time
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import NameOID
from informed_decision.http_transport import JSONTransport, TransportError
from informed_decision.policy import PolicyClient
FIXTURE = Path(__file__).parent / 'fixtures/review-policy'
@contextmanager
def flex_component(binary, directory, signing_key):
tokens={'synthetic-workload-token':'system:serviceaccount:informed-decision:review',
'synthetic-wrong-caller':'system:serviceaccount:other:other'}
calls=[]
class Reviewer(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_POST(self):
body=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
token=body['spec']['token'];calls.append({'known':token in tokens,'audiences':body['spec']['audiences']})
valid=(self.path=='/apis/authentication.k8s.io/v1/tokenreviews'
and self.headers.get('Authorization')=='Bearer synthetic-reviewer'
and body['spec']['audiences']==['flex-auth'] and token in tokens)
raw=json.dumps({'status':{'authenticated':valid,'audiences':['flex-auth'] if valid else [],
'user':{'username':tokens.get(token,'')}}}).encode()
self.send_response(201);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(raw)
server=ThreadingHTTPServer(('127.0.0.1',0),Reviewer)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
now=datetime.now(timezone.utc)
name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic-tokenreview-fixture')])
cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(signing_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1))
.not_valid_after(now+timedelta(hours=1)).add_extension(x509.BasicConstraints(ca=True,path_length=None),critical=True)
.sign(signing_key,hashes.SHA256()))
ca=directory/'fixture-ca.pem';ca.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
reviewer=directory/'reviewer-token';reviewer.write_text('synthetic-reviewer');reviewer.chmod(0o600)
with socket.socket() as listener:
listener.bind(('127.0.0.1',0));port=listener.getsockname()[1]
origin=f'http://127.0.0.1:{port}'
process=subprocess.Popen([str(binary),'serve','--addr',f'127.0.0.1:{port}',
'--registry',str(FIXTURE/'registry.json'),'--policy',str(FIXTURE/'policy.md'),
'--caller-auth-mode','enforce','--caller-binding','informed-decision=system:serviceaccount:informed-decision:review',
'--caller-kubernetes-url',f'http://127.0.0.1:{server.server_port}',
'--caller-reviewer-token-file',str(reviewer),'--caller-ca-file',str(ca)],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
try:
transport=JSONTransport(allow_internal_http=True)
for _ in range(100):
if process.poll() is not None:
raise RuntimeError('fixture evaluator exited: '+process.stderr.read().decode()[:1000])
try:
if transport.request('GET',origin+'/healthz')[0]==200:break
except TransportError:pass
time.sleep(.05)
else:raise RuntimeError('fixture evaluator did not start')
pin=json.loads((FIXTURE/'pin.json').read_text());pin.pop('fixture_only')
client=PolicyClient(origin,lambda:'synthetic-workload-token',allow_internal_http=True,**pin)
yield client,calls
finally:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
process.stderr.close();server.shutdown();server.server_close();thread.join(timeout=5)

View file

@ -274,7 +274,7 @@ def test_issuer_error_and_duplicate_params_do_not_leak_or_exchange(login):
@pytest.mark.parametrize("origin,csrf,expected", [(ORIGIN, "correct", 303), (ORIGIN, "wrong", 403),
(None, "correct", 403), ("https://attacker.test", "correct", 403)])
(None, "correct", 403), ("null", "correct", 403), ("https://attacker.test", "correct", 403)])
def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected):
sid = finish(login)
token = login[0].session(sid).csrf if csrf == "correct" else "wrong"
@ -284,6 +284,12 @@ def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected):
assert bool(login[0].session(sid)) == (expected != 303)
@pytest.mark.parametrize("path,policy", [("/", "same-origin"), ("/review", "same-origin"),
("/auth/callback", "no-referrer"), ("/presentations/pres-fixture/packet/0", "no-referrer")])
def test_browser_referrer_policy_preserves_form_origin_and_protects_auth_urls(login, path, policy):
assert dict(call(App(login[0]), path)["headers"])["Referrer-Policy"] == policy
@pytest.mark.parametrize("path,method,expected", [("/healthz", "GET", 200), ("/readyz", "GET", 503),
("/approvals/approval-fixture", "GET", 404), ("/approvals/approval-fixture/accept", "POST", 404),
("/v1/approvals/approval-fixture/entries", "POST", 404), ("/auth/logout", "GET", 404)])

View file

@ -22,6 +22,8 @@ def upstream():
"/ok": (200, b'{"ok":true}'), "/refuse": (403, b'{"error":"forbidden"}'),
"/broken": (200, b"broken"), "/array": (200, b"[]"),
"/large": (200, b" " * 262145),
"/plain-refuse": (403, b"forbidden secret-sentinel"),
"/large-refuse": (401, b"x" * 262145),
}.get(self.path, (500, b"{}"))
self.send_response(status)
self.end_headers()
@ -60,3 +62,9 @@ def test_json_success_and_typed_refusal_preserved():
client = JSONTransport(allow_internal_http=True)
assert client.request("GET", origin + "/ok") == (200, {"ok": True})
assert client.request("GET", origin + "/refuse") == (403, {"error": "forbidden"})
@pytest.mark.parametrize("path,status", [("/plain-refuse",403),("/large-refuse",401)])
def test_http_caller_refusal_is_not_relabelled_as_transport_outage(path,status):
with upstream() as (origin,calls):
assert JSONTransport(allow_internal_http=True).request("GET",origin+path)==(status,{})

114
tests/test_policy_client.py Normal file
View file

@ -0,0 +1,114 @@
import copy
from dataclasses import replace
from datetime import datetime, timezone
import json
import time
import pytest
from informed_decision.http_transport import TransportError
from informed_decision.oidc import HumanSession
from informed_decision.policy import CONTRACT, PolicyClient, PolicyError, build_request, submitted_digest
from informed_decision.provenance import Claim, Route
from test_skeleton import make_memo
PIN = "sha256:" + "b" * 64
STAMP = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat()
def human():
return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION),
Claim("human", Route.AUTHENTICATION), {"at": int(time.time()) - 20, "level": "aal2", "mfa": True,
"methods": ["pwd", "otp"], "source": "key-cape"}, time.time()+600, "synthetic-browser-token", roles=("Reviewer",))
def envelope(request):
now = time.time()
return {"id": "decision-fixture", "contract_version": CONTRACT, "request_id": request["id"],
"effect": "allow", "matched_policy_version": "v1", "subject": copy.deepcopy(request["subject"]),
"resource": copy.deepcopy(request["resource"]),
"binding": {**copy.deepcopy({k:request[k] for k in ("tenant","subject","action","resource","context")}),
"request_digest": "sha256:" + "e" * 64, "submitted_request_digest": submitted_digest(request)},
"lifetime": {"kind":"ttl", "not_before":STAMP(now-1), "expires_at":STAMP(now+60)},
"provenance": {"evaluator":"flex-auth/local", "mode":"standalone", "policy_package":"informed-decision.fixture",
"policy_version":"v1", "policy_package_digest":PIN, "registry_snapshot_digest":"sha256:"+"c"*64,
"decision_time":STAMP(now)}}
class PolicyTransport:
def __init__(self): self.calls=[]; self.change=lambda d: None; self.status=200
def request(self, method, url, *, headers=None, body=None):
self.calls.append((method,url,headers,json.loads(body)))
data=envelope(json.loads(body)); self.change(data)
return self.status, data
def policy(transport=None):
return PolicyClient("https://policy.test", lambda:"synthetic-workload-token", package="informed-decision.fixture",
version="v1", package_digest=PIN, transport=transport or PolicyTransport())
def request():
return build_request(human(), make_memo(approval_binding_digest="sha256:"+"a"*64), "read", "v1")
def test_request_preserves_identity_routes_and_excludes_presentation_claims():
req=request(); attributes=req["subject"]["attributes"]
assert attributes["tenant_source"]=="registration-supplied" and attributes["principal_type_source"]=="authentication-derived"
assert attributes["roles"]==["Reviewer"]
assert set(req["context"])=={"memo_version","approval_id","approval_binding_digest"}
assert "synthetic-browser-token" not in json.dumps(req)
assert not any(k in json.dumps(req) for k in ["view_hash","presentation_id","acked_highlight_ids"])
def test_fresh_exact_enriched_decision_and_separate_workload_identity():
client=policy();req=request();observed=client.check(req)
observed.require_current(time.time())
data=json.loads(observed.decision_json)
assert data["binding"]["request_digest"]!=data["binding"]["submitted_request_digest"]
assert client.transport.calls[0][2]["Authorization"]=="Bearer synthetic-workload-token"
assert observed.usable_until<=time.time()+30
with pytest.raises(PolicyError):observed.require_current(time.time()+31)
@pytest.mark.parametrize("mutate", [
lambda d:d.update(contract_version="unknown"), lambda d:d.update(request_id="other"),
lambda d:d["binding"].pop("submitted_request_digest"),
lambda d:d["binding"].update(submitted_request_digest="sha256:"+"0"*64),
lambda d:d["binding"].update(action="accept"), lambda d:d["binding"]["subject"].update(id="other"),
lambda d:d["binding"]["resource"].update(system="other"),
lambda d:d["binding"]["context"].update(memo_version=99),
lambda d:d["binding"].update(tenant="tenant:other"), lambda d:d["resource"].update(id="memo:other"),
lambda d:d["provenance"].update(policy_package="other"), lambda d:d["provenance"].update(policy_version="other"),
lambda d:d["provenance"].update(policy_package_digest="sha256:"+"0"*64),
lambda d:d.update(matched_policy_version="other"), lambda d:d["provenance"].pop("registry_snapshot_digest"),
lambda d:d["provenance"].update(decision_time=STAMP(time.time()-300)),
lambda d:d["lifetime"].update(expires_at=STAMP(time.time()-1)),
lambda d:d["lifetime"].update(not_before=STAMP(time.time()+300)),
lambda d:d["lifetime"].update(kind="forever"), lambda d:d.update(lifetime={}),
lambda d:d.update(obligations=[{"type":"unimplemented"}]), lambda d:d.update(obligations={}),
lambda d:d.update(obligations=None), lambda d:d.update(effect="redact"),
lambda d:d.update(effect="audit_only"), lambda d:d.update(effect="not_applicable"),
lambda d:d.update(effect="deny"), lambda d:d.update(diagnostics={"bad":float("nan")}),
])
def test_unusable_decision_never_allows(mutate):
transport=PolicyTransport();transport.change=mutate
observed=policy(transport).check(request())
with pytest.raises(PolicyError):observed.require_current(time.time())
@pytest.mark.parametrize("status", [400,401,403,404,409,500,503])
def test_upstream_errors_never_persist_response_body(status):
transport=PolicyTransport();transport.status=status
transport.change=lambda d:d.update(reason="secret-sentinel",diagnostics={"token":"secret-sentinel"})
observed=policy(transport).check(request())
assert observed.decision_json is None and "secret-sentinel" not in repr(observed)
with pytest.raises(PolicyError):observed.require_current(time.time())
def test_transport_and_missing_caller_have_typed_observations():
class Broken:
def request(self,*a,**kw):raise TransportError("secret-sentinel")
client=policy(Broken());assert client.check(request()).outcome=="policy_unavailable"
client.token_provider=lambda:None
assert client.check(request()).outcome=="caller_unavailable"

View file

@ -0,0 +1,76 @@
from dataclasses import replace
import json
import os
from pathlib import Path
import time
import pytest
from informed_decision.disposition import Verb
from informed_decision.policy import build_request
from informed_decision.runtime import AuditPump
from informed_decision.web import SESSION_COOKIE
from test_browser_auth import call, signing_key
from test_approval_component import component
from test_review_controller import review, opened
from test_durable_component import receiver
from review_components import flex_component
@pytest.fixture
def pdp(tmp_path,signing_key):
binary=os.environ.get('INFD_FLEX_AUTH_BINARY')
if not binary:pytest.skip('set INFD_FLEX_AUTH_BINARY to test the actual evaluator and caller boundary')
assert Path(binary).is_file()
with flex_component(binary,tmp_path,signing_key) as value:yield value
def test_actual_pdp_enrichment_and_workload_caller_boundary(pdp,review):
client,calls=pdp;controller,session,_,memo,*_=review
request=build_request(session,memo,'read',client.version)
observation=client.check(request);assert observation.outcome=='allow',observation
decision=json.loads(observation.decision_json)
assert decision['binding']['request_digest']!=decision['binding']['submitted_request_digest']
assert calls[-1]=={'known':True,'audiences':['flex-auth']}
client.token_provider=lambda:'synthetic-wrong-caller'
assert client.check(request).outcome=='caller_refused'
client.token_provider=lambda:session.access_token
assert client.check(request).outcome=='caller_refused' # Browser JWT is never a workload credential.
def test_actual_request_digest_handles_go_string_escaping(pdp,review):
client,_=pdp;_,session,_,memo,*_=review
session=replace(session,roles=('Reviewer <x>& ü \u2028 \u2029',))
observation=client.check(build_request(session,memo,'read',client.version))
assert observation.outcome=='allow',observation
def test_actual_policy_engine_and_independent_audit_receive_browser_act(pdp,review,receiver):
controller,session,app,memo,engine,transport=review;controller.policy=pdp[0]
page=opened(review);p=page.presentation
controller.acknowledge(session,p.id,['h-1'])
controller.act(session,p.id,Verb.ACCEPT,operation_id='real-component-click')
assert len(engine.get('fixture').entries)==1
sink,receiver_transport,_=receiver
pump=AuditPump(controller.store,sink);pump.tick();assert pump.ready()
assert all(row['state']=='delivered' for row in controller.store.outbox())
observations=controller.store.policy_observations()
assert len(observations)==3 and all(row['outcome']=='allow' and row['decision_attributable']==0 for row in observations)
response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session')
assert 'Approval entry recorded.' in response['body']
event=controller.store.evidence()[0]
status,body=receiver_transport.request('GET','https://audit.test/v1/events/'+event['id'],headers={'Authorization':'Bearer synthetic-auditor-token'})
assert status==200 and body['details']['data']['view_hash']==p.view_hash
assert 'private-brief-sentinel' not in json.dumps(body)
report=json.loads((controller.store.path.parent/'audit-reconciliation.json').read_text())
assert report['source_time_basis']=='occurred_at' and report['receiver_time_basis']=='accepted_at'
assert not report['completeness_proven'] and not report['automatic_loss_finding']
def test_actual_policy_refusal_prevents_content_and_engine_read(pdp,review):
controller,session,app,memo,engine,transport=review;controller.policy=pdp[0]
controller.store.save_memo(replace(memo,id='not-assigned-in-fixture'))
before=len(transport.calls)
response=call(app,'/review',query='memo_id=not-assigned-in-fixture',cookie=SESSION_COOKIE+'=fixture-session')
assert response['status']==403 and 'private-brief-sentinel' not in response['body']
assert len(transport.calls)==before and controller.store.evidence()==[]

View file

@ -0,0 +1,254 @@
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
import json
import sqlite3
import time
from urllib.parse import urlencode
import uuid
import pytest
from informed_decision.approval_http import ApprovalHTTPClient
from informed_decision.disposition import DispositionRefused, Verb
from informed_decision.http_transport import TransportError
from informed_decision.memo import BindingLevel, Highlight, PacketItem, Principal, Scope, StepKind
from informed_decision.policy import PolicyError
from informed_decision.review import ReviewController, ReviewError
from informed_decision.store import Conflict, Store
from informed_decision.web import App, SESSION_COOKIE
from informed_decision.oidc import ORIGIN
from test_approval_component import component, signing_key
from test_browser_auth import call
from test_policy_client import policy
from test_skeleton import make_memo
@pytest.fixture
def review(component, tmp_path):
client,engine,transport,session,_=component
private=tmp_path/'private';private.mkdir(mode=0o700)
store=Store(private/'review.sqlite')
packet=store.put_document(b'<script>private-packet-sentinel</script>\nFull fixture terms.')
memo=make_memo(approval_id='fixture',approval_binding_digest=client.get_approval('fixture')['binding']['digest'],
question='Approve the synthetic factory delivery?', requested_act='deliver',
highlights=(Highlight('h-1','doc-1','Disposable test target; no production action',required_ack=True),),
packet=(PacketItem('doc-1','Whole fixture document',packet),),
brief='Disposable browser/component exercise. private-brief-sentinel <img src=x onerror=alert(1)>')
memo=replace(memo,binding=replace(memo.binding,principal=Principal(session.subject,'person','Fixture reviewer'),
target=Scope('resource','fixture','Synthetic factory target','test')))
store.save_memo(memo)
controller=ReviewController(store,policy(),lambda s:ApprovalHTTPClient('https://approval.test',s,transport=transport))
class Login:
def session(self,sid):return session if sid=='fixture-session' else None
app=App(Login(),controller,readiness=lambda:True)
return controller,session,app,memo,engine,transport
def opened(review):
controller,session,*_=review
return controller.open(session,'memo-1')
def post(review,p,route,**params):
_,session,app,*_=review
return call(app,'/presentations/'+p.id+'/'+route,'POST',cookie=SESSION_COOKIE+'=fixture-session',
origin=ORIGIN,body=urlencode({'csrf':session.csrf,**params}).encode())
def test_read_denial_precedes_disclosure_engine_read_and_presentation(review):
controller,session,app,memo,engine,transport=review
controller.policy.transport.change=lambda d:d.update(effect='deny')
before=len(transport.calls)
r=call(app,'/review',query='memo_id=memo-1',cookie=SESSION_COOKIE+'=fixture-session')
assert r['status']==403 and 'private-brief-sentinel' not in r['body']
assert len(transport.calls)==before and controller.store.evidence()==[]
assert controller.store.policy_observations()[0]['outcome']=='policy_denied'
def test_no_session_or_wrong_named_recipient_cannot_check_or_render(review):
controller,session,app,*_=review
assert call(app,'/review',query='memo_id=memo-1')['status']==401
with pytest.raises(ReviewError):controller.open(replace(session,subject='other'),'memo-1')
assert controller.policy.transport.calls==[] and controller.store.policy_observations()==[]
@pytest.mark.parametrize('change', [{'locale':'de'}, {'binding_level':BindingLevel.AES},
{'binding_level':BindingLevel.QES}, {'step_kind':StepKind.REVIEW}])
def test_unsupported_profile_cannot_misstate_locale_or_binding_level(review,change):
controller,session,_,memo,*_=review
controller.store.save_memo(memo.next_version(**change))
with pytest.raises(ReviewError,match='unsupported_review_profile'):
controller.open(session,memo.id)
assert controller.policy.transport.calls==[] and controller.store.evidence()==[]
def test_open_persists_exact_policy_and_binding_but_not_browser_credential(review):
controller,session,app,*_=review
page=opened(review);content=controller.store.presentation_content(page.presentation.id)
observed=controller.store.policy_observations()
assert observed[0]['decision_attributable']==0
assert content['policy_observation_id']==observed[0]['id']
assert content['approval_binding']['digest']==page.memo.approval_binding_digest
assert session.access_token not in json.dumps(observed)
assert all('policy_observation_id' not in r['envelope'] for r in controller.store.evidence())
response=call(app,'/presentations/'+page.presentation.id,cookie=SESSION_COOKIE+'=fixture-session')
assert response['status']==200 and '&lt;img' in response['body'] and '<img src=x' not in response['body']
assert '&lt;script&gt;private-packet-sentinel' in response['body']
@pytest.mark.parametrize('fault',['origin','csrf','duplicate','content-type','field'])
def test_post_form_refusals_precede_policy_and_side_effects(review,fault):
controller,session,app,*_=review;p=opened(review).presentation
body=urlencode({'csrf':session.csrf,'h0':'h-1'}).encode();origin=ORIGIN;ctype='application/x-www-form-urlencoded'
if fault=='origin':origin='https://other.test'
if fault=='csrf':body=b'csrf=wrong&h0=h-1'
if fault=='duplicate':body+=b'&h0=other'
if fault=='content-type':ctype='application/json'
if fault=='field':body+=b'&subject=other'
before=len(controller.policy.transport.calls)
r=call(app,'/presentations/'+p.id+'/ack','POST',cookie=SESSION_COOKIE+'=fixture-session',body=body,origin=origin,content_type=ctype)
assert r['status'] in (400,403) and len(controller.policy.transport.calls)==before
assert not controller.store.presentation(p.id).acked_highlight_ids
def test_highlights_gate_accept_then_one_real_entry_and_original_view(review):
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
op=str(uuid.uuid4())
assert post(review,p,'act',verb='accept',operation_id=op)['status']==409
assert not engine.get('fixture').entries
assert post(review,p,'ack',h0='h-1')['status']==303
assert post(review,p,'act',verb='accept',operation_id=op)['status']==303
assert len(engine.get('fixture').entries)==1
posts=sum(method=='POST' for method,_ in transport.calls)
assert post(review,p,'act',verb='accept',operation_id=op)['status']==303
assert sum(method=='POST' for method,_ in transport.calls)==posts==1
page=controller.open(session,memo.id)
assert page.presentation.id==p.id and page.intent['state']=='confirmed'
response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session')
assert 'Approval entry recorded.' in response['body']
assert 'Accept the complete request</button>' not in response['body']
assert not any(url.endswith('/consume') for _,url in transport.calls)
@pytest.mark.parametrize('verb',[Verb.RETURN,Verb.DISCUSS,Verb.DECLINE])
def test_nonaccept_actions_preserve_vocabulary_without_engine_mutation(review,verb):
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
controller.acknowledge(session,p.id,['h-1'])
result=post(review,p,'act',verb=verb.value,operation_id=str(uuid.uuid4()),reason='clarification_needed' if verb is Verb.RETURN else '',note='Private response')
assert result['status']==303 and not engine.get('fixture').entries
assert controller.store.dispositions_for(p.id)[0].verb is verb
response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session')
assert 'Private response' in response['body']
assert all('Private response' not in r['envelope'] for r in controller.store.evidence())
def test_fresh_act_refusal_after_allowed_render_cannot_submit(review):
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
controller.acknowledge(session,p.id,['h-1'])
controller.policy.transport.change=lambda d:d.update(effect='deny')
assert post(review,p,'act',verb='accept',operation_id=str(uuid.uuid4()))['status']==403
assert not engine.get('fixture').entries and controller.store.dispositions_for(p.id)==[]
def test_policy_storage_failure_prevents_presentation(review):
controller,session,*_=review
with sqlite3.connect(controller.store.path) as db:
db.execute("CREATE TRIGGER fail_observation BEFORE INSERT ON policy_observations BEGIN SELECT RAISE(ABORT,'disk failure'); END")
with pytest.raises(sqlite3.IntegrityError):opened(review)
assert controller.store.evidence()==[]
def test_revision_between_policy_and_present_cannot_render_unchecked_version(review):
controller,session,_,memo,*_=review
controller.policy.transport.change=lambda d:controller.store.save_memo(memo.next_version(brief='changed'))
with pytest.raises(Conflict):opened(review)
assert controller.store.evidence()==[]
def test_stale_presentation_and_wrong_actor_cannot_ack_or_act(review):
controller,session,_,memo,engine,_=review;p=opened(review).presentation
with pytest.raises(ReviewError):controller.acknowledge(replace(session,subject='other'),p.id,['h-1'])
controller.store.save_memo(memo.next_version(brief='new version'))
with pytest.raises(ReviewError):controller.acknowledge(session,p.id,['h-1'])
with pytest.raises(ReviewError):controller.act(session,p.id,Verb.ACCEPT,operation_id='stale')
assert not engine.get('fixture').entries
def test_expiry_after_last_engine_read_prevents_reservation_and_post(review):
controller,session,_,memo,engine,transport=review;p=opened(review).presentation
controller.acknowledge(session,p.id,['h-1'])
original=transport.request;reads=0
def request(method,url,**kwargs):
nonlocal reads
result=original(method,url,**kwargs)
if method=='GET':
reads+=1
if reads==2:controller.clock=lambda:time.time()+40
return result
transport.request=request
with pytest.raises(PolicyError):controller.act(session,p.id,Verb.ACCEPT,operation_id='late')
assert not engine.get('fixture').entries
assert controller.store.intent_for('fixture',session.subject)['state']=='prepared'
def test_parallel_clicks_submit_once(review):
controller,session,_,memo,engine,transport=review;p=opened(review).presentation
controller.acknowledge(session,p.id,['h-1'])
def click(_):return controller.act(session,p.id,Verb.ACCEPT,operation_id='one-click')
with ThreadPoolExecutor(max_workers=2) as pool:assert list(pool.map(click,range(2)))==[p.id,p.id]
assert len(engine.get('fixture').entries)==1
assert sum(method=='POST' for method,_ in transport.calls)==1
def test_lost_entry_reply_is_visible_and_never_retried(review):
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
controller.acknowledge(session,p.id,['h-1'])
original=transport.request
def lose_reply(method,url,**kwargs):
result=original(method,url,**kwargs)
if method=='POST':raise TransportError('lost response')
return result
transport.request=lose_reply
controller.act(session,p.id,Verb.ACCEPT,operation_id='lost')
assert controller.store.intent_for('fixture',session.subject)['state']=='unresolved'
response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session')
assert 'Submission outcome is not confirmed.' in response['body']
assert 'Accept the complete request</button>' not in response['body']
controller.act(session,p.id,Verb.ACCEPT,operation_id='new-click')
assert len(engine.get('fixture').entries)==1 and sum(m=='POST' for m,_ in transport.calls)==1
def test_unknown_existing_entry_never_acquires_a_new_presentation_link(review):
controller,session,_,memo,engine,transport=review;p=opened(review).presentation
ApprovalHTTPClient('https://approval.test',session,transport=transport).add_entry('fixture')
with pytest.raises(ReviewError,match='existing_entry_unlinked'):
controller.act(session,p.id,Verb.ACCEPT,operation_id='unlinked')
assert controller.store.intent_for('fixture',session.subject) is None
assert sum(m=='POST' for m,_ in transport.calls)==1
def test_packet_download_rechecks_entitlement_and_forces_attachment(review):
controller,session,app,*_=review;p=opened(review).presentation
path='/presentations/'+p.id+'/packet/0'
response=call(app,path,cookie=SESSION_COOKIE+'=fixture-session')
assert response['status']==200
assert dict(response['headers'])['Content-Type']=='application/octet-stream'
assert dict(response['headers'])['Content-Disposition'].startswith('attachment;')
controller.policy.transport.change=lambda d:d.update(effect='deny')
denied=call(app,path,cookie=SESSION_COOKIE+'=fixture-session')
assert denied['status']==403 and 'private-packet-sentinel' not in denied['body']
def test_audit_not_ready_blocks_accept_before_policy_or_post(review):
controller,session,app,memo,engine,_=review;p=opened(review).presentation
app.readiness=lambda:False
assert post(review,p,'act',verb='accept',operation_id=str(uuid.uuid4()))['status']==503
assert not engine.get('fixture').entries
assert call(app,'/readyz')['status']==503
def test_renderer_release_cannot_silently_change_under_a_memo(review):
controller,session,_,memo,*_=review
controller.store.save_memo(memo.next_version(ui_release='informed-decision@0.1.0'))
with pytest.raises(ReviewError,match='renderer_changed'):opened(review)
assert controller.store.evidence()==[]

View file

@ -0,0 +1,82 @@
from dataclasses import replace
import json
import sqlite3
import time
import pytest
from informed_decision.runtime import Runtime, AuditPump, token_file
from informed_decision.store import Store
from test_policy_client import policy,request
from test_durable_store import storage,present
from test_durable_component import receiver
def test_schema_one_upgrade_preserves_existing_evidence(storage):
store,memo=storage;p=present(store,memo)
before=store.evidence()
with sqlite3.connect(store.path) as db:
db.execute('DROP TABLE policy_observations');db.execute('PRAGMA user_version=1')
reopened=Store(store.path)
assert reopened.retrieve_presentation(p.id)[0]==memo and reopened.evidence()==before
assert reopened.policy_observations()==[]
observation=policy().check(request());reopened.observe_policy(observation)
assert Store(store.path).policy_observations()[0]['decision_attributable']==0
with sqlite3.connect(store.path) as db:
for statement in ['DELETE FROM policy_observations',"UPDATE policy_observations SET outcome='allow'"]:
with pytest.raises(sqlite3.IntegrityError):db.execute(statement)
def test_caller_token_is_reread_on_rotation_and_bounded(tmp_path):
path=tmp_path/'token';path.write_text('synthetic-first\n');read=token_file(path)
assert read()=='synthetic-first'
path.write_text('synthetic-rotated');assert read()=='synthetic-rotated'
path.write_bytes(b'x'*32769)
with pytest.raises(ValueError):read()
with pytest.raises(ValueError):token_file('relative')
def config(tmp_path):
private=tmp_path/'private';private.mkdir(mode=0o700)
path=private/'runtime.json'
data={'schema':'informed-decision.review-runtime.v1','evidence_db':str(private/'review.sqlite'),
'approval_origin':'https://approval.test','policy':{'origin':'https://policy.test','package':'fixture',
'version':'v1','package_digest':'sha256:'+'a'*64,'caller_token_file':str(private/'unprovisioned-caller')},
'audit':{'origin':'https://audit.test','sender_token_file':str(private/'unprovisioned-sender')}}
path.write_text(json.dumps(data));path.chmod(0o600)
return path,data
def test_config_load_does_not_provision_or_read_credentials(tmp_path):
path,data=config(tmp_path);runtime=Runtime.from_file(path)
assert not runtime.pump.ready()
assert not (path.parent/'unprovisioned-caller').exists()
runtime.pump.tick();assert not runtime.pump.ready()
assert all(r['state']=='blocked' for r in runtime.controller.store.outbox())
@pytest.mark.parametrize('fault',['permissions','symlink','unknown-key','relative-store','missing-pin','unsafe-origin'])
def test_unsafe_or_incomplete_config_refused(tmp_path,fault):
path,data=config(tmp_path)
if fault=='permissions':path.chmod(0o644)
elif fault=='symlink':
linked=path.parent/'link.json';linked.symlink_to(path);path=linked
elif fault=='unknown-key':data['allow_everyone']=True
elif fault=='relative-store':data['evidence_db']='relative.db'
elif fault=='missing-pin':data['policy']['package_digest']=''
elif fault=='unsafe-origin':data['approval_origin']='http://public.example.com'
if fault not in ('permissions','symlink'):path.write_text(json.dumps(data))
with pytest.raises(ValueError):Runtime.from_file(path)
def test_pump_retains_blocked_delivery_and_requires_repair(storage,receiver):
store,memo=storage;present(store,memo)
sink,_,_=receiver;original=sink.token_provider;sink.token_provider=lambda:'synthetic-wrong-token'
pump=AuditPump(store,sink);pump.tick();assert not pump.ready()
ids=[r['id'] for r in store.outbox() if r['state']=='blocked'];assert ids
sink.token_provider=original
pump.tick();assert not pump.ready() # No implicit credential repair/requeue.
for ident in ids:store.requeue_blocked(ident)
pump.tick();assert pump.ready()
assert (store.path.parent/'audit-reconciliation.json').stat().st_mode & 0o777 == 0o600
pump.clock=lambda:time.time()+91;assert not pump.ready()

View file

@ -8,7 +8,7 @@ status: active
owner: claude
topic_slug: netkingdom
created: "2026-09-09"
updated: "2026-09-09"
updated: "2026-09-11"
reviewed_at: "2026-09-09"
reviewed_against_commit: "ee2cca5"
reviewed_note: >-
@ -568,6 +568,50 @@ in reconciliation, supply native custody and registered human/deployed-engine
proof. These remain live work in this task; `/readyz` stays 503 and browser bind
routes remain absent. Details: `docs/durable-review-evidence.md`.
2026-09-11 — **protected review and actual browser exercise implemented.**
The configured `0.2.0` service now connects the named memo to fresh Flex Auth
checks before render, download, acknowledgment and each human response. The
separate workload caller, exact package/version/digest and submitted request
binding are validated; observations are immutable and retain
`decision_attributable=false`. No presentation claim becomes a policy input.
Actual native component checks cover caller enforcement, request digest wire
format and registry enrichment, with fixture-only assignments and credentials.
The browser supports explicit highlights, accept/return/discuss/decline, current
actor/version/session/act checks, original confirmed correlation and visible
uncertain submissions without POST retry. An audit thread drains bounded batches,
emits declared heartbeats and saves a two-time-base reconciliation snapshot;
unhealthy delivery closes acceptance readiness. Owner configuration provisions
no identity, policy or custody. Existing v1 databases migrate atomically to v2;
old memo UI releases require a new version. The first browser profile is English
organizational approval; unsupported locale/step/level refuses before rendering.
344 automated checks pass, including actual Flex Auth, Approval Engine and
Audit Core components. Twelve Chromium checks exercise local HTTPS, synthetic
PKCE login, cookies, entitlement, escaped packet content, acknowledgment bypass,
return, responsive layout, a single accepted entry/reload, audit delivery,
caller refusal and sign-out. The browser caught `no-referrer` suppressing the
form Origin; review pages now preserve same-origin headers while auth/downloads
remain no-referrer. Missing/null/foreign form origins and bad CSRF still refuse.
Plain-text native caller 401/403 responses also retain their refusal meaning.
Evidence: `docs/evidence/2026-09-11-protected-browser-review.json` and the
accompanying browser receipt/screenshots. This is disposable component/browser
proof, **not native human login, admitted policy/custody, deployment or factory
execution**. Factory attempts and paid calls remain zero.
**Next within T08:** obtain owner admission against
`docs/flex-auth-review-contract.md` for the exact production package/pins,
caller and assignment source. Admit native review-sender custody with
AUDIT-WP-0009-T11, registration rollout, service packaging/private state,
backup/restore and uncertain-entry operating procedure; then exercise the
deployed callback and actual human binding against the admitted Approval Engine.
German UI acceptance (PR-60), broader product steps/awareness controls and the
specified evidence export remain product work under this live task. They must
not be silently claimed by the bounded factory profile. The exact operational
setup and remaining limits are in `docs/protected-browser-review.md`.
T08 remains `progress`; no residual has been hidden by finishing the workplan.
## Known risks
- **T02 is a hard gate.** Writing the blueprint before the layer ruling risks