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:
parent
f0e9341650
commit
04f66f81b6
7 changed files with 285 additions and 5 deletions
|
|
@ -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`
|
||||
|
|
|
|||
6
SCOPE.md
6
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
|
||||
|
|
|
|||
47
docs/specs/LifecycleHTTPContract.md
Normal file
47
docs/specs/LifecycleHTTPContract.md
Normal 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.
|
||||
113
informed_decision/lifecycle_http.py
Normal file
113
informed_decision/lifecycle_http.py
Normal 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"]}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
92
tests/test_lifecycle_http.py
Normal file
92
tests/test_lifecycle_http.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue