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
This commit is contained in:
custodian-sync 2026-10-04 18:36:55 +02:00
parent f0e9341650
commit 04f66f81b6
7 changed files with 285 additions and 5 deletions

View file

@ -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`

View file

@ -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

View file

@ -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.

View file

@ -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"]}

View file

@ -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

View file

@ -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

View file

@ -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.