Complete intake delivery wiring and narrow remaining owner blockers
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029c-c003-7283-acc9-930b434e6cae
This commit is contained in:
parent
49f8eb4c4b
commit
ff59199c3b
13 changed files with 452 additions and 60 deletions
15
SCOPE.md
15
SCOPE.md
|
|
@ -234,10 +234,9 @@ Stated here because a scope file that only lists capabilities overstates them.
|
|||
(MFA backdated on reuse; a downstream fetch missing split-horizon headers)
|
||||
are fixed and deployed. The original ask — Authelia `prompt=login`/`max_age`
|
||||
support, so a step-up can be requested rather than merely checked after the
|
||||
fact, plus an actionable freshness error in the browser instead of the flat
|
||||
"Review unavailable" — remains open. Authelia 4.38 rejected that parameter
|
||||
once already (`cbea539`); retrying it is gated on the Authelia upgrade this
|
||||
repository does not own.
|
||||
fact, is complete in INFD-WP-0006, with live fresh-login/p01 acceptance.
|
||||
An actionable freshness error in the browser instead of the flat
|
||||
"Review unavailable" remains open in the existing intake record.
|
||||
- **`INFD-IN-0007`** — see Layer. No deadline.
|
||||
- **A reusable account/session-status component**, requested of `user-engine`
|
||||
as `USER-IN-0002` on 2026-09-27: identity, MFA-freshness, logout, to replace
|
||||
|
|
@ -267,7 +266,11 @@ 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
|
||||
revocation and write-back. T07 also supplies opt-in scheduled delivery, matching
|
||||
hub message receipts and stable write keys, and immutable posed audit evidence
|
||||
with receipts, readiness, heartbeat and reconciliation. Actual Audit Core
|
||||
component tests exercise lost-reply deduplication and counts. This is local
|
||||
implementation, not a deployed intake listener. Attribute contracts, forwarding,
|
||||
workload custody, private listener composition and live acceptance are tracked
|
||||
as blocked tasks in INFD-WP-0005, with acceptance requirements in
|
||||
`docs/intake-dependency-requirements.md`.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
| 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 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md |
|
||||
| task | INFD-WP-0005-T07 | done | — | 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 | done | — | workplans/INFD-WP-0005-frictionless-decision-intake.md |
|
||||
| task | INFD-WP-0005-T10 | wait | — | workplans/INFD-WP-0005-frictionless-decision-intake.md |
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ 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.
|
||||
`IntakeDeliveryWorker` and the optional scheduled runtime dispatch audit,
|
||||
notification and hub transports with leases, stable ids, bounded batches and a
|
||||
disabled-by-default write-back. Posed events use the ordinary immutable audit
|
||||
outbox, receipts, per-class reconciliation and readiness checks.
|
||||
No listener is composed into the deployed runtime yet; shipped registries are
|
||||
empty, so no caller is admitted. No live deployment occurred.
|
||||
|
||||
|
|
@ -75,31 +77,50 @@ condition on offline tests or initial dev rollout.
|
|||
|
||||
Existing request: `23b670ac-809e-49bf-9233-e0b62cf0a039`.
|
||||
|
||||
Register `informed-decision.memo.posed` for source informed-decision and tenant
|
||||
Completed locally by AUDIT-WP-0011 (`audit_core/ingestion.py` and sender registry).
|
||||
The registered contract accepts `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.
|
||||
submission digest. No packet, brief, or secret content. T07 now connects posed
|
||||
jobs to AuditCoreSink, preserving the original event id on recovery and duplicate
|
||||
receipts. The new class participates in readiness, reconciliation and optional
|
||||
86400-second heartbeat. Actual receiver tests cover acceptance, lost replies and
|
||||
duplicate receipts. Sender custody and deployed receiver/version acceptance
|
||||
remain part of T10; owner-local registration is not deployment evidence.
|
||||
|
||||
## 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;
|
||||
stable idempotency key, durable retry and receipt retention. Owner commit
|
||||
`49eeddb` provides a dev-only forwarding fixture and retained notification
|
||||
receipts. Its `_bounded_tree` accepts only int/float/null scalar leaves and
|
||||
rejects governed memo booleans (`requires_new_bind`, `required_ack`, `sealed`)
|
||||
with `invalid_type`; a cross-repo negative fixture reproduces this using p01.
|
||||
Accept boolean leaves without removing or coercing shared-schema binding fields,
|
||||
then provide pose/replay/conflict and browser/PDP acceptance evidence. The
|
||||
fixture is unscheduled and does not choose/admit an account. Identify the actual
|
||||
SA and supply runtime forwarding to review into the submitter registry.
|
||||
Forwarding proves the hub identity; drafted_by remains visibly asserted.
|
||||
The dedicated endpoint `POST /informed-decision/notifications` accepts only
|
||||
id/question/class/submitter using our stable delivery id as Idempotency-Key.
|
||||
Its receipt and founder message commit in one transaction; the receipt is
|
||||
retained independently of generic write-key TTL. `HubDelivery` checks the
|
||||
delivery id, message UUID, all echoed metadata, accepted status and
|
||||
signed-in-overview reference; queued
|
||||
relay acknowledgements remain pending. 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
|
||||
other-memo conflicts block for review. T07 binds hub transports and schedules
|
||||
IntakeDeliveryWorker in the runtime with explicit `intake_delivery` configuration.
|
||||
Generic resolution idempotency retention defaults to 14 days. A resolved
|
||||
decision refuses a repeated resolve and can be correlated by read after a 409;
|
||||
unreadable conflicts remain retryable. Queued or mismatched success responses
|
||||
are not delivery receipts. Keep
|
||||
INFD_HUB_WRITEBACK unset until deployment approval and end-to-end evidence.
|
||||
|
||||
## gate-house and founder — T09, T10
|
||||
|
|
@ -114,13 +135,33 @@ 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.
|
||||
was completed in INFD-WP-ADHOC-2026-10-03-T02 (p01 accepted with native engine
|
||||
and audit receipts); it does not approve a new intake rollout.
|
||||
|
||||
## Opt-in delivery configuration
|
||||
|
||||
The private review runtime v1 file may include:
|
||||
|
||||
```json
|
||||
"intake_delivery": {
|
||||
"hub_origin": "http://state-hub.state-hub.svc.cluster.local:8000"
|
||||
}
|
||||
```
|
||||
|
||||
Omitting this field preserves the existing runtime: no intake deliveries or new
|
||||
posed heartbeat are scheduled. Configuration uses the existing audit custody;
|
||||
it neither enables a listener nor grants submitter access. `INFD_HUB_WRITEBACK`
|
||||
still defaults off. Terminal resolution failures retain the human's existing
|
||||
evidence and pending delivery; they never repeat the human act.
|
||||
|
||||
## 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.
|
||||
Full suite with `INFD_AUDIT_CORE_SOURCE=/home/worsch/audit-core` and
|
||||
`INFD_STATE_HUB_SOURCE=/home/worsch/state-hub`: 452 passed, 65 skipped
|
||||
(optional Approval Engine/Flex Auth component fixtures are not enabled).
|
||||
Checks include posed receiver acceptance/lost-reply replay, immutable local
|
||||
evidence/counts, readiness refusal, notification contract/receipt matching,
|
||||
resolution retry on unreadable conflicts, explicit runtime configuration and
|
||||
the State Hub forwarder's governed-boolean refusal. `git diff --check` passed.
|
||||
No new tasks/workplans or live changes were created. T07 is complete locally;
|
||||
T05/T06/T08/T10 remain waiting and INFD-WP-0005 remains blocked.
|
||||
|
|
|
|||
|
|
@ -256,9 +256,11 @@ intake fact from a signed intake receipt. Then the PEP carries only ids.
|
|||
(`deploy/review-groups.json`: review group → named recipient KeyCape `sub`),
|
||||
never from memo content. Draft principal `pending-human-session` stays in the
|
||||
unsigned record; the live bind still substitutes the authenticated subject.
|
||||
- Notification: a State Hub message to the founder's inbox (`to_agent`
|
||||
configured, default `bernd`) with memo id, question, class, submitter and the
|
||||
review link. The signed-in overview (INFD-WP-0003) lists it as pending.
|
||||
- Notification: the retained State Hub notification endpoint creates a message
|
||||
to the founder's inbox (`bernd`) with memo id, question, class and submitter.
|
||||
The owner contract prohibits URLs and names the signed-in overview as the
|
||||
authoritative review entry point, replacing the initially proposed review
|
||||
link. The overview (INFD-WP-0003) lists admissible memos as pending.
|
||||
No packet content, no highlights, no secrets in the notification. Email/push
|
||||
is a later channel owned elsewhere.
|
||||
- audit-core receives `informed-decision.memo.posed` (submitter, class,
|
||||
|
|
@ -369,7 +371,9 @@ 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
|
||||
registries are empty. Optional review-runtime delivery configuration now binds
|
||||
State Hub transports and schedules posed audit evidence, notification and
|
||||
disabled-by-default resolution. Private intake listener 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.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class EventClass(str, Enum):
|
|||
PRESENTATION = "informed-decision.presentation"
|
||||
DISPOSITION = "informed-decision.disposition"
|
||||
STANCE_APPLICATION = "informed-decision.stance_application"
|
||||
MEMO_POSED = "informed-decision.memo.posed"
|
||||
HEARTBEAT = "audit-core.heartbeat"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -90,9 +90,11 @@ def write_back(answer: Answer, serves: Mapping | None, *, base_url: str,
|
|||
if status == 409:
|
||||
# Already resolved: ours if the hub rationale names this memo.
|
||||
try:
|
||||
_, current = transport("GET", f"{base_url.rstrip('/')}/decisions/{decision_id}", {})
|
||||
read_status, current = transport("GET", f"{base_url.rstrip('/')}/decisions/{decision_id}", {})
|
||||
except OSError as exc:
|
||||
return WritebackResult("failed", decision_id, payload, type(exc).__name__)
|
||||
if read_status != 200 or not isinstance(current, dict) or not isinstance(current.get('rationale'), str):
|
||||
return WritebackResult("failed", decision_id, payload, "resolution_unreadable")
|
||||
if f"memo {answer.memo_id} " in (current.get("rationale") or ""):
|
||||
return WritebackResult("already_resolved", decision_id, payload)
|
||||
return WritebackResult("conflict", decision_id, payload, json.dumps(body)[:200])
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import time
|
|||
import uuid
|
||||
|
||||
from .hub_writeback import Answer, enabled, write_back
|
||||
from .audit import OutboxWorker
|
||||
|
||||
|
||||
class IntakeDeliveryWorker:
|
||||
|
|
@ -21,16 +22,18 @@ class IntakeDeliveryWorker:
|
|||
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 = []
|
||||
delivered = 0
|
||||
if self.audit:
|
||||
kinds.append('memo-posed')
|
||||
self.store.stage_intake_audit()
|
||||
OutboxWorker(self.store, self.audit).run_once(limit=limit)
|
||||
delivered += self.store.settle_intake_audit()
|
||||
kinds = []
|
||||
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
|
||||
return delivered
|
||||
for _ in range(limit):
|
||||
now, lease = self.clock(), str(uuid.uuid4())
|
||||
with self.store._transaction() as db:
|
||||
|
|
@ -45,10 +48,7 @@ class IntakeDeliveryWorker:
|
|||
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':
|
||||
if row['kind'] == 'notification':
|
||||
# notify(id, metadata) returns only after a deduplicated receiver receipt.
|
||||
if self.notify(row['id'], payload) is True:
|
||||
state = 'delivered'
|
||||
|
|
|
|||
57
informed_decision/intake_hub.py
Normal file
57
informed_decision/intake_hub.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Bounded delivery to State Hub's retained memo-notification receipts."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from .http_transport import JSONTransport, fixed_origin
|
||||
|
||||
|
||||
class HubDelivery:
|
||||
def __init__(self, origin, *, transport=None):
|
||||
self.origin = fixed_origin(origin, allow_internal_http=True)
|
||||
self.transport = transport or JSONTransport(allow_internal_http=True)
|
||||
|
||||
def _request(self, method, url, payload, key):
|
||||
body = json.dumps(payload, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
|
||||
return self.transport.request(method, url, body=body if method == 'POST' else None,
|
||||
headers={'Content-Type': 'application/json', 'Idempotency-Key': key,
|
||||
'X-StateHub-Source-Agent': 'informed-decision'})
|
||||
|
||||
def notify(self, delivery_id, metadata):
|
||||
uuid.UUID(delivery_id)
|
||||
if set(metadata) != {'memo_id', 'question', 'memo_class', 'submitter'}:
|
||||
raise ValueError('invalid notification metadata')
|
||||
if any(not isinstance(v, str) or not v or len(v) > 4096 for v in metadata.values()):
|
||||
raise ValueError('invalid notification metadata')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,255}', metadata['memo_id']):
|
||||
raise ValueError('invalid memo id')
|
||||
status, receipt = self._request('POST', self.origin + '/informed-decision/notifications',
|
||||
metadata, delivery_id)
|
||||
# A queued relay receipt is pending, not successful central delivery.
|
||||
if status not in (200, 201) or not isinstance(receipt, dict):
|
||||
return False
|
||||
try:
|
||||
uuid.UUID(receipt['message_id'])
|
||||
except (ValueError, TypeError, KeyError, AttributeError):
|
||||
return False
|
||||
return (receipt.get('delivery_id') == delivery_id and receipt.get('status') == 'accepted'
|
||||
and receipt.get('authoritative_review') == 'signed-in-overview'
|
||||
and all(receipt.get(k) == v for k, v in metadata.items()))
|
||||
|
||||
def resolution(self, method, url, payload):
|
||||
suffix = '/resolve' if method == 'POST' else ''
|
||||
pattern = r'/decisions/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}' + suffix
|
||||
if (method not in ('POST', 'GET') or not url.startswith(self.origin + '/decisions/')
|
||||
or not re.fullmatch(pattern, url[len(self.origin):])):
|
||||
raise ValueError('invalid hub resolution route')
|
||||
material = json.dumps([method, url, payload], sort_keys=True, separators=(',', ':')).encode()
|
||||
status, receipt = self._request(method, url, payload,
|
||||
'infd-resolution:' + hashlib.sha256(material).hexdigest())
|
||||
if 200 <= status < 300:
|
||||
ident = url[len(self.origin + '/decisions/'):].split('/')[0]
|
||||
if (status != 200 or not isinstance(receipt, dict) or receipt.get('id') != ident
|
||||
or (method == 'POST' and (receipt.get('status') != 'resolved'
|
||||
or any(receipt.get(k) != payload[k] for k in ('rationale', 'decided_by'))))):
|
||||
return 503, {} # Queued or mismatched response is not a resolution receipt.
|
||||
return status, receipt
|
||||
|
|
@ -36,8 +36,9 @@ def token_file(path):
|
|||
|
||||
class AuditPump:
|
||||
"""One process, bounded batches, 30s ticks; never drops a failed record."""
|
||||
def __init__(self, store, sink, *, clock=time.time):
|
||||
def __init__(self, store, sink, *, clock=time.time, intake_worker=None):
|
||||
self.store, self.worker, self.clock = store, OutboxWorker(store, sink), clock
|
||||
self.intake_worker = intake_worker
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._lock = threading.Lock()
|
||||
|
|
@ -50,8 +51,12 @@ class AuditPump:
|
|||
|
||||
def tick(self):
|
||||
try:
|
||||
self.store.queue_heartbeats()
|
||||
if self.intake_worker:
|
||||
self.intake_worker.run_once(limit=10)
|
||||
self.store.queue_heartbeats(include_posed=self.intake_worker is not None)
|
||||
result = self.worker.run_once(limit=10)
|
||||
if self.intake_worker:
|
||||
self.store.settle_intake_audit()
|
||||
now = self.clock()
|
||||
if now - self._last_reconciled >= 300:
|
||||
stamp = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat(timespec="microseconds")
|
||||
|
|
@ -71,6 +76,8 @@ class AuditPump:
|
|||
self._last_reconciled = now
|
||||
healthy = not result["retrying"] and not result["blocked"] and not any(
|
||||
row["state"] != "delivered" for row in self.store.outbox())
|
||||
if self.intake_worker and self.store.intake_audit_pending():
|
||||
healthy = False
|
||||
with self._lock:
|
||||
self._last_ok = now if healthy else None
|
||||
except Exception:
|
||||
|
|
@ -109,7 +116,7 @@ class Runtime:
|
|||
or info.st_size > 16384):
|
||||
raise ValueError("runtime configuration must be an owned private 0600 file")
|
||||
data = json.loads(path.read_text())
|
||||
if ((set(data) - {"ordinary_approval_ids"}) != {"schema", "evidence_db", "policy", "approval_origin", "audit"}
|
||||
if ((set(data) - {"ordinary_approval_ids", "intake_delivery"}) != {"schema", "evidence_db", "policy", "approval_origin", "audit"}
|
||||
or data["schema"] != "informed-decision.review-runtime.v1"
|
||||
or set(data["policy"]) != {"origin", "package", "version", "package_digest", "caller_token_file"}
|
||||
or set(data["audit"]) != {"origin", "sender_token_file"}):
|
||||
|
|
@ -125,7 +132,19 @@ class Runtime:
|
|||
allow_internal_http=True)
|
||||
sink = AuditCoreSink(data["audit"]["origin"], token_file(data["audit"]["sender_token_file"]),
|
||||
allow_internal_http=True)
|
||||
delivery = data.get('intake_delivery')
|
||||
hub = None
|
||||
if 'intake_delivery' in data:
|
||||
from .intake_hub import HubDelivery
|
||||
if not isinstance(delivery, dict) or set(delivery) != {'hub_origin'}:
|
||||
raise ValueError('invalid intake delivery configuration')
|
||||
hub = HubDelivery(delivery['hub_origin'])
|
||||
store = Store(data["evidence_db"])
|
||||
controller = ReviewController(store, client, lambda session: ApprovalHTTPClient(
|
||||
approval_origin, session, allow_internal_http=True, ordinary_approval_ids=tuple(ordinary)))
|
||||
return cls(controller, AuditPump(store, sink))
|
||||
worker = None
|
||||
if hub:
|
||||
from .intake_delivery import IntakeDeliveryWorker
|
||||
worker = IntakeDeliveryWorker(store, audit=sink, notify=hub.notify,
|
||||
hub_transport=hub.resolution, hub_base_url=hub.origin)
|
||||
return cls(controller, AuditPump(store, sink, intake_worker=worker))
|
||||
|
|
|
|||
|
|
@ -495,11 +495,56 @@ class Store:
|
|||
with self._connection() as db:
|
||||
return [dict(r) for r in db.execute("SELECT * FROM outbox ORDER BY rowid")]
|
||||
|
||||
def queue_heartbeats(self, now=None):
|
||||
def stage_intake_audit(self):
|
||||
"""Promote retained posed envelopes into the ordinary immutable audit outbox.
|
||||
|
||||
Same ids preserve receiver deduplication, including a previously sent
|
||||
integration job. Delivery receipts and reconciliation now use one path.
|
||||
"""
|
||||
with self._transaction() as db:
|
||||
for row in db.execute("SELECT i.id,i.payload FROM intake_deliveries i LEFT JOIN evidence e ON e.id=i.id "
|
||||
"WHERE i.kind='memo-posed' AND (e.id IS NULL OR e.envelope!=i.payload) "
|
||||
"ORDER BY i.rowid LIMIT 100").fetchall():
|
||||
event = json.loads(row['payload'])
|
||||
fields = {'id', 'type', 'source', 'subject', 'tenant', 'correlation_id', 'occurred_at', 'data'}
|
||||
data_fields = {'memo_id', 'approval_id', 'approval_binding_digest', 'submitter', 'memo_class', 'submission_digest'}
|
||||
if (set(event) != fields or not isinstance(event.get('data'), dict)
|
||||
or set(event['data']) != data_fields
|
||||
or any(not isinstance(v, str) or not v for v in event['data'].values())
|
||||
or event.get('id') != row['id'] or event.get('type') != EventClass.MEMO_POSED.value
|
||||
or event.get('source') != 'informed-decision' or event.get('tenant') != 'tenant:platform'):
|
||||
raise Conflict('invalid retained posed event')
|
||||
occurred = datetime.fromisoformat(event['occurred_at'])
|
||||
if occurred.tzinfo is None:
|
||||
raise Conflict('invalid retained posed time')
|
||||
stamp = occurred.astimezone(timezone.utc).isoformat(timespec='microseconds')
|
||||
existing = db.execute('SELECT envelope FROM evidence WHERE id=?', (row['id'],)).fetchone()
|
||||
if existing and existing[0] != row['payload']:
|
||||
raise Conflict('posed event id collision')
|
||||
db.execute('INSERT OR IGNORE INTO evidence VALUES (?,?,?,?,?)',
|
||||
(row['id'], EventClass.MEMO_POSED.value, stamp, row['payload'], row['payload']))
|
||||
db.execute('INSERT OR IGNORE INTO outbox(id) VALUES (?)', (row['id'],))
|
||||
|
||||
def settle_intake_audit(self):
|
||||
with self._transaction() as db:
|
||||
result = db.execute("UPDATE intake_deliveries SET state='delivered',lease=NULL,lease_until=NULL "
|
||||
"WHERE kind='memo-posed' AND state!='delivered' AND id IN "
|
||||
"(SELECT id FROM outbox WHERE state='delivered')")
|
||||
return result.rowcount
|
||||
|
||||
def intake_audit_pending(self):
|
||||
with self._connection() as db:
|
||||
return db.execute("SELECT 1 FROM intake_deliveries WHERE kind='memo-posed' "
|
||||
"AND state!='delivered' LIMIT 1").fetchone() is not None
|
||||
|
||||
def queue_heartbeats(self, now=None, *, include_posed=False):
|
||||
now = time.time() if now is None else now
|
||||
count = 0
|
||||
with self._transaction() as db:
|
||||
for event_class, interval in HEARTBEAT_CLASSES.items():
|
||||
classes = {**HEARTBEAT_CLASSES}
|
||||
if include_posed:
|
||||
classes[EventClass.MEMO_POSED.value] = 86400
|
||||
for event_class, interval in classes.items():
|
||||
if db.execute("SELECT 1 FROM outbox o JOIN evidence e ON e.id=o.id WHERE o.state!='delivered' "
|
||||
"AND (e.class=? OR (e.class=? AND json_extract(e.envelope,'$.data.class')=?)) LIMIT 1",
|
||||
(event_class, EventClass.HEARTBEAT.value, event_class)).fetchone():
|
||||
|
|
|
|||
|
|
@ -87,3 +87,10 @@ def test_bad_decision_id_refused():
|
|||
with pytest.raises(ValueError):
|
||||
write_back(answer(), {"hub_decision_id": "D1", "resolves": "on-answer"}, base_url=BASE,
|
||||
transport=Hub(), env=ON)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('read_status,body', [(503, {}), (404, {}), (200, {}), (200, None)])
|
||||
def test_unreadable_conflict_remains_retryable(read_status, body):
|
||||
hub = Hub((409, {}), (read_status, body))
|
||||
assert write_back(answer(), {'hub_decision_id': D1, 'resolves': 'on-answer'},
|
||||
base_url=BASE, transport=hub, env=ON).status == 'failed'
|
||||
|
|
|
|||
198
tests/test_intake_delivery_runtime.py
Normal file
198
tests/test_intake_delivery_runtime.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""Delivery contracts, recovery and opt-in runtime composition."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.audit import AuditDeliveryError, OutboxWorker
|
||||
from informed_decision.evidence import EventClass
|
||||
from informed_decision.intake_delivery import IntakeDeliveryWorker
|
||||
from informed_decision.intake_hub import HubDelivery
|
||||
from informed_decision.runtime import AuditPump, Runtime
|
||||
from informed_decision.store import Store
|
||||
from test_durable_component import receiver
|
||||
from test_intake import envelope
|
||||
from test_intake_service import setup, request
|
||||
from test_review_runtime import config
|
||||
|
||||
|
||||
class HubTransport:
|
||||
def __init__(self, status=201):
|
||||
self.status, self.calls = status, []
|
||||
|
||||
def request(self, method, url, *, headers, body):
|
||||
payload = json.loads(body) if body else None
|
||||
self.calls.append((method, url, headers, body))
|
||||
if url.endswith('/notifications'):
|
||||
receipt = {'message_id': str(uuid.uuid4()), 'delivery_id': headers['Idempotency-Key'],
|
||||
'status': 'accepted', 'authoritative_review': 'signed-in-overview', **payload}
|
||||
else:
|
||||
receipt = {'id': url.split('/decisions/')[1].split('/')[0],
|
||||
'status': 'resolved', **(payload or {})}
|
||||
return self.status, receipt
|
||||
|
||||
|
||||
def test_notification_replay_uses_identical_request_and_checks_receipt():
|
||||
transport = HubTransport()
|
||||
hub = HubDelivery('https://hub.test', transport=transport)
|
||||
ident = str(uuid.uuid4())
|
||||
metadata = {'memo_id': 'review-1', 'question': 'Proceed?', 'memo_class': 'credential-lane', 'submitter': 'state-hub'}
|
||||
assert hub.notify(ident, metadata)
|
||||
assert hub.notify(ident, metadata)
|
||||
assert transport.calls[0] == transport.calls[1]
|
||||
assert json.loads(transport.calls[0][3]) == metadata
|
||||
assert transport.calls[0][1].endswith('/informed-decision/notifications')
|
||||
assert transport.calls[0][2]['Idempotency-Key'] == ident
|
||||
transport.status = 202
|
||||
assert not hub.notify(ident, metadata) # Relay queue acknowledgement is not a receipt.
|
||||
with pytest.raises(ValueError):
|
||||
hub.notify(ident, {**metadata, 'packet': 'private'})
|
||||
transport.request = lambda *args, **kwargs: (201, {'id': str(uuid.uuid4())})
|
||||
assert not hub.notify(ident, metadata)
|
||||
|
||||
|
||||
def test_resolution_is_fixed_origin_and_idempotent():
|
||||
transport = HubTransport(status=200)
|
||||
hub = HubDelivery('https://hub.test', transport=transport)
|
||||
url = 'https://hub.test/decisions/' + str(uuid.uuid4())
|
||||
for _ in range(2):
|
||||
hub.resolution('POST', url + '/resolve', {'rationale': 'memo review-1', 'decided_by': 'human'})
|
||||
assert transport.calls[0] == transport.calls[1]
|
||||
hub.resolution('GET', url, {})
|
||||
transport.status = 202
|
||||
assert hub.resolution('POST', url + '/resolve', {'rationale': 'memo review-1', 'decided_by': 'human'}) == (503, {})
|
||||
for method, target in [('GET', url + '/resolve'), ('POST', url),
|
||||
('POST', url.replace('hub.test', 'other.test') + '/resolve'),
|
||||
('POST', 'https://hub.test/decisions/' + '-' * 36 + '/resolve')]:
|
||||
with pytest.raises(ValueError):
|
||||
hub.resolution(method, target, {})
|
||||
|
||||
|
||||
def test_runtime_delivery_is_opt_in_and_writeback_remains_off(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv('INFD_HUB_WRITEBACK', raising=False)
|
||||
path, data = config(tmp_path)
|
||||
assert Runtime.from_file(path).pump.intake_worker is None
|
||||
data['intake_delivery'] = {'hub_origin': 'https://hub.test'}
|
||||
path.write_text(json.dumps(data))
|
||||
worker = Runtime.from_file(path).pump.intake_worker
|
||||
assert worker is not None and worker.audit and worker.notify and worker.hub_transport
|
||||
from informed_decision.hub_writeback import enabled
|
||||
assert not enabled(worker.env)
|
||||
for invalid in [None, {}, {**data['intake_delivery'], 'allow_all': True},
|
||||
{'hub_origin': 'http://public.example.com'}]:
|
||||
data['intake_delivery'] = invalid
|
||||
path.write_text(json.dumps(data))
|
||||
with pytest.raises(ValueError):
|
||||
Runtime.from_file(path)
|
||||
|
||||
|
||||
def test_posed_evidence_survives_restart_and_is_immutable(setup):
|
||||
service, store, _, _ = setup
|
||||
assert request(service, envelope())[0] == 201
|
||||
store.stage_intake_audit()
|
||||
before = store.evidence()
|
||||
assert len(before) == 1 and before[0]['class'] == EventClass.MEMO_POSED.value
|
||||
Store(store.path).stage_intake_audit()
|
||||
assert store.evidence() == before and len(store.outbox()) == 1
|
||||
with store._connection() as db:
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute('DELETE FROM evidence')
|
||||
assert store.intake_audit_pending()
|
||||
|
||||
|
||||
def test_actual_posed_receiver_lost_reply_deduplicates_and_reconciles(setup, receiver):
|
||||
service, store, _, _ = setup
|
||||
assert request(service, envelope())[0] == 201
|
||||
sink, transport, _ = receiver
|
||||
store.stage_intake_audit()
|
||||
first = store.claim_delivery(now=10)
|
||||
transport.lose_reply = True
|
||||
with pytest.raises(AuditDeliveryError):
|
||||
sink.deliver(first[0], first[2])
|
||||
replay = Store(store.path).claim_delivery(now=41)
|
||||
assert replay[0] == first[0] and replay[2] == first[2]
|
||||
reference = sink.deliver(replay[0], replay[2])
|
||||
store.finish_delivery(replay[0], replay[1], reference=reference)
|
||||
assert store.settle_intake_audit() == 1 and not store.intake_audit_pending()
|
||||
report = OutboxWorker(store, sink).reconcile('2020-01-01T00:00:00Z', '2100-01-01T00:00:00Z')
|
||||
assert report['count_values_match']
|
||||
assert report['counts'][EventClass.MEMO_POSED.value] == {'source': 1, 'receiver': 1}
|
||||
assert store.outbox()[0]['receiver_reference'] == 'audit:' + first[0]
|
||||
|
||||
|
||||
def test_pump_includes_posed_evidence_and_retains_failed_notifications(setup, receiver):
|
||||
service, store, _, _ = setup
|
||||
assert request(service, envelope())[0] == 201
|
||||
sink, _, _ = receiver
|
||||
worker = IntakeDeliveryWorker(store, audit=sink, notify=lambda *args: False)
|
||||
pump = AuditPump(store, sink, intake_worker=worker)
|
||||
pump.tick()
|
||||
assert pump.ready() and not store.intake_audit_pending()
|
||||
with store._connection() as db:
|
||||
assert db.execute("SELECT state FROM intake_deliveries WHERE kind='notification'").fetchone()[0] == 'pending'
|
||||
snapshot = json.loads((store.path.parent / 'audit-reconciliation.json').read_text())
|
||||
assert snapshot['count_values_match'] and EventClass.MEMO_POSED.value in snapshot['counts']
|
||||
|
||||
|
||||
def test_failed_posed_delivery_blocks_readiness_and_no_empty_heartbeat(setup, receiver):
|
||||
service, store, _, _ = setup
|
||||
assert request(service, envelope())[0] == 201
|
||||
sink, _, _ = receiver
|
||||
sink.token_provider = lambda: 'invalid-synthetic-token'
|
||||
pump = AuditPump(store, sink, intake_worker=IntakeDeliveryWorker(store, audit=sink))
|
||||
pump.tick()
|
||||
assert not pump.ready() and store.intake_audit_pending()
|
||||
heartbeats = [json.loads(row['envelope']) for row in store.evidence()
|
||||
if row['class'] == EventClass.HEARTBEAT.value]
|
||||
assert all(row['data']['class'] != EventClass.MEMO_POSED.value for row in heartbeats)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub_contract():
|
||||
source = os.environ.get('INFD_STATE_HUB_SOURCE')
|
||||
if not source:
|
||||
pytest.skip('set INFD_STATE_HUB_SOURCE for actual hub contract checks')
|
||||
assert (Path(source) / 'api/services/memo_forwarding.py').is_file()
|
||||
sys.path.insert(0, source)
|
||||
from api.services import informed_decision_contract, memo_forwarding
|
||||
return informed_decision_contract, memo_forwarding
|
||||
|
||||
|
||||
def test_actual_hub_forwarder_refuses_governed_boolean_fields(setup, hub_contract, tmp_path):
|
||||
service, store, creates, _ = setup
|
||||
contract, forwarding = hub_contract
|
||||
body = envelope()
|
||||
body['idempotency_key'] = contract.stable_idempotency_key(body['serves']['hub_decision_id'], body['memo']['id'])
|
||||
calls = []
|
||||
def transport(method, url, payload, headers):
|
||||
assert method == 'POST' and url == 'https://intake.test/v1/memos'
|
||||
assert headers['Authorization'] == 'Bearer projected-token'
|
||||
calls.append(payload)
|
||||
return request(service, payload)
|
||||
fixture = forwarding.DevIntakeFixture(tmp_path / 'hub.sqlite', transport=transport,
|
||||
intake_base_url='https://intake.test', hub_base_url='https://hub.test',
|
||||
token_reader=lambda: 'projected-token')
|
||||
# Owner commit 49eeddb only permits int/float/null leaves, rejecting the
|
||||
# shared schema's actual bools. Do not strip or change those binding fields.
|
||||
with pytest.raises(contract.ContractError) as refused:
|
||||
fixture.submit(body)
|
||||
assert refused.value.code == 'invalid_type'
|
||||
assert not store.memos_for('keycape-human') and not creates and not calls
|
||||
assert b'projected-token' not in Path(fixture.path).read_bytes()
|
||||
|
||||
|
||||
def test_notification_body_matches_actual_hub_parser(hub_contract):
|
||||
contract, _ = hub_contract
|
||||
class Transport(HubTransport):
|
||||
def request(self, method, url, *, headers, body):
|
||||
parsed = contract.parse_notification(json.loads(body))
|
||||
assert parsed.as_body() == json.loads(body)
|
||||
assert contract.parse_delivery_id(headers['Idempotency-Key'])
|
||||
return super().request(method, url, headers=headers, body=body)
|
||||
hub = HubDelivery('https://hub.test', transport=Transport())
|
||||
assert hub.notify(str(uuid.uuid4()), {'memo_id': 'review-1', 'question': 'Proceed?',
|
||||
'memo_class': 'credential-lane', 'submitter': 'state-hub'})
|
||||
|
|
@ -24,8 +24,8 @@ state_hub_workstream_id: "c9407d2e-fd51-5afe-be4c-24483d1b2dbc"
|
|||
Design: `docs/specs/FrictionlessDecisionIntake.md`.
|
||||
|
||||
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
|
||||
service, durable ledger/outbox, scheduled delivery and doctrine-response incorporation. The remaining
|
||||
intake listener composition, custody, policy, forwarding and rollout require
|
||||
owner deliveries or founder approval. Requirements and acceptance evidence are
|
||||
in `docs/intake-dependency-requirements.md`; no live change was made.
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ state_hub_task_id: "b91af9d2-30ef-5c46-93f0-5faeba923b5d"
|
|||
|
||||
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).
|
||||
Wired into the terminal-disposition outbox and opt-in scheduled runtime (T07).
|
||||
|
||||
## Authenticated intake service and ledger
|
||||
|
||||
|
|
@ -144,19 +144,21 @@ Replaces OPERATOR step A4.
|
|||
|
||||
```task
|
||||
id: INFD-WP-0005-T07
|
||||
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)"
|
||||
status: done
|
||||
priority: medium
|
||||
depends_on: [INFD-WP-0005-T04]
|
||||
state_hub_task_id: "22d361cc-4bf2-59d4-9b97-728d42b1aeee"
|
||||
```
|
||||
|
||||
Notification and evidence: hub message to the founder on each posed memo
|
||||
(id, question, class, submitter, link; no content); emit
|
||||
(id, question, class, submitter, signed-in-overview reference; no content); emit
|
||||
`informed-decision.memo.posed` to audit-core once registered (msg `23b670ac`);
|
||||
wire `hub_writeback.write_back` into the terminal-disposition path with a local
|
||||
outbox. Replaces OPERATOR step A5.
|
||||
outbox. Replaces OPERATOR step A5. Implemented locally using the registered
|
||||
AUDIT-WP-0011 receiver contract and State Hub's retained notification receipts
|
||||
(the owner contract prohibits notification URLs).
|
||||
The optional runtime `intake_delivery` configuration schedules immutable posed
|
||||
evidence, notification and gated resolution delivery. Live acceptance remains T10.
|
||||
|
||||
## State Hub forwarding acceptance
|
||||
|
||||
|
|
@ -165,12 +167,18 @@ id: INFD-WP-0005-T08
|
|||
status: wait
|
||||
priority: medium
|
||||
blocked_on: message-from:state-hub
|
||||
blocking_reason: "State Hub as first registered submitter (forwarding decision memos with its SA token) is state-hub's change (msg e30dea31)"
|
||||
blocking_reason: "Owner dev forwarder 49eeddb refuses governed bool fields (invalid_type); state-hub must fix its contract, choose the actual SA and supply reviewed grant/runtime forwarding (msg e30dea31), alongside T05/T06"
|
||||
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.
|
||||
The owner's DevIntakeFixture now has a cross-repo negative fixture against the
|
||||
real p01 envelope: `_bounded_tree` refuses `requires_new_bind`, `required_ack`
|
||||
and `sealed` booleans. The owner must accept shared-schema boolean leaves;
|
||||
removing or coercing binding fields here is not a compatible workaround.
|
||||
Production submitter registration and browser/PDP acceptance also wait on
|
||||
T05/T06 and the owner's chosen SA/runtime forwarding.
|
||||
|
||||
## Doctrine response
|
||||
|
||||
|
|
@ -219,10 +227,17 @@ 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.
|
||||
custody remain external. T07 is complete locally: posed jobs enter the ordinary
|
||||
immutable audit outbox with receipt retention, per-class reconciliation and
|
||||
optional 24-hour heartbeat; notifications require matching hub receipts and
|
||||
stable idempotency keys. The scheduled runtime drains these and terminal human
|
||||
resolutions when explicitly configured; disabled write-back remains pending.
|
||||
Tests exercise the actual Audit Core receiver, lost-reply replay, counts,
|
||||
readiness refusal and failed notification retention. Nothing was deployed.
|
||||
|
||||
Loose-end audit: all other current and archived workplans have only done/cancel
|
||||
tasks. T05, T06, T08 and T10 remain waiting on the named external deliveries and
|
||||
live acceptance; this plan remains blocked. No new task or workplan was opened.
|
||||
|
||||
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