From c1cf6cf54cb74ef67ea4557583872f383d24e835 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 3 Oct 2026 18:45:10 +0200 Subject: [PATCH] Implement offline workload intake and record blocked owner dependencies Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029c-c003-7283-acc9-930b434e6cae --- SCOPE.md | 16 ++ WORK-RECORDS.md | 8 +- deploy/intake-submitters.json | 1 + deploy/review-groups.json | 1 + docs/intake-dependency-requirements.md | 126 ++++++++++ docs/specs/FrictionlessDecisionIntake.md | 46 +++- informed_decision/intake_approval.py | 34 +++ informed_decision/intake_delivery.py | 68 ++++++ informed_decision/intake_service.py | 218 ++++++++++++++++++ informed_decision/store.py | 29 ++- tests/test_intake_service.py | 201 ++++++++++++++++ tests/test_review_runtime.py | 9 +- ...FD-WP-0005-frictionless-decision-intake.md | 58 ++++- 13 files changed, 790 insertions(+), 25 deletions(-) create mode 100644 deploy/intake-submitters.json create mode 100644 deploy/review-groups.json create mode 100644 docs/intake-dependency-requirements.md create mode 100644 informed_decision/intake_approval.py create mode 100644 informed_decision/intake_delivery.py create mode 100644 informed_decision/intake_service.py create mode 100644 tests/test_intake_service.py diff --git a/SCOPE.md b/SCOPE.md index 94814e7..d7c4d55 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -255,3 +255,19 @@ Stated here because a scope file that only lists capabilities overstates them. human proof (`INFD-WP-0001-T08`) and `audit-core` custody/live delivery (`AUDIT-IN-0003`, `AUDIT-WP-0009`) — both live and observed in production as of this Status section. + + +## Offline workload intake — 2026-10-03 + +INFD-WP-0005 implements a separate workload-only WSGI intake application, +injected TokenReview and create-only approval transports, per-request grant +reload, per-submitter daily limits, and a schema-3 durable ledger. It atomically +loads pinned memos and packet bytes and queues notification/posed jobs. Human +terminal dispositions atomically queue optional hub resolution; a leased worker +retries delivery with stable ids and leaves disabled/unconfigured adapters pending. +An uncertain approval create never reposts automatically; retained receipts can +resume local loading. Tests cover schema-1/2 upgrade, rollback, replay/restart, +revocation and write-back. This is offline implementation, not a deployed intake +listener. Owner contracts, workload custody and runtime composition are tracked +as blocked tasks in INFD-WP-0005, with acceptance requirements in +`docs/intake-dependency-requirements.md`. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ed4306e..821df62 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -10,7 +10,7 @@ | --- | --- | --- | --- | --- | | workplan | INFD-WP-ADHOC-2026-10-03 | blocked | — | workplans/ADHOC-2026-10-03.md | | workplan | INFD-WP-0003 | finished | — | workplans/INFD-WP-0003-my-decisions-overview.md | -| workplan | INFD-WP-0005 | proposed | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | +| workplan | INFD-WP-0005 | blocked | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | task | INFD-WP-ADHOC-2026-10-03-T01 | done | — | workplans/ADHOC-2026-10-03.md | | task | INFD-WP-ADHOC-2026-10-03-T02 | wait | — | workplans/ADHOC-2026-10-03.md | | task | INFD-WP-0003-T01 | done | — | workplans/INFD-WP-0003-my-decisions-overview.md | @@ -20,12 +20,12 @@ | task | INFD-WP-0005-T01 | done | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | task | INFD-WP-0005-T02 | done | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | task | INFD-WP-0005-T03 | done | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | -| task | INFD-WP-0005-T04 | todo | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | +| task | INFD-WP-0005-T04 | done | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | task | INFD-WP-0005-T05 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | task | INFD-WP-0005-T06 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | -| task | INFD-WP-0005-T07 | todo | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | +| task | INFD-WP-0005-T07 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | task | INFD-WP-0005-T08 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | -| task | INFD-WP-0005-T09 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | +| task | INFD-WP-0005-T09 | done | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | task | INFD-WP-0005-T10 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md | | intake | INFD-IN-0001 | closed | blue | intakes/intakes.md | | intake | INFD-IN-0002 | closed | blue | intakes/intakes.md | diff --git a/deploy/intake-submitters.json b/deploy/intake-submitters.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/deploy/intake-submitters.json @@ -0,0 +1 @@ +{} diff --git a/deploy/review-groups.json b/deploy/review-groups.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/deploy/review-groups.json @@ -0,0 +1 @@ +{} diff --git a/docs/intake-dependency-requirements.md b/docs/intake-dependency-requirements.md new file mode 100644 index 0000000..b99dc18 --- /dev/null +++ b/docs/intake-dependency-requirements.md @@ -0,0 +1,126 @@ +# Intake implementation and dependency acceptance — 2026-10-03 + +Work record: INFD-WP-0005. These are requirements for owner implementation; +this document grants no deployment or credential-custody change. + +Local implementation: `intake_service.IntakeService` is a separate WSGI app +with only `POST /v1/memos`; `TokenReview` checks an injected Kubernetes response; +`intake_approval.IntakeApprovalClient` sends create-only approval requests through +a bounded transport. Store schema 3 migrates existing schema 1/2 databases and +adds the submission ledger and integration outbox. Packet bytes, pinned memo, +ready ledger and posed/notification jobs commit in one transaction. Terminal +human dispositions queue hub resolution in their existing evidence transaction. +`IntakeDeliveryWorker` dispatches injected audit, notification and hub transports +with leases, stable ids, bounded retries and a disabled-by-default write-back. +No listener is composed into the deployed runtime yet; shipped registries are +empty, so no caller is admitted. No live deployment occurred. + +## railiance-platform — T05, T10 + +Existing request: `cd811073-d6db-41cf-8a57-10df0b360eab`. + +Deliver a reviewed deployment change with: + +- ESO custody for `platform/workloads/informed-decision/sitting-requester`, + mounted read-only to the intake workload; exact-path read grant, sibling-path + refusal, safe secret metadata handling and no values in evidence. +- Separate private intake port and Service, absent from public ingress; + NetworkPolicy admitting only registered submitter namespaces/service accounts. +- TokenReview RBAC for the review workload, API-server CA verification and an + authenticated, bounded TokenReview transport. Project submitter tokens for + audience `informed-decision-intake`; do not reuse browser or audit tokens. +- Project the reviewed JSON registries so removal is visible on the next request + without image rebuild. Review additions independently of the admitted submitter. +- Workload OIDC client-credentials exchange yielding only `approval:create`, + subject `informed-decision`, platform tenant and service principal provenance. + Bind it to `IntakeApprovalClient`; no approve/read/consume scopes. + +Acceptance: wrong audience, unknown SA, removed grant and absent custody fail +closed; one approved dev pose creates one human-control draft. Record immutable +image/config references and founder approval before any live change. + +## access-engine — T06, T10 + +Existing request: `306bcdd2-4fe4-4ccf-8aac-590c2abe51c4`. + +Deliver the §5.2 attribute admission branch, generated class/submitter data, +drift check, exact-record compatibility branch and versioned request/response +fixtures. Confirm field placement for review_group, memo_class, intake_submitter, +approval_requester, approval_human_control, memo_version, approval_id and +approval_binding_digest. Keep human authentication-derived identity, verified +groups and act-time MFA freshness. No view_hash in PDP context. The approval +fact must be read live and matched to the memo pin independently of an allow. + +Acceptance: unknown class/submitter, requester mismatch, false human_control, +digest mismatch and stale MFA refuse; existing exact-record memos still work. +Consumer changes must update both policy request construction and Store's strict +policy-observation context comparison, with live engine reads before checks. +Do not activate consumer attributes against the current exact-only pin. Signed +PDP attribution remains the separately declared FLEX-WP-0024 gap. + +## approval-engine — T05 recovery and production close-down + +Existing request: `59c5ee82-c706-4432-bab4-24af64031d39`. + +Confirm native service creation response fields and optional requested_via. +Provide idempotent creation or a lookup/recovery contract for lost create replies. +Current local behavior reserves before POST: `creating` without a receipt returns +`approval_create_unresolved` and never reposts. `created` with a persisted receipt +can retry the atomic local load without creating another approval. An unresolved +create needs owner-assisted receipt reconciliation; never manufacture a digest +or infer approval state. Idempotent creation is a close-down requirement, not a +condition on offline tests or initial dev rollout. + +## audit-core — T07 + +Existing request: `23b670ac-809e-49bf-9233-e0b62cf0a039`. + +Register `informed-decision.memo.posed` for source informed-decision and tenant +platform; accept the locally retained event envelope containing memo/approval +ids, carried binding digest, authenticated forwarding submitter, memo class and +submission digest. No packet, brief, or secret content. Confirm sender custody, +Idempotency-Key receipts and heartbeat/reconciliation requirements for the new +class. Provide positive/duplicate/refusal fixtures. Only then connect posed +jobs to AuditCoreSink and include the class in runtime readiness/reconciliation. + +## state-hub — T07, T08 + +Existing request: `e30dea31-90e3-4fc0-954a-ed8412b52aca`. + +Deliver forwarding with the projected intake-audience SA token, bounded envelope, +stable idempotency key, durable retry and receipt retention. Identify the actual +SA to review into the submitter registry. Forwarding proves the hub identity; +drafted_by remains asserted and must be visibly described that way. Add a dev +fixture for pose/replay/conflict and a deduplicated notification contract using +our stable delivery id. Notifications carry only id/question/class/submitter; +the signed-in overview is the authoritative review entry point. + +Confirm resolve semantics: terminal human accept/decline only; return/discuss +and resolves: never do not resolve. A 409 must allow reading an existing +resolution to correlate it. Delivery failure retains local evidence and retries; +other-memo conflicts block for review. Bind the hub transports and schedule +IntakeDeliveryWorker in the runtime after these contracts are accepted. Keep +INFD_HUB_WRITEBACK unset until deployment approval and end-to-end evidence. + +## gate-house and founder — T09, T10 + +Reply `4a290496-5aa7-4488-a01d-f8fde925a54b` answers the doctrine check: no +human act is required when posing. GH-DEC-2026-023 is still proposed; this reply +is not a ratified decision or live rollout approval. Spec now records C1–C6 and +F1–F3. Production close-down additionally requires authenticated drafting +identity, independent list review, signed claims/receipts, volume budgets and +retirement of exact-record admission after its memos become terminal. + +T10 resumes after T05–T08 acceptance and founder approval for the concrete +rollout. Record dev positive/negative traces first, then one live human answer +with audit and write-back receipts. The provisioning sitting's human binding +remains blocked in INFD-WP-ADHOC-2026-10-03-T02. + +## Verification + +`uv run --extra dev python -m pytest -q`: 437 passed, 67 skipped (optional +component fixtures are not enabled in this run). After the final receipt-recovery +concurrency guard and nonempty-entry response refusal, the affected intake, +runtime migration, durable store and write-back suites: 63 passed, 1 skipped. +`git diff --check` passed. State Hub consistency reconciled the blocked plan, +completed T04/T09 and waiting T07; no registrar fallback was required. diff --git a/docs/specs/FrictionlessDecisionIntake.md b/docs/specs/FrictionlessDecisionIntake.md index a8ad8c7..c020172 100644 --- a/docs/specs/FrictionlessDecisionIntake.md +++ b/docs/specs/FrictionlessDecisionIntake.md @@ -137,8 +137,8 @@ idempotency_key})`. The ledger key is `(submitter_id, idempotency_key)`: - a memo id already in the store under another key → `409 memo_id_taken`. The ledger row is written **before** the approval-engine POST with state -`creating`, and finished after. A crash between the two leaves a `creating` -row that a retry resumes; see §4 for the orphan-draft gap. +`creating`, and finished after. A crash before receipt retention leaves a `creating` +row that refuses automatic retry; see §4 for recovery requirements. A new version of a memo is a new submission with a new memo id or an explicit `supersedes` relation in a later version of this contract; intake never @@ -172,9 +172,10 @@ only through the attended reader lane. The change: What approval-engine would ideally add (requested, not blocking): 1. An `Idempotency-Key` on `POST /v1/approvals`, so a retried create after a - crash returns the same draft instead of leaving an orphan. Until then intake - resumes `creating` rows by looking up its own receipt, and an orphan draft - is harmless (never bound, expires). + crash returns the same draft instead of leaving an orphan. Until then a `creating` row without a receipt requires explicit + owner-assisted reconciliation and never automatically repeats creation. A + `created` row with a durable receipt retries local loading only. An orphan + draft is never bound and expires. 2. An optional, non-binding `requested_via` evidence field so the engine's own record names the original submitter. @@ -337,3 +338,38 @@ satisfy the doctrine the exact-record list was serving. The operator scripts (`create_sitting_approvals.*`, `attach_compact_bindings.py`, `load_sitting_memos.py`) stay as the fallback until phase 3 is live, then retire. + +## 11. Doctrine response and local implementation — 2026-10-03 + +Gate House reply `4a290496-5aa7-4488-a01d-f8fde925a54b` confirms no human act +is required at posing time. Draft GH-DEC-2026-023 remains proposed and awaits +the founder's ruling. The response imposes these requirements now: + +- C1: PDP allow is eligibility only. MUST read the approval live and compare + approval id and native digest with the memo pin; never obtain approval fact + from an allow. +- C2: view_hash MUST NEVER enter PDP context. The binding digest MUST come from + the engine's live response and MUST NOT be recomputed here. +- C3: every intake-created approval MUST have human_control true; no class may + override it. PDP MUST deny false. Principal identity stays authentication-derived. +- C4: removing a submitter from runtime data MUST refuse the next pose without + a policy image rebuild, including attempts to replay previously accepted data. +- C5: submitter/class additions MUST be reviewed commits by someone other than + the submitter being admitted. Local allow-list refusal grants no PDP permission. +- C6: state-hub submitter proves authenticated forwarding only. drafted_by MUST + remain visibly asserted; loopback hub access does not authenticate the drafter. + +Findings F1–F3 remain open: PEP-asserted admission facts require independently +verified signed claims/receipts at close-down; ungated posing can flood human +attention; hub notifications can impersonate a review request. Intake now +requires a configured positive per-submitter daily budget. At close-down the +budget is mandatory, the authenticated overview is authoritative, notifications +carry ids, and terminal exact-record memos lose the compatibility branch. +R2/R5 re-arming includes authenticated drafting identity and independent grant +review. The unsigned PDP attribution gap remains explicitly declared. + +The offline service and its durable integration worker are implemented; shipped +registries are empty and transports are injected. Runtime composition and live +acceptance remain blocked on the owner requirements in +`docs/intake-dependency-requirements.md`. These requirements do not arm a live +control or grant custody/deployment approval. diff --git a/informed_decision/intake_approval.py b/informed_decision/intake_approval.py new file mode 100644 index 0000000..cfe9bfb --- /dev/null +++ b/informed_decision/intake_approval.py @@ -0,0 +1,34 @@ +"""Create-only approval adapter; token custody/exchange belongs to workload setup.""" +from datetime import datetime, timedelta, timezone + +from .http_transport import JSONTransport, fixed_origin +from .intake import canonical +from .intake_service import IntakeError + + +class IntakeApprovalClient: + def __init__(self, origin, token_provider, *, transport=None, allow_internal_http=False): + self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http) + self.token_provider = token_provider + self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http) + + def __call__(self, request): + if (set(request) != {'binding', 'human_control', 'required_count'} + or request['human_control'] is not True or type(request['required_count']) is not int + or request['required_count'] != 1 or request['binding'].get('actor') != 'informed-decision'): + raise IntakeError(400, 'invalid_approval_intent') + try: + token = self.token_provider() + if (not isinstance(token, str) or not token or len(token) > 16384 + or not token.isascii() or any(c.isspace() for c in token)): + raise ValueError() + now = datetime.now(timezone.utc) + body = {**request, 'pdp_path': False, 'validity': { + 'not_before': now.isoformat(), 'expires_at': (now + timedelta(hours=24)).isoformat()}} + status, result = self.transport.request('POST', self.origin + '/v1/approvals', + headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}, body=canonical(body)) + if status != 201: + raise ValueError() + return result + except Exception: + raise IntakeError(503, 'approval_create_unresolved') from None diff --git a/informed_decision/intake_delivery.py b/informed_decision/intake_delivery.py new file mode 100644 index 0000000..14b63dc --- /dev/null +++ b/informed_decision/intake_delivery.py @@ -0,0 +1,68 @@ +"""Leased local integration outbox. Owner composition supplies bounded transports. + +Unknown adapters leave rows pending; hub resolutions remain pending while the +write-back flag is off. Dispatch failure never rolls back a human disposition. +Audit/notification receivers must deduplicate using the stable delivery id. +""" +import json +import time +import uuid + +from .hub_writeback import Answer, enabled, write_back + + +class IntakeDeliveryWorker: + def __init__(self, store, *, audit=None, notify=None, hub_transport=None, + hub_base_url='', env=None, clock=time.time): + self.store, self.audit, self.notify = store, audit, notify + self.hub_transport, self.hub_base_url = hub_transport, hub_base_url + self.env, self.clock = env, clock + + def run_once(self, limit=10): + if type(limit) is not int or not 1 <= limit <= 100: + raise ValueError('delivery limit must be 1..100') + kinds = [] + if self.audit: + kinds.append('memo-posed') + if self.notify: + kinds.append('notification') + if self.hub_transport and enabled(self.env): + kinds.append('hub-resolution') + if not kinds: + return 0 + delivered = 0 + for _ in range(limit): + now, lease = self.clock(), str(uuid.uuid4()) + with self.store._transaction() as db: + placeholders = ','.join('?' for kind in kinds) + row = db.execute(f"SELECT * FROM intake_deliveries WHERE kind IN ({placeholders}) " + "AND state IN ('pending','in_flight') AND next_attempt<=? " + "AND (lease_until IS NULL OR lease_until<=?) ORDER BY rowid LIMIT 1", (*kinds, now, now)).fetchone() + if row is None: + break + db.execute("UPDATE intake_deliveries SET state='in_flight',lease=?,lease_until=?,attempts=attempts+1 WHERE id=?", + (lease, now + 30, row['id'])) + state = 'pending' + try: + payload = json.loads(row['payload']) + if row['kind'] == 'memo-posed': + self.audit.deliver(row['id'], row['payload']) + state = 'delivered' + elif row['kind'] == 'notification': + # notify(id, metadata) returns only after a deduplicated receiver receipt. + if self.notify(row['id'], payload) is True: + state = 'delivered' + else: + result = write_back(Answer(**payload['answer']), payload['serves'], + base_url=self.hub_base_url, transport=self.hub_transport, env=self.env) + if result.status in ('resolved', 'already_resolved'): + state = 'delivered' + elif result.status == 'conflict': + state = 'blocked' + except Exception: + pass # Never persist upstream bodies, tokens, or exception text. + with self.store._transaction() as db: + db.execute("UPDATE intake_deliveries SET state=?,lease=NULL,lease_until=NULL,next_attempt=? " + "WHERE id=? AND lease=?", (state, now + min(3600, 2 ** min(row['attempts'] + 1, 11)), row['id'], lease)) + delivered += state == 'delivered' + return delivered diff --git a/informed_decision/intake_service.py b/informed_decision/intake_service.py new file mode 100644 index 0000000..e9c6647 --- /dev/null +++ b/informed_decision/intake_service.py @@ -0,0 +1,218 @@ +"""Separate workload-only WSGI intake. No browser session or binding route. + +The creator and TokenReview transports are injected by the workload composition. +An uncertain create is never automatically repeated: its durable reservation +requires receipt recovery until approval-engine supports idempotent creation. +""" +from dataclasses import replace +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import uuid + +from .intake import IntakeRefused, SubmitterGrant, canonical, idempotency_outcome, validate_submission +from .memo import Principal +from .records import dumps + +AUDIENCE = "informed-decision-intake" +MAX_BODY = 1024 * 1024 + + +class IntakeError(Exception): + def __init__(self, status, code): + self.status, self.code = status, code + super().__init__(code) + + +class TokenReview: + """call(body) performs an authenticated Kubernetes TokenReview using pod credentials. + + The adapter must verify the API server's CA and suppress tokens and responses + in its logs. TokenReview is performed afresh on every pose, including replay. + """ + def __init__(self, call): + self.call = call + + def authenticate(self, token): + try: + result = self.call({"apiVersion": "authentication.k8s.io/v1", "kind": "TokenReview", + "spec": {"token": token, "audiences": [AUDIENCE]}}) + status = result["status"] + user = status["user"] + username = user["username"] + if (status.get("authenticated") is not True or status.get("error") + or AUDIENCE not in status.get("audiences", []) + or not user.get("uid") or not isinstance(username, str) + or not re.fullmatch(r"system:serviceaccount:[a-z0-9-]+:[a-z0-9-]+", username)): + raise ValueError() + return username + except Exception: + raise IntakeError(401, "workload_authentication_failed") from None + + +def configuration(submitters_path, groups_path): + """Reload reviewed deployment data on every request so removal revokes immediately. + + Empty shipped registries deliberately admit no workload or human recipient. + Entries identify actual service accounts; drafted_by is only an assertion. + """ + try: + submitters = json.loads(Path(submitters_path).read_text()) + groups = json.loads(Path(groups_path).read_text()) + if not isinstance(submitters, dict) or not isinstance(groups, dict): + raise ValueError() + for username, row in submitters.items(): + if (not re.fullmatch(r"system:serviceaccount:[a-z0-9-]+:[a-z0-9-]+", username) + or set(row) != {"submitter_id", "memo_classes", "review_groups", "daily_limit"} + or not isinstance(row["submitter_id"], str) + or not row["submitter_id"] or type(row["daily_limit"]) is not int + or row["daily_limit"] < 1): + raise ValueError() + for field in ("memo_classes", "review_groups"): + if not isinstance(row[field], list) or any(not isinstance(x, str) for x in row[field]): + raise ValueError() + if len({r["submitter_id"] for r in submitters.values()}) != len(submitters): + raise ValueError() + for row in groups.values(): + if (set(row) != {"subject", "display_name"} or not all(isinstance(x, str) and x for x in row.values())): + raise ValueError() + return submitters, groups + except Exception: + raise IntakeError(503, "intake_configuration_unavailable") from None + + +class IntakeService: + def __init__(self, store, authenticator, creator, *, submitters_path, groups_path): + self.store, self.authenticator, self.creator = store, authenticator, creator + self.submitters_path, self.groups_path = submitters_path, groups_path + + def pose(self, token, envelope): + username = self.authenticator.authenticate(token) + submitters, groups = configuration(self.submitters_path, self.groups_path) + row = submitters.get(username) + if row is None: + raise IntakeError(403, "submitter_unknown") + grant = SubmitterGrant(row["submitter_id"], frozenset(row["memo_classes"]), frozenset(row["review_groups"])) + submission = validate_submission(envelope, submitter_id=grant.submitter_id, grant=grant) + recipient = groups.get(submission.review_group) + if recipient is None: + raise IntakeError(503, "review_group_unconfigured") + metadata = {"memo_class": submission.memo_class, "review_group": submission.review_group, + "drafted_by": submission.drafted_by, "serves": submission.serves} + recovered = None + # Reserve before any network side effect. SQLite serializes concurrent poses. + with self.store._transaction() as db: + previous = db.execute("SELECT * FROM intake_ledger WHERE submitter=? AND key=?", + (submission.submitter_id, submission.idempotency_key)).fetchone() + if previous: + idempotency_outcome(previous["digest"], submission.submission_digest) + if previous["state"] == "ready": + return 200, json.loads(previous["receipt"]) + if previous["state"] == "created": + recovered = json.loads(previous["receipt"]) + else: + raise IntakeError(409, "approval_create_unresolved") + if not previous and (db.execute("SELECT 1 FROM memos WHERE id=?", (submission.memo.id,)).fetchone() + or db.execute("SELECT 1 FROM intake_ledger WHERE memo_id=?", (submission.memo.id,)).fetchone()): + raise IntakeError(409, "memo_id_taken") + # Budget counts attempts as well as completed poses; retries do not spend twice. + day = datetime.now(timezone.utc).date().isoformat() + count = db.execute("SELECT COUNT(*) FROM intake_ledger WHERE submitter=? AND json_extract(metadata,'$.day')=?", + (submission.submitter_id, day)).fetchone()[0] + if not previous and count >= row["daily_limit"]: + raise IntakeError(429, "submitter_budget_exhausted") + metadata["day"] = day + if not previous: + db.execute("INSERT INTO intake_ledger VALUES (?,?,?,?,?,?,NULL)", + (submission.submitter_id, submission.idempotency_key, submission.submission_digest, + submission.memo.id, "creating", dumps(metadata))) + if recovered is None: + try: + approval = self.creator({"binding": submission.approval_binding, + "human_control": True, "required_count": 1}) + binding = approval["binding"] + approval_id, digest = approval["id"], binding["digest"] + if (not re.fullmatch(r"[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}", approval_id) + or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest) + or binding.get("human_control") is not True + or any(binding.get(k) != v for k, v in submission.approval_binding.items()) + or approval.get("status") != "requested" or approval.get("entries")): + raise ValueError() + except Exception: + raise IntakeError(503, "approval_create_unresolved") from None + # Preserve the receipt before local load: recovery can locate this engine object. + receipt = {"memo_id": submission.memo.id, "approval_id": approval_id, "approval_binding_digest": digest} + with self.store._transaction() as db: + db.execute("UPDATE intake_ledger SET state='created',receipt=? WHERE submitter=? AND key=?", + (dumps(receipt), submission.submitter_id, submission.idempotency_key)) + else: + receipt = recovered + approval_id, digest = receipt["approval_id"], receipt["approval_binding_digest"] + memo = replace(submission.memo, approval_id=approval_id, approval_binding_digest=digest, + binding=replace(submission.memo.binding, principal=Principal( + recipient["subject"], "person", recipient["display_name"]))) + with self.store._transaction() as db: + current = db.execute("SELECT state,receipt FROM intake_ledger WHERE submitter=? AND key=?", + (submission.submitter_id, submission.idempotency_key)).fetchone() + if current["state"] == "ready": + return 200, json.loads(current["receipt"]) + for item in envelope["packet"]: + data = submission.packet[item["item_id"]] + document_digest = "sha256:" + hashlib.sha256(data).hexdigest() + existing = db.execute("SELECT * FROM documents WHERE digest=?", (document_digest,)).fetchone() + if existing and (existing["media_type"] != item["media_type"] or existing["content"] != data): + raise IntakeError(409, "document_conflict") + db.execute("INSERT OR IGNORE INTO documents VALUES (?,?,?)", (document_digest, item["media_type"], data)) + db.execute("INSERT INTO memos VALUES (?,?,?)", (memo.id, memo.version, dumps(memo))) + event_id = str(uuid.uuid4()) + event = {"id": event_id, "type": "informed-decision.memo.posed", + "source": "informed-decision", "tenant": "tenant:platform", + "subject": memo.id, "correlation_id": approval_id, + "occurred_at": datetime.now(timezone.utc).isoformat(), + "data": {**receipt, "submitter": submission.submitter_id, + "memo_class": submission.memo_class, + "submission_digest": submission.submission_digest}} + notification = {"memo_id": memo.id, "question": memo.question, + "memo_class": submission.memo_class, "submitter": submission.submitter_id} + for kind, payload in (("memo-posed", event), ("notification", notification)): + db.execute("INSERT INTO intake_deliveries(id,kind,payload) VALUES (?,?,?)", + (event_id if kind == "memo-posed" else str(uuid.uuid4()), kind, dumps(payload))) + db.execute("UPDATE intake_ledger SET state='ready' WHERE submitter=? AND key=?", + (submission.submitter_id, submission.idempotency_key)) + return 201, receipt + + def __call__(self, environ, start_response): + try: + if environ.get("PATH_INFO") != "/v1/memos": + raise IntakeError(404, "not_found") + if environ.get("REQUEST_METHOD") != "POST": + raise IntakeError(405, "method_not_allowed") + authorization = environ.get("HTTP_AUTHORIZATION", "") + if not authorization.startswith("Bearer ") or not authorization[7:] or len(authorization) > 16384: + raise IntakeError(401, "workload_token_required") + if environ.get("CONTENT_TYPE", "").split(";")[0] != "application/json": + raise IntakeError(415, "json_required") + length = int(environ.get("CONTENT_LENGTH") or 0) + if length < 1 or length > MAX_BODY: + raise IntakeError(413, "submission_too_large") + raw = environ["wsgi.input"].read(length) + if len(raw) != length: + raise IntakeError(400, "invalid_json") + status, body = self.pose(authorization[7:], json.loads(raw)) + except IntakeError as exc: + status, body = exc.status, {"error": exc.code} + except IntakeRefused as exc: + status = 409 if exc.reason == "idempotency_conflict" else 400 + body = {"error": exc.reason} # No client-controlled field paths in logs/responses. + except (ValueError, TypeError, KeyError, UnicodeError): + status, body = 400, {"error": "invalid_submission"} + except Exception: + status, body = 503, {"error": "intake_unavailable"} + from http import HTTPStatus + raw = canonical(body) + start_response(f"{status} {HTTPStatus(status).phrase}", + [("Content-Type", "application/json"), ("Content-Length", str(len(raw))), + ("Cache-Control", "no-store")]) + return [raw] diff --git a/informed_decision/store.py b/informed_decision/store.py index b3ec6aa..b62e0fd 100644 --- a/informed_decision/store.py +++ b/informed_decision/store.py @@ -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 = 2 +SCHEMA_VERSION = 3 _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,7 +99,7 @@ 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 not in (1, SCHEMA_VERSION): + elif version not in (1, 2, SCHEMA_VERSION): raise StoreError("unsupported evidence schema version") if version in (0, 1): db.executescript("""BEGIN IMMEDIATE; @@ -113,6 +113,19 @@ class Store: BEGIN SELECT RAISE(ABORT,'immutable evidence'); END; PRAGMA user_version=2; COMMIT;""") + if version in (0, 1, 2): + db.executescript("""BEGIN IMMEDIATE; + CREATE TABLE intake_ledger ( + submitter TEXT NOT NULL, key TEXT NOT NULL, digest TEXT NOT NULL, + memo_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL, + metadata TEXT NOT NULL, receipt TEXT, + PRIMARY KEY(submitter,key)); + CREATE TABLE intake_deliveries ( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, + next_attempt REAL NOT NULL DEFAULT 0, lease TEXT, lease_until REAL); + PRAGMA user_version=3; COMMIT;""") + @contextmanager def _connection(self): db = sqlite3.connect(self.path.as_uri() + "?mode=rw", uri=True, timeout=5, isolation_level=None) @@ -320,6 +333,18 @@ class Store: db.execute("INSERT INTO dispositions VALUES (?,?,?,?,?)", (d.id, operation_id, request, p.id, dumps(d))) if verb is Verb.ACCEPT: db.execute("INSERT INTO submissions(disposition_id,approval_id,subject) VALUES (?,?,?)", (d.id, memo.approval_id, actor.sub)) + intake = db.execute("SELECT metadata FROM intake_ledger WHERE memo_id=? AND state='ready'", (memo.id,)).fetchone() + if intake and verb in (Verb.ACCEPT, Verb.DECLINE): + from .hub_writeback import Answer, build_resolution + serves = json.loads(intake["metadata"]).get("serves") + if serves and serves.get("resolves") == "on-answer": + answer = Answer(memo.id, memo.version, verb.value, d.id, d.at, + memo.binding.principal.display_name, memo.approval_id, + memo.approval_binding_digest, p.view_hash, actor.kind.value) + payload = {"answer": answer.__dict__, "serves": serves, + "resolution": build_resolution(answer)} + db.execute("INSERT INTO intake_deliveries(id,kind,payload) VALUES (?,?,?)", + (d.id, "hub-resolution", dumps(payload))) c = commit_disposition(d, custody="informed-decision:dispositions:" + d.id) self._event(db, c, {"disposition": json.loads(dumps(d)), "presentation": json.loads(dumps(p)), "policy_observation_id": policy_id}, diff --git a/tests/test_intake_service.py b/tests/test_intake_service.py new file mode 100644 index 0000000..4aaea34 --- /dev/null +++ b/tests/test_intake_service.py @@ -0,0 +1,201 @@ +import io +import json + +import pytest + +from informed_decision.intake_service import IntakeError, IntakeService, TokenReview, AUDIENCE +from informed_decision.store import Store +from test_intake import envelope + +USERNAME = 'system:serviceaccount:state-hub:state-hub' +APPROVAL = '12345678-1234-1234-1234-123456789abc' + + +def token_review(body): + assert body['spec'] == {'token': 'projected-token', 'audiences': [AUDIENCE]} + return {'status': {'authenticated': True, 'audiences': [AUDIENCE], + 'user': {'username': USERNAME, 'uid': 'workload-uid'}}} + + +@pytest.fixture +def setup(tmp_path): + private = tmp_path / 'private' + private.mkdir(mode=0o700) + store = Store(private / 'review.sqlite') + submitters, groups = tmp_path / 'submitters.json', tmp_path / 'groups.json' + submitters.write_text(json.dumps({USERNAME: {'submitter_id': 'state-hub', + 'memo_classes': ['credential-lane'], 'review_groups': ['net-kingdom-admins'], 'daily_limit': 2}})) + groups.write_text(json.dumps({'net-kingdom-admins': {'subject': 'keycape-human', 'display_name': 'Reviewer'}})) + calls = [] + def create(body): + calls.append(body) + assert body['human_control'] is True and body['required_count'] == 1 + return {'id': APPROVAL, 'status': 'requested', 'binding': { + **body['binding'], 'human_control': True, 'digest': 'sha256:' + 'a' * 64}} + service = IntakeService(store, TokenReview(token_review), create, + submitters_path=submitters, groups_path=groups) + return service, store, calls, submitters + + +def request(service, body, token='projected-token', path='/v1/memos'): + raw = json.dumps(body).encode() + environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'POST', 'CONTENT_TYPE': 'application/json', + 'CONTENT_LENGTH': str(len(raw)), 'HTTP_AUTHORIZATION': 'Bearer ' + token, + 'wsgi.input': io.BytesIO(raw)} + response = [] + result = b''.join(service(environ, lambda status, headers: response.append(status))) + return int(response[0].split()[0]), json.loads(result) + + +def test_pose_replay_and_restart(setup): + service, store, calls, _ = setup + status, receipt = request(service, envelope()) + assert status == 201 + assert receipt['approval_id'] == APPROVAL + memo = store.memo(receipt['memo_id']) + assert memo.binding.principal.id == 'keycape-human' + assert memo.sealed is False + assert store.memos_for('keycape-human') == [memo] + service.store = Store(store.path) + assert request(service, envelope()) == (200, receipt) + assert len(calls) == 1 + with store._connection() as db: + assert db.execute('SELECT COUNT(*) FROM documents').fetchone()[0] == 1 + assert db.execute('SELECT COUNT(*) FROM dispositions').fetchone()[0] == 0 + + +def test_revocation_refuses_even_replay(setup): + service, _, calls, config = setup + assert request(service, envelope())[0] == 201 + config.write_text('{}') + assert request(service, envelope()) == (403, {'error': 'submitter_unknown'}) + assert len(calls) == 1 + + +def test_conflicts_precede_create(setup): + service, _, calls, _ = setup + assert request(service, envelope())[0] == 201 + changed = envelope() + changed['memo']['brief'] += ' Changed' + assert request(service, changed) == (409, {'error': 'idempotency_conflict'}) + changed = envelope() + changed['idempotency_key'] = 'different-key' + assert request(service, changed) == (409, {'error': 'memo_id_taken'}) + assert len(calls) == 1 + + +def test_uncertain_create_never_reposts_or_loads(setup): + service, store, calls, _ = setup + def lost(body): + calls.append(body) + raise OSError('sensitive upstream body') + service.creator = lost + assert request(service, envelope()) == (503, {'error': 'approval_create_unresolved'}) + service.store = Store(store.path) + assert request(service, envelope()) == (409, {'error': 'approval_create_unresolved'}) + assert len(calls) == 1 + with store._connection() as db: + assert db.execute('SELECT COUNT(*) FROM memos').fetchone()[0] == 0 + assert db.execute('SELECT COUNT(*) FROM documents').fetchone()[0] == 0 + + +def test_created_receipt_recovers_without_create(setup): + service, store, calls, _ = setup + # Fail the load transaction, after the receipt has been persisted. + with store._connection() as db: + db.execute("CREATE TRIGGER fail_load BEFORE INSERT ON memos BEGIN SELECT RAISE(ABORT,'failure'); END") + assert request(service, envelope())[0] == 503 + with store._connection() as db: + assert db.execute('SELECT COUNT(*) FROM documents').fetchone()[0] == 0 + assert db.execute('SELECT state FROM intake_ledger').fetchone()[0] == 'created' + db.execute('DROP TRIGGER fail_load') + assert request(service, envelope())[0] == 201 + assert len(calls) == 1 + + +def test_budget_and_no_browser_routes(setup): + service, _, calls, config = setup + data = json.loads(config.read_text()) + data[USERNAME]['daily_limit'] = 1 + config.write_text(json.dumps(data)) + assert request(service, envelope())[0] == 201 + another = envelope() + another['memo']['id'] = 'another-memo' + another['idempotency_key'] = 'another-key' + assert request(service, another) == (429, {'error': 'submitter_budget_exhausted'}) + assert request(service, envelope(), path='/review')[0] == 404 + assert len(calls) == 1 + + +@pytest.mark.parametrize('status', [ + {'authenticated': False}, + {'authenticated': True, 'audiences': ['wrong'], 'user': {'username': USERNAME, 'uid': 'x'}}, + {'authenticated': True, 'audiences': [AUDIENCE], 'user': {'username': 'human', 'uid': 'x'}}, + {'authenticated': True, 'audiences': [AUDIENCE], 'user': {'username': USERNAME}}, +]) +def test_token_review_fails_closed(status): + with pytest.raises(IntakeError) as error: + TokenReview(lambda body: {'status': status}).authenticate('hidden-token') + assert error.value.code == 'workload_authentication_failed' + + +def test_notifications_retry_and_disabled_writeback_stays_pending(setup): + from informed_decision.intake_delivery import IntakeDeliveryWorker + service, store, _, _ = setup + assert request(service, envelope())[0] == 201 + seen = [] + now = [100.0] + def notify(delivery_id, payload): + seen.append((delivery_id, payload)) + return len(seen) > 1 + worker = IntakeDeliveryWorker(store, notify=notify, clock=lambda: now[0]) + assert worker.run_once() == 0 + now[0] += 10 + assert worker.run_once() == 1 + assert seen[0] == seen[1] + assert 'brief' not in seen[0][1] and 'packet' not in seen[0][1] + with store._connection() as db: + assert db.execute("SELECT state FROM intake_deliveries WHERE kind='memo-posed'").fetchone()[0] == 'pending' + + +def test_terminal_disposition_queues_writeback_atomically(setup): + from informed_decision.disposition import Actor, ActorKind, Verb + from informed_decision.provenance import Claim, Route + from informed_decision.intake_delivery import IntakeDeliveryWorker + service, store, _, _ = setup + env = envelope() + env['serves']['resolves'] = 'on-answer' + status, receipt = request(service, env) + assert status == 201 + p = store.present(receipt['memo_id'], principal_sub='keycape-human', + tenant=Claim('tenant:platform', Route.REGISTRATION), + principal_type=Claim('human', Route.AUTHENTICATION)) + actor = Actor('keycape-human', ActorKind.PERSON) + store.acknowledge(p.id, actor, store.memo(receipt['memo_id']).required_ack_ids) + d = store.record_disposition(p.id, actor, Verb.DECLINE, operation_id='decline-once', reasons=('wrong_scope',)) + store.record_disposition(p.id, actor, Verb.DECLINE, operation_id='decline-once', reasons=('wrong_scope',)) + calls = [] + def transport(method, url, payload): + calls.append(payload) + return 200, {} + worker = IntakeDeliveryWorker(store, hub_transport=transport, hub_base_url='http://hub', env={}) + assert worker.run_once() == 0 and calls == [] + worker.env = {'INFD_HUB_WRITEBACK': '1'} + assert worker.run_once() == 1 + assert d.id in calls[0]['rationale'] + assert worker.run_once() == 0 + + +def test_create_adapter_has_only_create_route(): + from informed_decision.intake_approval import IntakeApprovalClient + calls = [] + class Transport: + def request(self, method, url, *, headers, body): + calls.append((method, url, json.loads(body))) + assert headers['Authorization'] == 'Bearer workload-token' + return 201, {'id': APPROVAL} + client = IntakeApprovalClient('https://engine.example', lambda: 'workload-token', transport=Transport()) + assert client({'binding': {'actor': 'informed-decision'}, 'human_control': True, 'required_count': 1}) == {'id': APPROVAL} + assert calls[0][0:2] == ('POST', 'https://engine.example/v1/approvals') + assert calls[0][2]['pdp_path'] is False + assert 'validity' in calls[0][2] diff --git a/tests/test_review_runtime.py b/tests/test_review_runtime.py index 02e3b5c..cbcace7 100644 --- a/tests/test_review_runtime.py +++ b/tests/test_review_runtime.py @@ -12,11 +12,16 @@ from test_durable_store import storage,present from test_durable_component import receiver -def test_schema_one_upgrade_preserves_existing_evidence(storage): +@pytest.mark.parametrize("version", [1, 2]) +def test_schema_upgrade_preserves_existing_evidence(storage, version): 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') + db.execute('DROP TABLE intake_deliveries') + db.execute('DROP TABLE intake_ledger') + if version == 1: + db.execute('DROP TABLE policy_observations') + db.execute(f'PRAGMA user_version={version}') reopened=Store(store.path) assert reopened.retrieve_presentation(p.id)[0]==memo and reopened.evidence()==before assert reopened.policy_observations()==[] diff --git a/workplans/INFD-WP-0005-frictionless-decision-intake.md b/workplans/INFD-WP-0005-frictionless-decision-intake.md index 96176ef..1d0d0f5 100644 --- a/workplans/INFD-WP-0005-frictionless-decision-intake.md +++ b/workplans/INFD-WP-0005-frictionless-decision-intake.md @@ -4,7 +4,7 @@ type: workplan title: "Frictionless decision intake — agents pose memos, only answering is human" domain: infotech repo: informed-decision -status: active +status: blocked flavor: implementation owner: claude topic_slug: netkingdom @@ -23,11 +23,11 @@ state_hub_workstream_id: "c9407d2e-fd51-5afe-be4c-24483d1b2dbc" Design: `docs/specs/FrictionlessDecisionIntake.md`. -Lifecycle review (2026-10-03): active implementation. T01–T03 have shipped -design and offline implementation, and T04 is actionable without live access. -The remaining owner responses, credential custody, doctrine review, and rollout -approvals remain explicit task gates; this status correction grants none of -those approvals. T07 still depends on T04. +Lifecycle review (2026-10-03): blocked after completing the local intake +service, durable ledger/outbox and doctrine-response incorporation. The remaining +runtime composition, custody, policy, receiver contracts and rollout require +owner deliveries or founder approval. Requirements and acceptance evidence are +in `docs/intake-dependency-requirements.md`; no live change was made. Today a drafted memo reaches the review page only after an attended requester login (A3), a hand-edited exact-record admission package with CI build and pin @@ -49,7 +49,7 @@ Owner messages sent 2026-10-03 (hub message ids): | gate-house | `6ab42744-b651-4136-9794-c0dc092509b7` | doctrine check: no human act required at posing time | | the-custodian | `35a87432-b0d5-4301-9fb9-d6a99bac4e0c` | close-down checklist rows R1–R8 | -## Tasks +## Intake design ```task id: INFD-WP-0005-T01 @@ -63,6 +63,8 @@ identity, validation, idempotency, approval creation without a human login, attribute admission, load/notify, invariants, write-back, build-mode relaxations, migration. +## Offline validation + ```task id: INFD-WP-0005-T02 status: done @@ -75,6 +77,8 @@ Offline intake validation: `informed_decision/intake.py` and (closed envelope, unsigned draft, one question, bundled acts, packet digests, secret-marker scan without echo, submitter/class/group grants, idempotency). +## Hub write-back adapter + ```task id: INFD-WP-0005-T03 status: done @@ -86,9 +90,11 @@ Hub write-back adapter `informed_decision/hub_writeback.py` behind `INFD_HUB_WRITEBACK=1` (default off), with tests in `tests/test_hub_writeback.py`. Not wired into the disposition path yet (T07). +## Authenticated intake service and ledger + ```task id: INFD-WP-0005-T04 -status: todo +status: done priority: high state_hub_task_id: "2fe63827-25cb-5a2d-88a7-35414f5718ae" ``` @@ -99,6 +105,8 @@ the projected SA token (audience `informed-decision-intake`), written in one transaction, principal resolved from `deploy/review-groups.json`. Offline-testable with a fake TokenReview and fake approval client. +## Requester custody and runtime composition + ```task id: INFD-WP-0005-T05 status: wait @@ -115,6 +123,8 @@ Intake creates the approval itself with `informed-decision-sitting-requester` `binding.actor = informed-decision`), pins approval id and engine digest on the memo, and records the receipt. Replaces OPERATOR step A3. +## Attribute admission consumer + ```task id: INFD-WP-0005-T06 status: wait @@ -130,9 +140,13 @@ Consumer side of attribute admission: carry `review_group`, `memo_class`, update `docs/flex-auth-review-contract.md`; keep exact-record memos working. Replaces OPERATOR step A4. +## Notification, audit and write-back delivery + ```task id: INFD-WP-0005-T07 -status: todo +status: wait +blocked_on: message-from:audit-core +blocking_reason: "Local queued notification/posed events and disposition write-back implemented; audit-core event registration and State Hub deduplicated notification contracts required for runtime wiring (docs/intake-dependency-requirements.md)" priority: medium depends_on: [INFD-WP-0005-T04] state_hub_task_id: "22d361cc-4bf2-59d4-9b97-728d42b1aeee" @@ -144,6 +158,8 @@ Notification and evidence: hub message to the founder on each posed memo wire `hub_writeback.write_back` into the terminal-disposition path with a local outbox. Replaces OPERATOR step A5. +## State Hub forwarding acceptance + ```task id: INFD-WP-0005-T08 status: wait @@ -156,17 +172,19 @@ state_hub_task_id: "ce2b36b4-ef82-53b7-aee7-e0063855370b" Register state-hub as a submitter and run an end-to-end pose from a hub decision through to the founder's review page in a dev stack. +## Doctrine response + ```task id: INFD-WP-0005-T09 -status: wait +status: done priority: medium -blocked_on: message-from:gate-house -blocking_reason: "Doctrine check that no human act is required at posing time (msg 6ab42744)" state_hub_task_id: "403b0a7a-26c8-5893-b7ab-84579877d540" ``` Record gate-house's answer in the spec; adjust §5 if doctrine requires it. +## Approved live rollout + ```task id: INFD-WP-0005-T10 status: wait @@ -192,3 +210,19 @@ class grant was found; the real draft refuses submitter_unknown without a grant. The alternative authenticated intake remains T04–T10. Gate House's T09 reply 4a290496 is described with its proposed-record conditions, without changing authority or task states. No submission, provisioning or human answer occurred. + +## Implementation evidence — 2026-10-03 + +T04: `intake_service.py`, `intake_approval.py`, Store schema 3 and empty reviewed +registries. Offline tests cover authentication/audience refusal, runtime +revocation, replay/restart, collisions, daily budget, atomic rollback, uncertain +create refusal and receipt-based load recovery. No deployed listener is enabled. + +T05 local create-only transport is implemented; workload token exchange and +custody remain external. T07 local integration jobs are durable and terminal +human dispositions enqueue resolution atomically; the worker preserves stable +ids and leaves unconfigured adapters and disabled write-back pending. Receiver +contracts and scheduled runtime composition remain blocked. + +T09: reply 4a290496 incorporated into the spec, with proposed-decision status +preserved. No founder ruling or deployment permission is inferred.