From 04f66f81b6db825631439c3cc272d1599eee70e7 Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Sun, 4 Oct 2026 18:36:55 +0200 Subject: [PATCH] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-10-04: - update .custodian-brief.md for informed-decision Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f --- .custodian-brief.md | 4 +- SCOPE.md | 6 +- docs/specs/LifecycleHTTPContract.md | 47 ++++++++ informed_decision/lifecycle_http.py | 113 ++++++++++++++++++ tests/test_lifecycle.py | 12 ++ tests/test_lifecycle_http.py | 92 ++++++++++++++ ...INFD-WP-0007-decision-lifecycle-service.md | 16 ++- 7 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 docs/specs/LifecycleHTTPContract.md create mode 100644 informed_decision/lifecycle_http.py create mode 100644 tests/test_lifecycle_http.py diff --git a/.custodian-brief.md b/.custodian-brief.md index ede1860..a9da5ad 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — informed-decision **Domain:** infotech -**Last synced:** 2026-10-04 15:40 UTC +**Last synced:** 2026-10-04 16:36 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -13,7 +13,7 @@ Progress: 1/7 done | workplan_id: `2b3e04ed-2137-584f-a6c2-f1abb29636fa` **Open tasks:** - ► Specify lifecycle contracts against existing owners `cde0c932` - ► Implement durable headless lifecycle foundation `2a206bc2` -- · Expose authenticated lifecycle API for agents and humans `65c760da` +- ► Expose authenticated lifecycle API for agents and humans `65c760da` - · Build the lifecycle workspace at decisions.coulomb.social `a6a7c9a8` - · Prove lifecycle without per-decision rollout and prepare live delivery `aa839bea` - · Approved live acceptance `25fa4743` diff --git a/SCOPE.md b/SCOPE.md index 4a66826..fd48250 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -17,8 +17,10 @@ registration, immutable revisions, attributed research sources/evaluations/ comments and event retrieval using the existing Memo and Store (schema 4). Its local event queue is durable. `LifecycleService` now wraps these operations with fresh exact-policy observations, expiry checks inside write transactions, -and a verified human-session adapter. HTTP exposure, workload identity composition -and external event delivery remain unimplemented. Unpublished lifecycle records +and a verified human-session adapter. An unmounted WSGI +`LifecycleAPI` now provides retrieval, history and annotation routes through that +service; production HTTP exposure, workload identity composition and external +event delivery remain unimplemented. Unpublished lifecycle records are excluded from the legacy approver overview, legacy import cannot bypass their revision history, and pending lifecycle events close runtime readiness. See `docs/specs/LifecycleFoundationContract.md` and diff --git a/docs/specs/LifecycleHTTPContract.md b/docs/specs/LifecycleHTTPContract.md new file mode 100644 index 0000000..d74df60 --- /dev/null +++ b/docs/specs/LifecycleHTTPContract.md @@ -0,0 +1,47 @@ +# Lifecycle HTTP adapter — initial subset + +2026-10-04; INFD-WP-0007-T04, in progress. `LifecycleAPI` is an unmounted WSGI +adapter over `LifecycleService`. It starts no listener and adds no route to the +existing browser app or public ingress. + +| Method and path | Input | Result | +| --- | --- | --- | +| `GET /v1/decisions/{id}` | Optional positive integer `version` | Existing Memo JSON for the current or specified revision | +| `GET /v1/decisions/{id}/events` | Optional `after` sequence cursor, `limit` 1–100 | `{"events": [...]}` ordered by sequence; use last returned sequence for next page | +| `POST /v1/decisions/{id}/comments` | `{"version": 1, "body": {"text": "...", "locator": "optional"}}` | Durable event receipt | +| `POST /v1/decisions/{id}/sources` | `version` and source `body` per LifecycleFoundationContract | Durable event receipt | +| `POST /v1/decisions/{id}/evaluations` | `version` and evaluation `body` per LifecycleFoundationContract | Durable event receipt | + +Ids are 1–256 ASCII letters/digits or `.`, `_`, `:`, `-`. POST requires exact +`Content-Type: application/json`, a declared byte length up to 256 KiB and a +nonempty `Idempotency-Key` up to 256 characters. Mutation receipts are HTTP 200 +for both first submission and replay, with `event_id`, `memo_id`, `version`. +Unknown fields, duplicate JSON/query keys, non-finite JSON values, boolean +revision numbers and truncated bodies are rejected. Version-one registration, +revision, search, publication and disposition routes are not yet exposed. + +Authentication runs before routing or reading a mutation body. The composition +must explicitly supply a bearer-token verifier returning an authenticated +`Caller`. There is no default or testing fallback. Cookies are not credentials +for this adapter; its future human browser integration must provide a separately +reviewed session/CSRF boundary or admitted bearer identity. Neither the intake SA +audience nor the browser's approval-engine access token is implicitly admitted. + +Every handled command invokes the shared policy-consuming service. Clients cannot +supply tenant, actor or policy evidence in the body. Reads and retries receive +fresh checks. Denied existing/missing record reads return the same policy-denied +response. No new authorization verdict or approval mutation is produced here. + +Responses are JSON with `Cache-Control: no-store` and `nosniff`. Errors are bounded +`{"error": "code"}` envelopes: 400 invalid request, 401 missing/failed/expired +identity, 403 policy denial, 404 unavailable record/route, 405 unsupported method, +409 conflict, 413 oversized body, 415 unsupported media type, 503 unavailable +service or unusable policy evidence. Raw exception text and credentials are never +returned. Upstream raw diagnostics are not forwarded. + +T04 remains in progress: complete closed memo mutation schemas, canonical URLs, +search/publication contracts, admitted authenticators and runtime wiring. Current +tests use the real service/repository/PolicyClient with synthetic authenticator +and PDP transport; they establish adapter behavior, not live identity admission. +Runtime must not expose this adapter before lifecycle policy and independent +event-delivery contracts are accepted and readiness can be satisfied. diff --git a/informed_decision/lifecycle_http.py b/informed_decision/lifecycle_http.py new file mode 100644 index 0000000..5681ec9 --- /dev/null +++ b/informed_decision/lifecycle_http.py @@ -0,0 +1,113 @@ +"""Version-one JSON adapter; only explicitly composed bearer authentication. + +No listener or default authenticator. Runtime must supply an admitted identity +adapter returning Caller; browser cookies are deliberately not credentials here. +""" + +from http import HTTPStatus +import json +import re +from urllib.parse import parse_qs + +from .lifecycle_service import Caller, LifecycleError +from .records import dumps +from .store import Conflict, EvidenceUnavailable + +MAX_BODY = 256 * 1024 +ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations))?") + + +def _object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate field") + result[key] = value + return result + + +class LifecycleAPI: + def __init__(self, service, authenticate): + self.service, self.authenticate = service, authenticate + + def __call__(self, environ, start_response): + try: + status, result = self.dispatch(environ) + except LifecycleError as exc: + status, result = exc.status, {"error": exc.code} + except Conflict: + status, result = 409, {"error": "conflict"} + except EvidenceUnavailable: + status, result = 404, {"error": "record_unavailable"} + except (ValueError, TypeError, KeyError, UnicodeError, RecursionError): + status, result = 400, {"error": "invalid_request"} + except Exception: + status, result = 503, {"error": "service_unavailable"} + body = dumps(result).encode() + start_response(f"{status} {HTTPStatus(status).phrase}", [ + ("Content-Type", "application/json; charset=utf-8"), + ("Content-Length", str(len(body))), ("Cache-Control", "no-store"), + ("X-Content-Type-Options", "nosniff"), + *(([("WWW-Authenticate", "Bearer")] if status == 401 else []))]) + return [body] + + def dispatch(self, environ): + authorization = environ.get("HTTP_AUTHORIZATION", "") + if (not authorization.startswith("Bearer ") or not 8 <= len(authorization) <= 32775 + or any(ord(c) < 33 or ord(c) > 126 for c in authorization[7:])): + raise LifecycleError(401, "authentication_required") + try: + caller = self.authenticate(authorization[7:]) + if not isinstance(caller, Caller): + raise ValueError() + except Exception: + raise LifecycleError(401, "authentication_failed") from None + self.service._caller(caller) + match = ROUTE.fullmatch(environ.get("PATH_INFO", "")) + if not match: + raise LifecycleError(404, "route_not_found") + memo_id, resource = match.groups() + method = environ.get("REQUEST_METHOD", "GET") + query = environ.get("QUERY_STRING", "") + if len(query) > 2048: + raise ValueError() + params = parse_qs(query, keep_blank_values=True, strict_parsing=True, max_num_fields=4) + if any(len(value) != 1 for value in params.values()): + raise ValueError() + if method == "GET" and resource is None: + if set(params) - {"version"}: + raise ValueError() + version = int(params["version"][0]) if "version" in params else None + if version is not None and version < 1: + raise ValueError() + return 200, self.service.retrieve(caller, memo_id, version) + if method == "GET" and resource == "events": + if set(params) - {"after", "limit"}: + raise ValueError() + return 200, {"events": self.service.events(caller, memo_id, + after=int(params.get("after", ["0"])[0]), limit=int(params.get("limit", ["100"])[0]))} + kinds = {"comments": "comment", "sources": "source", "evaluations": "evaluation"} + if method != "POST" or resource not in kinds: + raise LifecycleError(405, "method_not_allowed") + if params: + raise ValueError() + if environ.get("CONTENT_TYPE", "").lower() != "application/json": + raise LifecycleError(415, "json_required") + length = int(environ.get("CONTENT_LENGTH", "")) + if length < 1: + raise ValueError() + if length > MAX_BODY: + raise LifecycleError(413, "body_too_large") + raw = environ["wsgi.input"].read(length) + if len(raw) != length: + raise ValueError() + data = json.loads(raw, object_pairs_hook=_object, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError())) + if not isinstance(data, dict) or set(data) != {"version", "body"}: + raise ValueError() + key = environ.get("HTTP_IDEMPOTENCY_KEY", "") + if not isinstance(key, str) or not key.strip() or len(key) > 256: + raise ValueError() + event_id = self.service.annotate(caller, memo_id, data["version"], kind=kinds[resource], + body=data["body"], key=key) + return 200, {"event_id": event_id, "memo_id": memo_id, "version": data["version"]} diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 9ea6804..addcf5d 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1,5 +1,6 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import replace +import json import sqlite3 import subprocess import sys @@ -212,3 +213,14 @@ def test_pending_lifecycle_events_close_existing_runtime_readiness_immediately(w repo.register(AGENT, memo, key="register") assert not pump.ready() assert not AuditPump(Store(repo.store.path), None).ready() + + +def test_admin_reports_lifecycle_backlog_without_private_content(workspace, monkeypatch, capsys): + from informed_decision.container import admin + repo, memo = workspace + repo.register(AGENT, memo, key="register") + monkeypatch.setattr(sys, "argv", ["admin", "inspect", "--db", str(repo.store.path)]) + admin() + output = capsys.readouterr().out + assert json.loads(output)["lifecycle"] == {"records": 1, "events": 1, "pending": 1} + assert memo.question not in output and AGENT.actor.sub not in output diff --git a/tests/test_lifecycle_http.py b/tests/test_lifecycle_http.py new file mode 100644 index 0000000..555e72f --- /dev/null +++ b/tests/test_lifecycle_http.py @@ -0,0 +1,92 @@ +import io +import json + +import pytest + +from informed_decision.lifecycle_http import LifecycleAPI, MAX_BODY +from test_lifecycle import workspace +from test_lifecycle_service import caller, service + + +def call(api, path, *, method="GET", body=None, token="fixture", query="", raw=None, **extra): + raw = raw if raw is not None else json.dumps(body).encode() if body is not None else b"" + environ = {"PATH_INFO": path, "REQUEST_METHOD": method, "QUERY_STRING": query, + "HTTP_AUTHORIZATION": "Bearer " + token if token is not None else "", + "CONTENT_TYPE": "application/json", "CONTENT_LENGTH": str(len(raw)), + "HTTP_IDEMPOTENCY_KEY": "operation-1", "wsgi.input": io.BytesIO(raw), **extra} + response = {} + payload = b"".join(api(environ, lambda status, headers: response.update(status=int(status.split()[0]), headers=dict(headers)))) + return response["status"], json.loads(payload), response["headers"] + + +@pytest.fixture +def api(workspace): + app, memo = service(workspace) + app.register(caller(), memo, key="initial") + def authenticate(token): + if token != "fixture": + raise ValueError("secret diagnostic must not escape") + return caller() + return LifecycleAPI(app, authenticate) + + +def test_authenticated_retrieval_comment_replay_and_history(api): + status, memo, headers = call(api, "/v1/decisions/memo-1") + assert status == 200 and memo["version"] == 1 + assert headers["Cache-Control"] == "no-store" + command = {"version": 1, "body": {"text": "Compare alternatives"}} + status, receipt, _ = call(api, "/v1/decisions/memo-1/comments", method="POST", body=command) + assert status == 200 + assert call(api, "/v1/decisions/memo-1/comments", method="POST", body=command)[1] == receipt + status, result, _ = call(api, "/v1/decisions/memo-1/events", query="limit=1") + cursor = result["events"][0]["sequence"] + status, result, _ = call(api, "/v1/decisions/memo-1/events", query=f"after={cursor}") + assert result["events"][0]["id"] == receipt["event_id"] + assert len(result["events"]) == 1 + + +def test_authentication_runs_before_body_read_and_ignores_cookie(api): + class Unreadable: + def read(self, size): + pytest.fail("unauthenticated body read") + status, result, headers = call(api, "/v1/decisions/memo-1/comments", method="POST", + token=None, HTTP_COOKIE="session=forged", **{"wsgi.input": Unreadable()}) + assert status == 401 and headers["WWW-Authenticate"] == "Bearer" + assert call(api, "/v1/decisions/memo-1", token="bad")[1] == {"error": "authentication_failed"} + + +def test_policy_denial_does_not_disclose_content(api): + api.service.policy.transport.change = lambda data: data.update(effect="deny") + for path in ["/v1/decisions/memo-1", "/v1/decisions/absent", "/v1/decisions/memo-1/events"]: + status, result, _ = call(api, path) + assert status == 403 and result == {"error": "policy_denied"} + + +@pytest.mark.parametrize("query", ["version=1&version=2", "version=0", "tenant=other", "version=bad"]) +def test_closed_query_contract(api, query): + assert call(api, "/v1/decisions/memo-1", query=query)[0] == 400 + + +@pytest.mark.parametrize("raw", [ + b'{"version":1,"version":2,"body":{"text":"duplicate"}}', + b'{"version":NaN,"body":{"text":"bad"}}', + b'{"version":1,"body":{"text":"hello"},"actor":"forged"}', + b'{"version":true,"body":{"text":"bad"}}', b'[]', +]) +def test_malformed_commands_refused_without_event(api, raw): + assert call(api, "/v1/decisions/memo-1/comments", method="POST", raw=raw)[0] == 400 + assert api.service.repository.store.lifecycle_counts()["events"] == 1 + + +def test_body_limits_and_idempotency_conflict(api): + path = "/v1/decisions/memo-1/comments" + assert call(api, path, method="POST", CONTENT_LENGTH=str(MAX_BODY + 1))[0] == 413 + assert call(api, path, method="POST", CONTENT_TYPE="text/plain")[0] == 415 + assert call(api, path, method="POST", raw=b'{}', CONTENT_LENGTH="20")[0] == 400 + assert call(api, path, method="POST", body={"version": 1, "body": {"text": "first"}})[0] == 200 + assert call(api, path, method="POST", body={"version": 1, "body": {"text": "second"}})[0] == 409 + + +def test_unimplemented_binding_and_creation_routes_are_unavailable(api): + assert call(api, "/v1/decisions", method="POST", body={})[0] == 404 + assert call(api, "/v1/decisions/memo-1/accept", method="POST", body={})[0] == 404 diff --git a/workplans/INFD-WP-0007-decision-lifecycle-service.md b/workplans/INFD-WP-0007-decision-lifecycle-service.md index 29c6be0..8e8f007 100644 --- a/workplans/INFD-WP-0007-decision-lifecycle-service.md +++ b/workplans/INFD-WP-0007-decision-lifecycle-service.md @@ -75,7 +75,7 @@ and absence of UI imports. No mirrored approval state. ```task id: INFD-WP-0007-T04 -status: todo +status: progress priority: high depends_on: [INFD-WP-0007-T03] state_hub_task_id: "65c760da-de7e-5e85-9817-6f3e2b46c8f9" @@ -174,3 +174,17 @@ Cross-repo suite with Approval Engine, Audit Core and State Hub source fixtures: 571 passed, 5 skipped. This is source integration evidence, not live cluster verification or new lifecycle policy admission. External runtime/identity/policy/ receiver contracts remain on T02/T03 and WP-0005. + +## HTTP adapter continuation — 2026-10-04 + +Started T04 against the implemented application contract while broader T02/T03 +work continues. `LifecycleAPI` provides bearer-authenticated adapter seams for +retrieval, event pagination and comments/sources/evaluations, calling the same +application service. Closed/bounded JSON, idempotency headers, sanitized errors +and cookie refusal are tested. No default authenticator or listener is supplied; +public ingress and browser app remain unchanged. Contract and remaining route/ +identity/runtime work: `docs/specs/LifecycleHTTPContract.md`. + +HTTP continuation validation: cross-repo suite 585 passed, 5 skipped; a subsequent +focused lifecycle/HTTP run also covers content-free admin inspection. +`git diff --check` passed. Runtime remains unmounted pending the named contracts.