Implement offline workload intake and record blocked owner dependencies
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029c-c003-7283-acc9-930b434e6cae
This commit is contained in:
parent
c1a75e85aa
commit
c1cf6cf54c
13 changed files with 790 additions and 25 deletions
16
SCOPE.md
16
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`.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
1
deploy/intake-submitters.json
Normal file
1
deploy/intake-submitters.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
1
deploy/review-groups.json
Normal file
1
deploy/review-groups.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
126
docs/intake-dependency-requirements.md
Normal file
126
docs/intake-dependency-requirements.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
34
informed_decision/intake_approval.py
Normal file
34
informed_decision/intake_approval.py
Normal file
|
|
@ -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
|
||||
68
informed_decision/intake_delivery.py
Normal file
68
informed_decision/intake_delivery.py
Normal file
|
|
@ -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
|
||||
218
informed_decision/intake_service.py
Normal file
218
informed_decision/intake_service.py
Normal file
|
|
@ -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]
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
201
tests/test_intake_service.py
Normal file
201
tests/test_intake_service.py
Normal file
|
|
@ -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]
|
||||
|
|
@ -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()==[]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue