Implement lifecycle workspace publication and audit delivery

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-04 20:17:24 +02:00
parent ee472acc87
commit 5fec7de4cb
21 changed files with 1022 additions and 78 deletions

View file

@ -4,29 +4,34 @@
> direction belongs in `INTENT.md`; the current stage belongs in `GOAL.md`;
> current work and gates belong in `workplans/`.
## Lifecycle expansion — 2026-10-04 (first local persistence slice)
## Lifecycle expansion — 2026-10-04 (source implementation)
The product now targets the full decision lifecycle for agents and humans with
an API foundation and a UI at `decisions.coulomb.social`. See
`docs/specs/DecisionLifecycleService.md` and `INFD-WP-0007`. Existing memo,
presentation, disposition, storage, review and intake modules are reusable
building blocks; they are not a complete headless lifecycle API. Research,
evaluation, collaborative authoring and audience-scoped publication still need
service/UI implementation. The first internal `LifecycleRepository` now supports
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. An unmounted WSGI
`LifecycleAPI` now provides registration, optimistic revisions with atomic packet
uploads, 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
`docs/specs/LifecycleServiceContract.md`. Frictionless intake has local implementation
but its live delivery remains under `INFD-WP-0005`. The dated deployment snapshot
below is historical, not a fresh observation of production.
INFD-WP-0007 adds a headless service and an optional browser workspace. The shared
foundation supports registration with packet custody, immutable revisions,
attributed sources/evaluations/comments, explicit revision-specific publication,
authorized search, retrieval and history. Every operation consumes fresh Access
Engine policy observations; audience facts do not implement a local ACL.
The workspace reuses verified KeyCape sessions and CSRF/origin checks for draft
creation, research, discussion, publication, refinement, revision comparison and
packet downloads. Runtime configuration requires a separate lifecycle policy
package. The bearer API adapter remains unmounted pending workload identity and
listener admission. Lifecycle records remain outside the legacy approver overview;
the human binding handoff is not implemented.
Opt-in delivery commits lifecycle evidence through the existing Audit Core
outbox, with receipt-based settlement, retries and reconciliation. Current delivery
is platform-tenant-only. Pending evidence closes runtime readiness. The deployment
renderer supports separately pinned lifecycle policy and exact network proposals;
no live configuration was changed. Cross-repository validation: 623 passed,
5 skipped, including two agent-created decisions and human collaboration with
unchanged runtime configuration and actual Audit Core delivery. Identity and PDP
responses in that scenario are synthetic; this is not live acceptance evidence.
Contracts: `docs/specs/DecisionLifecycleService.md`, `LifecycleFoundationContract.md`,
`LifecycleServiceContract.md`, `LifecycleHTTPContract.md` and
`LifecycleWorkspaceDelivery.md` under `docs/specs/`. INFD-WP-0005 retains intake
runtime/admission work. The deployment snapshot below is historical.
## Status — 2026-09-27

View file

@ -77,19 +77,19 @@ legacy `save_memo` behavior. Existing stale-presentation guards see the same mem
versions. Approval-bound/sealed revision handling requires the later review
integration; this repository refuses those lifecycle revision commands.
Pending lifecycle events are durable local obligations. They are not emitted as
invented Audit Core event types or reported delivered. The existing review audit
queue remains unchanged. Before exposure, T02/T03 must settle receiver admission,
payload/privacy, delivery retries and readiness accounting for the new queue.
Runtime readiness now refuses while lifecycle events remain pending, and the
admin inspection command reports content-free lifecycle counts. No events are
marked delivered without the future admitted receiver adapter.
Pending lifecycle events are durable local obligations. The opt-in runtime audit
adapter stages immutable `informed-decision.lifecycle` commitments into the
existing transactional outbox and clears pending obligations only after a receiver
receipt. Content remains in local custody; the independent commitment contains
its digest and existence assertion. Readiness refuses while lifecycle delivery
is pending. See `LifecycleWorkspaceDelivery.md` for the platform tenant boundary,
retries, heartbeats and outstanding production admission.
## Remaining contracts
T02 remains in progress: production identity/policy composition, versioned HTTP schemas,
publication audience policy, incomplete drafts, non-cluster workload identity,
legacy record enrollment, source fetching/retention, independent evidence delivery
production publication audience policy, incomplete drafts, non-cluster workload identity,
legacy record enrollment, source fetching/retention, live independent evidence delivery
and non-approval binding integration. T03 is started with this persistence slice;
it is not complete. T04/T05 continue to own HTTP and UI exposure. Native runtime
intake and admission remain with `INFD-WP-0005`.

View file

@ -6,6 +6,8 @@ existing browser app or public ingress.
| Method and path | Input | Result |
| --- | --- | --- |
| `GET /v1/decisions` | Optional `q` (up to 200 characters), opaque `cursor` | Authorized current revision summaries and `next_cursor` |
| `POST /v1/decisions/{id}/publications` | `version`, `audience` with explicit `subjects` and `groups` lists | Durable publication event receipt |
| `POST /v1/decisions` | `memo`, optional `documents` | Register an unbound version-one memo and return event/record/revision/resource receipt |
| `POST /v1/decisions/{id}/revisions` | `memo`, `expected_version`, `summary`, optional `documents` | Compare-and-append successor revision; same receipt shape |
| `GET /v1/decisions/{id}` | Optional positive integer `version` | Existing Memo JSON for the current or specified revision |
@ -19,7 +21,7 @@ Ids are 1–256 ASCII letters/digits or `.`, `_`, `:`, `-`. POST requires exact
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. Search, publication and disposition routes are not yet exposed.
revision numbers and truncated bodies are rejected. Disposition routes are not exposed.
Authentication runs before routing or reading a mutation body. The composition
must explicitly supply a bearer-token verifier returning an authenticated
@ -40,8 +42,9 @@ identity, 403 policy denial, 404 unavailable record/route, 405 unsupported metho
service or unusable policy evidence. Raw exception text and credentials are never
returned. Upstream raw diagnostics are not forwarded.
T04 remains in progress: search/publication contracts, admitted authenticators,
canonical browser URLs and runtime wiring. Current
T04 remains in progress: admitted workload authenticators and API listener
composition. The browser workspace uses verified sessions through a separate
adapter (see LifecycleWorkspaceDelivery.md). 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
@ -64,8 +67,11 @@ Registration is `{ "memo": <Memo JSON>, "documents": [...] }`. Revision adds
`expected_version` and a nonempty `summary`; its memo id must match the path and
its version must be exactly expected_version + 1. Every mutation requires
Idempotency-Key. Register/revise receipts include a relative API resource link
`/v1/decisions/{id}?version={version}`. This does not claim that an unpublished
draft has a human review URL or that the API is publicly mounted.
`/v1/decisions/{id}?version={version}` and a `workspace_url` at
`https://decisions.coulomb.social/decisions/{id}?version={version}`. The latter
is an authorized authoring workspace, not a binding review link. The API remains
unmounted. Search cursors are caller/tenant/query scoped, expire after five
minutes and are invalidated on process restart. Every page rechecks policy.
Each document has exactly `digest`, `media_type`, `content_base64`. At most 100
documents are allowed; the total JSON request remains bounded at 256 KiB and

View file

@ -2,8 +2,8 @@
2026-10-04; `INFD-WP-0007-T02/T03`. Implemented in
`informed_decision/lifecycle_service.py`, above `LifecycleRepository`.
This is a headless Python application interface. No HTTP route or production
composition enables it yet.
This is a headless Python application interface. An optional runtime composition
enables the browser workspace; production admission is still outstanding.
## Caller and authority
@ -23,8 +23,8 @@ Non-cluster/workload composition remains T02/T04 work.
The service consumes `PolicyClient` observations. It neither evaluates policy
nor falls back to local permissions. New proposed action names are
`lifecycle.register`, `lifecycle.revise`, `lifecycle.source`,
`lifecycle.evaluation`, `lifecycle.comment`, `lifecycle.read`, and
`lifecycle.events`. These are not added to the existing review-action allowlist;
`lifecycle.evaluation`, `lifecycle.comment`, `lifecycle.read`,
`lifecycle.events`, `lifecycle.publish`, `lifecycle.search` and `lifecycle.packet`. These are not added to the existing review-action allowlist;
review and human disposition contracts remain unchanged.
The request carries the authenticated subject, tenant, resource `memo:<id>`,
@ -35,7 +35,17 @@ and context:
* `memo_version`: submitted/requested revision, or null for latest retrieval and
record-wide event retrieval;
* `command_digest`: SHA-256 of canonical command JSON, including mutation content,
expected revision and idempotency key, or the read/page parameters.
expected revision and idempotency key, or the read/page parameters;
* `record`: tenant-partitioned registration facts, original registering actor,
current revision and latest publication audience for the requested revision;
* `publication_audience`: explicit subjects/groups for a publication command.
Search uses resource `decisions`, type `decision-collection`, then independently
authorizes each candidate with `lifecycle.read`. A page examines at most 50
candidates; denied records are omitted, unavailable policy fails the request.
Opaque cursors are scoped to caller/tenant/query and expire after five minutes.
Packet access checks its own action and a revision-specific packet index; a
known content digest grants no retrieval capability.
The command digest correlates the requested operation; it is not `view_hash`,
an approval binding digest or an authorization verdict. No presentation evidence
@ -53,7 +63,8 @@ policy pins, response lifetime and the supported effect.
Mutation commands snapshot supplied content before the network call. Under the
repository's write transaction, a guard rechecks caller and decision expiry and
the persisted observation before even returning an idempotent replay. New events
the persisted observation and unchanged record/audience facts before even
returning an idempotent replay. New events
carry `body.policy_observation_id`; receipts replay their original event and
observation link while the fresh retry check remains in policy history. The
identity/policy guard does not perform network calls while holding the write lock.
@ -61,26 +72,27 @@ identity/policy guard does not perform network calls while holding the write loc
Read authorization precedes repository lookup. Denied requests therefore cannot
distinguish missing and existing records through lookup errors. The service
rechecks identity and decision expiry after retrieving content and before returning
it. Repository tenant partition enforcement also applies. Record-wide event
it, including unchanged record/audience facts. Repository tenant partition enforcement also applies. Record-wide event
access requires its own action; it does not inherit a memo-read grant.
The existing `decision_attributable: false` declaration remains in force. A
matched, recorded response is not proof of signed PDP attribution. New lifecycle
events still reside in the durable pending queue until an Audit Core contract
and delivery adapter are admitted.
events remain pending until the opt-in audit adapter obtains a receiver receipt.
See LifecycleWorkspaceDelivery.md for custody, reconciliation and admission.
## Error and integration contract
`LifecycleError` supplies bounded status/code pairs: 401 for expired identity,
403 for a policy deny, and 503 for unavailable or unusable policy evidence.
The repository retains `Conflict`, `EvidenceUnavailable` and validation errors;
the future HTTP adapter must map these to bounded envelopes without echoing
the HTTP adapter maps these to bounded envelopes without echoing
content, credentials or raw exception diagnostics.
No accept/publish endpoint is implied by this interface. Human acceptance stays
on the existing presentation/disposition path; publication needs an explicit
audience and separately authorized transition. The service supplies the shared
operations that subsequent HTTP and UI adapters will invoke.
Publication appends an immutable event for the current revision with an explicit
nonempty audience (at most 32 subjects and 32 groups, no wildcard). A successor
revision does not inherit publication. Audience facts are inputs to Access Engine,
never local permissions. Human acceptance requires the separate existing
presentation/disposition path; the lifecycle-to-review handoff is still open.
Before enabling a listener, T02/T04 must provide: reviewed lifecycle PDP package
and action/context fixtures; admitted human/workload identity adapters; closed

View file

@ -0,0 +1,70 @@
# Lifecycle workspace and delivery
2026-10-04; INFD-WP-0007-T02 through T06, source implementation.
## Human workspace
With `lifecycle_policy` configured, the signed-in home links to `/decisions`.
The workspace uses the same headless service as the API. Existing KeyCape
sessions establish identity; existing origin and CSRF checks protect forms.
Users can search, prepare a draft, record source metadata and evaluations,
comment, publish to explicit members/groups, refine, compare consecutive
revisions and download authorized packet entries. Source content is not fetched.
Packet uploads currently use the API. All forms receive fresh policy checks;
rendering a button grants no authority.
Publication records an audience for one revision. Access Engine determines access
using authenticated identity and retained record facts. Publication creates no
approval and no human disposition. New revisions require their own publication.
The workspace explicitly states that human acceptance is unavailable for these
drafts. Connecting a lifecycle revision to the existing protected presentation
and Approval Engine path remains T03/T05 work.
## Runtime and deployment inputs
The optional runtime `lifecycle_policy` object requires exactly `origin`,
`package`, `version`, `package_digest`, `caller_token_file`. Its package must
differ from the review package. No default policy or identity fallback exists.
The optional boolean `lifecycle_delivery` defaults to false. Configure both
for an admitted workspace with evidence delivery.
The deployment renderer accepts the same separate policy pins plus `pod_name`,
requires explicit `lifecycle_delivery: true` with that package, and generates
exact egress and an owner-review ingress proposal. It references the existing
mounted workload policy credential path; this does not authorize that workload
for a new package. Existing admission inputs render without lifecycle changes.
The API still requires its own admitted bearer verifier and listener composition.
## Independent evidence
On an enabled audit tick, up to 100 pending mutations are staged as immutable
`informed-decision.lifecycle` commitments in the existing outbox. The event UUID
is the delivery identity. Commitment metadata includes memo id, revision,
operation, a content digest and an existence assertion. The digest covers the
retained event and historical memo. Advisory text stays in source custody;
commitment delivery does not provide independent content reconstructability.
Pending obligations clear only after the outbox retains a receiver reference.
Lost replies retry with the same identity. The existing worker supplies bounded
delivery, reconciliation and a 24-hour lifecycle heartbeat. Undelivered evidence
keeps runtime readiness false. The current sink/reconciliation identity is
platform-scoped; other tenants fail closed at staging. Multi-tenant receiver
admission is not claimed. Kubernetes traffic readiness behavior is unchanged.
Tests use the actual local Audit Core receiver contract, including deduplication
and reconciliation. The agent-to-human collaboration scenario creates two
records, publishes/comments/refines and drains five events with unchanged runtime
configuration bytes. Its identity and PDP transport are synthetic. It proves
source behavior, not deployed workload authority or all seven acceptance cases.
## Remaining delivery work
T02/T04 require owner-admitted lifecycle policy actions/context, workload identity
and API ingress. The policy must cover all actions in LifecycleServiceContract,
including collection search and per-record checks. Existing compact-sitting pins
do not supply these grants. WP-0005 retains private intake composition work.
T03/T05 require the durable review handoff and human-only acceptance integration.
T06 requires full acceptance evidence, sender/cadence admission, immutable image,
database backup/migration and rollback verification. T07 requires the separately
approved concrete live rollout. No production pins or resources changed here.

View file

@ -39,6 +39,7 @@ class EventClass(str, Enum):
DISPOSITION = "informed-decision.disposition"
STANCE_APPLICATION = "informed-decision.stance_application"
MEMO_POSED = "informed-decision.memo.posed"
LIFECYCLE = "informed-decision.lifecycle"
HEARTBEAT = "audit-core.heartbeat"

View file

@ -184,6 +184,54 @@ class LifecycleRepository:
raise EvidenceUnavailable("evaluation source unavailable for this revision")
return self._append(db, scope, digest, memo, kind, body, policy_id)
def publish(self, context, memo_id, version, *, audience, key, guard=None):
audience = publication_audience(audience)
if type(version) is not int or version < 1:
raise ValueError("positive revision required")
scope, digest = self._request(context, "publish", key,
{"memo_id": memo_id, "version": version, "audience": audience})
with self.store._transaction() as db:
policy_id = guard(db) if guard is not None else None
self._record(db, context.tenant, memo_id)
replay = self._replay(db, scope, digest)
if replay:
return replay
memo = self.store._memo(db, memo_id)
if memo.version != version:
raise Conflict("publish requires the current revision")
return self._append(db, scope, digest, memo, "published", {"audience": audience}, policy_id)
def policy_context(self, tenant, memo_id, version=None):
with self.store._connection() as db:
return self._policy_context(db, tenant, memo_id, version)
@staticmethod
def _policy_context(db, tenant, memo_id, version=None):
row = db.execute("SELECT e.actor, (SELECT MAX(version) FROM memos WHERE id=l.memo_id) AS current_version "
"FROM lifecycle_records l JOIN lifecycle_events e ON e.memo_id=l.memo_id "
"WHERE l.tenant=? AND l.memo_id=? AND e.kind='registered'", (tenant, memo_id)).fetchone()
if row is None:
return {"registered": False}
published = db.execute("SELECT body FROM lifecycle_events WHERE tenant=? AND memo_id=? "
"AND version=? AND kind='published' ORDER BY sequence DESC LIMIT 1",
(tenant, memo_id, version if version is not None else row["current_version"])).fetchone()
return {"registered": True, "registered_by": json.loads(row["actor"]),
"current_version": row["current_version"],
"published_audience": json.loads(published["body"])["audience"] if published else None}
def candidates(self, tenant, *, query="", after="", limit=100):
"""Internal bounded search candidates; callers must authorize each result."""
if (not isinstance(query, str) or len(query) > 200 or not isinstance(after, str)
or len(after) > 256 or type(limit) is not int or not 1 <= limit <= 100):
raise ValueError("invalid search parameters")
with self.store._connection() as db:
return [row[0] for row in db.execute(
"SELECT l.memo_id FROM lifecycle_records l JOIN memos m ON m.id=l.memo_id "
"WHERE l.tenant=? AND l.memo_id>? AND m.version=(SELECT MAX(version) FROM memos WHERE id=l.memo_id) "
"AND (instr(lower(l.memo_id),lower(?))>0 OR instr(lower(json_extract(m.body,'$.question')),lower(?))>0 "
"OR instr(lower(json_extract(m.body,'$.brief')),lower(?))>0) ORDER BY l.memo_id LIMIT ?",
(tenant, after, query, query, query, limit))]
@staticmethod
def _validate_annotation(kind, body):
fields = {
@ -221,6 +269,19 @@ class LifecycleRepository:
self._record(db, tenant, memo_id)
return self.store._memo(db, memo_id, version)
def packet(self, tenant, memo_id, version, index):
if type(version) is not int or version < 1 or type(index) is not int or index < 0:
raise ValueError("invalid packet reference")
with self.store._connection() as db:
self._record(db, tenant, memo_id)
memo = self.store._memo(db, memo_id, version)
if index >= len(memo.packet):
raise EvidenceUnavailable("packet unavailable")
row = db.execute("SELECT content FROM documents WHERE digest=?", (memo.packet[index].hash,)).fetchone()
if row is None:
raise EvidenceUnavailable("packet unavailable")
return bytes(row[0])
def events(self, tenant: str, memo_id: str, *, after: int = 0, limit: int = 100) -> list[dict]:
if type(after) is not int or after < 0 or type(limit) is not int or not 1 <= limit <= 100:
raise ValueError("invalid event page")
@ -231,3 +292,20 @@ class LifecycleRepository:
(tenant, memo_id, after, limit)).fetchall()
return [{**dict(row), "actor": json.loads(row["actor"]), "body": json.loads(row["body"])}
for row in rows]
def publication_audience(value):
if not isinstance(value, dict) or set(value) != {"subjects", "groups"}:
raise ValueError("explicit publication audience required")
result = {}
for name, items in value.items():
if not isinstance(items, list) or len(items) > 32:
raise ValueError("invalid publication audience")
for item in items:
_text(item, "audience member")
if not item.strip() or item == "*":
raise ValueError("explicit audience member required")
result[name] = sorted(set(items))
if not any(result.values()):
raise ValueError("publication audience must not be empty")
return result

View file

@ -11,11 +11,12 @@ from urllib.parse import parse_qs
from .lifecycle_service import Caller, LifecycleError
from .lifecycle_commands import decode_memo
from .oidc import ORIGIN
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|revisions))?")
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations|revisions|publications))?")
def _object(pairs):
@ -77,6 +78,11 @@ class LifecycleAPI:
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 registration:
if set(params) - {"q", "cursor"}:
raise ValueError()
return 200, self.service.search(caller, query=params.get("q", [""])[0],
cursor=params.get("cursor", [None])[0])
if method == "GET" and resource is None:
if set(params) - {"version"}:
raise ValueError()
@ -90,7 +96,7 @@ class LifecycleAPI:
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, "register", "revisions"}:
if method != "POST" or resource not in {*kinds, "register", "revisions", "publications"}:
raise LifecycleError(405, "method_not_allowed")
if params:
raise ValueError()
@ -109,6 +115,11 @@ class LifecycleAPI:
key = environ.get("HTTP_IDEMPOTENCY_KEY", "")
if not isinstance(key, str) or not key.strip() or len(key) > 256:
raise ValueError()
if resource == "publications":
if not isinstance(data, dict) or set(data) != {"version", "audience"}:
raise ValueError()
event_id = self.service.publish(caller, memo_id, data["version"], audience=data["audience"], key=key)
return 200, {"event_id": event_id, "memo_id": memo_id, "version": data["version"]}
if resource in {"register", "revisions"}:
required = {"memo"} if registration else {"memo", "expected_version", "summary"}
if (not isinstance(data, dict) or not required <= data.keys()
@ -124,7 +135,8 @@ class LifecycleAPI:
event_id = self.service.revise(caller, memo, key=key,
expected_version=data["expected_version"], summary=data["summary"], documents=documents)
return 200, {"event_id": event_id, "memo_id": memo.id, "version": memo.version,
"resource": f"/v1/decisions/{memo.id}?version={memo.version}"}
"resource": f"/v1/decisions/{memo.id}?version={memo.version}",
"workspace_url": f"{ORIGIN}/decisions/{memo.id}?version={memo.version}"}
if not isinstance(data, dict) or set(data) != {"version", "body"}:
raise ValueError()
event_id = self.service.annotate(caller, memo_id, data["version"], kind=kinds[resource],

View file

@ -8,11 +8,13 @@ from dataclasses import dataclass
import hashlib
import json
import math
import secrets
import threading
import time
import uuid
from .disposition import Actor, ActorKind
from .lifecycle import Attribution, LifecycleRepository
from .lifecycle import Attribution, LifecycleRepository, publication_audience
from .oidc import HumanSession
from .policy import PolicyError
from .provenance import Route
@ -58,6 +60,8 @@ class LifecycleError(RuntimeError):
class LifecycleService:
def __init__(self, repository: LifecycleRepository, policy, *, clock=time.time):
self.repository, self.policy, self.clock = repository, policy, clock
self._cursors = {}
self._cursor_lock = threading.Lock()
def _caller(self, caller):
if not isinstance(caller, Caller) or caller.expires_at <= self.clock():
@ -71,14 +75,21 @@ class LifecycleService:
command = dumps(payload).encode()
if len(command) > 256 * 1024:
raise ValueError("lifecycle command too large")
version = payload.get("expected_version", payload.get("memo").version
if payload.get("memo") is not None else payload.get("version"))
state = (self.repository.policy_context(tenant, memo_id, version)
if operation != "search" else {"registered": False})
request = {"id": str(uuid.uuid4()), "tenant": tenant,
"subject": {"id": actor.sub, "type": "human" if actor.kind is ActorKind.PERSON else actor.kind.value,
"tenant": tenant, "attributes": json.loads(caller.attributes_json)},
"action": "lifecycle." + operation,
"resource": {"id": "memo:" + memo_id, "type": "decision-memo",
"resource": {"id": "decisions" if operation == "search" else "memo:" + memo_id,
"type": "decision-collection" if operation == "search" else "decision-memo",
"system": "informed-decision", "tenant": tenant},
"context": {"contract": "informed-decision.lifecycle.v1",
"memo_version": payload.get("memo").version if payload.get("memo") is not None else payload.get("version"),
"record": state,
**({"publication_audience": payload["audience"]} if operation == "publish" else {}),
"command_digest": "sha256:" + hashlib.sha256(command).hexdigest()},
"policy_version": self.policy.version}
observation = self.policy.check(request)
@ -102,10 +113,17 @@ class LifecycleService:
if (recorded is None or recorded["request"] != observation.request_json
or recorded["outcome"] != "allow" or recorded["usable_until"] <= self.clock()):
raise LifecycleError(503, "policy_observation_unavailable")
if self.repository._policy_context(db, tenant, memo_id, version) != state:
raise LifecycleError(409, "record_changed")
return policy_id
def read_current():
current()
if operation != "search" and self.repository.policy_context(tenant, memo_id, version) != state:
raise LifecycleError(409, "record_changed")
current()
return current, guard
return read_current, guard
def register(self, caller, memo, *, key, documents=None):
# Snapshot before the PDP call: mutable caller-owned objects cannot change
@ -137,6 +155,56 @@ class LifecycleService:
current()
return result
def publish(self, caller, memo_id, version, *, audience, key):
audience = publication_audience(audience)
_, guard = self._authorize(caller, memo_id, "publish",
{"version": version, "audience": audience, "key": key})
return self.repository.publish(caller.attribution, memo_id, version, audience=audience, key=key, guard=guard)
def packet(self, caller, memo_id, version, index):
current, _ = self._authorize(caller, memo_id, "packet", {"version": version, "index": index})
result = self.repository.packet(caller.attribution.tenant, memo_id, version, index)
current()
return result
def search(self, caller, *, query="", cursor=None):
self._caller(caller)
if not isinstance(query, str) or len(query) > 200:
raise ValueError("invalid search query")
scope = (caller.attribution.tenant, dumps(caller.attribution.actor), query)
after = ""
with self._cursor_lock:
self._cursors = {key: row for key, row in self._cursors.items() if row[2] > self.clock()}
if cursor is not None:
row = self._cursors.get(cursor) if isinstance(cursor, str) else None
if row is None or row[0] != scope:
raise ValueError("invalid search cursor")
after = row[1]
current, _ = self._authorize(caller, "decisions", "search", {"query": query, "after": after})
candidates = self.repository.candidates(caller.attribution.tenant, query=query, after=after, limit=50)
results, checks = [], [current]
for memo_id in candidates:
try:
check, _ = self._authorize(caller, memo_id, "read", {"version": None})
except LifecycleError as error:
if error.status == 403:
continue
raise
memo = self.repository.retrieve(caller.attribution.tenant, memo_id)
check()
checks.append(check)
results.append({"memo_id": memo.id, "version": memo.version, "question": memo.question})
for check in checks:
check()
next_cursor = None
if len(candidates) == 50:
with self._cursor_lock:
if len(self._cursors) >= 1000:
raise LifecycleError(503, "search_capacity")
next_cursor = secrets.token_urlsafe(24)
self._cursors[next_cursor] = (scope, candidates[-1], self.clock() + 300)
return {"decisions": results, "next_cursor": next_cursor}
def events(self, caller, memo_id, *, after=0, limit=100):
current, _ = self._authorize(caller, memo_id, "events", {"after": after, "limit": limit})
result = self.repository.events(caller.attribution.tenant, memo_id, after=after, limit=limit)

View file

@ -0,0 +1,183 @@
"""Human workspace adapter: forms and rendering, with no storage or policy rules."""
from dataclasses import replace
import re
from urllib.parse import urlencode
import uuid
from .lifecycle_commands import decode_memo
from .lifecycle_service import Caller, LifecycleError
from .memo import Memo, BindingLevel, BindingSlice, Principal, Scope, StepKind
from .records import dumps
from .store import EvidenceUnavailable
from .ui import document, hidden, text
import json
def _form(action, session, fields, button):
return (f'<form method="post" action="{text(action)}">'
+ hidden("csrf", session.csrf) + hidden("key", str(uuid.uuid4()))
+ fields + f'<button type="submit">{text(button)}</button></form>')
def _textarea(name, label, value=""):
return f'<label>{text(label)}<textarea name="{text(name)}" required>{text(value)}</textarea></label>'
def _lines(value):
return [line.strip() for line in value.splitlines() if line.strip()]
class LifecycleWorkspace:
def __init__(self, service):
self.service = service
def dispatch(self, method, path, params, session, form=None):
caller = Caller.from_human_session(session)
self.service._caller(caller)
if method == "GET" and path == "/decisions":
if set(params) - {"q", "cursor"}:
raise ValueError()
query = params.get("q", "")
page = self.service.search(caller, query=query, cursor=params.get("cursor"))
content = ('<h1>Decision workspace</h1><p>Prepare a decision, examine evidence, and invite discussion.</p>'
'<form method="get" action="/decisions"><label>Search decisions '
f'<input name="q" value="{text(query)}" maxlength="200"></label><button>Search</button></form>'
'<p><a href="/decisions/new/prepare">Prepare a decision</a></p><ul>')
for row in page["decisions"]:
content += (f'<li><a href="/decisions/{text(row["memo_id"])}">{text(row["question"])}</a>'
f' · revision {row["version"]}</li>')
content += '</ul>'
if not page["decisions"]:
content += '<p>No decisions to display on this page.</p>'
if page["next_cursor"]:
content += f'<p><a href="/decisions?{text(urlencode({"q": query, "cursor": page["next_cursor"]}))}">Next page</a></p>'
return 200, document("Decision workspace", content, session.subject), []
if path == "/decisions/new/prepare":
if params:
raise ValueError()
if method == "GET":
fields = (_textarea("question", "Question") + _textarea("brief", "Context, alternatives and recommendation")
+ '<label>Subject or scope identifier <input name="scope_id" required maxlength="200"></label>'
'<label>Subject or scope label <input name="scope_label" required maxlength="200"></label>')
content = '<h1>Prepare a decision</h1>' + _form(path, session, fields, "Save draft")
return 200, document("Prepare a decision", content, session.subject), []
if method == "POST":
self._fields(form, {"question", "brief", "scope_id", "scope_label"})
memo = Memo(id=str(uuid.uuid4()), version=1, question=form["question"], brief=form["brief"],
requested_act="accept", binding_level=BindingLevel.ORGANIZATIONAL,
step_kind=StepKind.MITZEICHNUNG,
binding=BindingSlice(Principal("pending-human-session", "person", "To be assigned"),
Scope("resource", form["scope_id"], form["scope_label"])))
# Stable create identity across an HTTP retry of the same rendered form.
memo = replace(memo, id=str(uuid.uuid5(uuid.NAMESPACE_URL,
dumps([caller.attribution.tenant, session.subject, form["key"]]))))
self.service.register(caller, decode_memo(json.loads(dumps(memo))), key=form["key"])
return 303, "", [("Location", "/decisions/" + memo.id)]
match = re.fullmatch(r"/decisions/([A-Za-z0-9._:-]{1,256})(?:/(revise|comment|publish|source|evaluate|packet/([0-9]{1,3})))?", path)
if not match:
return 404, "Not found.", []
memo_id, action, index = match.groups()
if method == "GET" and (action is None or index is not None):
if set(params) - {"version", "after"}:
raise ValueError()
version = int(params["version"]) if "version" in params else None
if index is not None:
if version is None or "after" in params:
raise ValueError()
return 200, self.service.packet(caller, memo_id, version, int(index)), [
("Content-Disposition", f'attachment; filename="decision-document-{int(index)+1}.bin"')]
memo = self.service.retrieve(caller, memo_id, version)
events = self.service.events(caller, memo_id, after=int(params.get("after", "0")))
previous = None
if memo.version > 1:
try:
previous = self.service.retrieve(caller, memo_id, memo.version - 1)
except EvidenceUnavailable:
pass
except LifecycleError as error:
if error.status != 403:
raise
return 200, self._page(memo, events, session, previous), []
if method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate"}:
if params:
raise ValueError()
fields = {"revise": {"version", "question", "brief", "summary"},
"comment": {"version", "text"}, "publish": {"version", "subjects", "groups"},
"source": {"version", "reference", "retrieved_at", "digest", "summary"},
"evaluate": {"version", "assessment", "source_ids", "uncertainty"}}[action]
self._fields(form, fields)
version = int(form["version"])
if action == "revise":
previous = self.service.retrieve(caller, memo_id, version)
memo = previous.next_version(question=form["question"], brief=form["brief"])
self.service.revise(caller, decode_memo(json.loads(dumps(memo))), expected_version=version,
summary=form["summary"], key=form["key"])
elif action == "publish":
self.service.publish(caller, memo_id, version, key=form["key"],
audience={"subjects": _lines(form["subjects"]), "groups": _lines(form["groups"])})
else:
body = {key: form[key] for key in fields - {"version"}}
if action == "evaluate":
body["source_ids"] = _lines(body["source_ids"])
self.service.annotate(caller, memo_id, version, key=form["key"],
kind="evaluation" if action == "evaluate" else action, body=body)
return 303, "", [("Location", "/decisions/" + memo_id)]
return 405, "Method not allowed.", []
@staticmethod
def _fields(form, required):
if not isinstance(form, dict) or set(form) != required | {"csrf", "key"}:
raise ValueError()
uuid.UUID(form["key"])
def _page(self, memo, events, session, previous=None):
base = "/decisions/" + memo.id
revision = hidden("version", memo.version)
content = (f'<p><a href="/decisions">Decision workspace</a></p><h1>{text(memo.question)}</h1>'
f'<p>Revision {memo.version} · {text(memo.binding.target.label)}</p>'
f'<section class="card"><h2>Context and recommendation</h2><p class="brief">{text(memo.brief)}</p></section>'
'<p>Human acceptance is not available for this draft. Publishing invites discussion; it does not record acceptance.</p>')
if previous is not None:
content += f'<details class="card"><summary>Compare with revision {previous.version}</summary>'
for field, label in (("question", "Question"), ("brief", "Context and recommendation")):
before, after = getattr(previous, field), getattr(memo, field)
if before != after:
content += (f'<h3>{label}</h3><p>Before</p><p class="brief">{text(before)}</p>'
f'<p>After</p><p class="brief">{text(after)}</p>')
content += '</details>'
if memo.packet:
content += '<section class="card"><h2>Supporting documents</h2><ul>'
for index, item in enumerate(memo.packet):
content += f'<li><a href="{text(base)}/packet/{index}?version={memo.version}">{text(item.label)}</a></li>'
content += '</ul></section>'
content += '<section class="card"><h2>Discussion and history</h2>'
for event in events:
body = {key: value for key, value in event["body"].items() if key != "policy_observation_id"}
content += (f'<article class="record"><h3>{text(event["kind"])} · revision {event["version"]}</h3>'
f'<p>{text(event["actor"]["sub"])} ({text(event["actor"]["kind"])}) · {text(event["at"])}</p>')
if event["kind"] == "source":
content += f'<p>Source reference: <code>{text(event["id"])}</code></p>'
for key, value in body.items():
content += f'<p><strong>{text(key.replace("_", " "))}:</strong> {text(dumps(value) if isinstance(value, (dict, list)) else value)}</p>'
content += '</article>'
if len(events) == 100:
content += f'<p><a href="{text(base)}?version={memo.version}&amp;after={events[-1]["sequence"]}">More history</a></p>'
content += '</section><section class="card"><h2>Add a comment</h2>'
content += _form(base + "/comment", session, revision + _textarea("text", "Comment"), "Add comment") + '</section>'
content += '<details class="card"><summary>Refine this decision</summary>'
content += _form(base + "/revise", session, revision + _textarea("question", "Question", memo.question)
+ _textarea("brief", "Context and recommendation", memo.brief)
+ _textarea("summary", "What changed"), "Save new revision") + '</details>'
content += '<details class="card"><summary>Publish for discussion</summary><p>Enter explicit member or group identifiers, one per line.</p>'
content += _form(base + "/publish", session, revision + '<label>Members<textarea name="subjects"></textarea></label>'
'<label>Groups<textarea name="groups"></textarea></label>', "Publish this revision") + '</details>'
content += '<details class="card"><summary>Add a research source</summary>'
content += _form(base + "/source", session, revision + ''.join(_textarea(name, label) for name, label in (
("reference", "Source location or reference"), ("retrieved_at", "Retrieved at (date, time and timezone)"),
("digest", "Source content digest (sha256:...)"), ("summary", "What the source supports"))), "Add source") + '</details>'
content += '<details class="card"><summary>Evaluate the evidence</summary>'
content += _form(base + "/evaluate", session, revision + _textarea("assessment", "Assessment")
+ '<label>Source references (one per line)<textarea name="source_ids"></textarea></label>'
+ _textarea("uncertainty", "Uncertainty and open questions"), "Add evaluation") + '</details>'
return document("Decision workspace", content, session.subject)

View file

@ -36,9 +36,10 @@ 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, intake_worker=None):
def __init__(self, store, sink, *, clock=time.time, intake_worker=None, lifecycle_delivery=False):
self.store, self.worker, self.clock = store, OutboxWorker(store, sink), clock
self.intake_worker = intake_worker
self.lifecycle_delivery = lifecycle_delivery
self._stop = threading.Event()
self._thread = None
self._lock = threading.Lock()
@ -57,10 +58,15 @@ class AuditPump:
try:
if self.intake_worker:
self.intake_worker.run_once(limit=10)
self.store.queue_heartbeats(include_posed=self.intake_worker is not None)
if self.lifecycle_delivery:
self.store.stage_lifecycle_audit()
self.store.queue_heartbeats(include_posed=self.intake_worker is not None,
include_lifecycle=self.lifecycle_delivery)
result = self.worker.run_once(limit=10)
if self.intake_worker:
self.store.settle_intake_audit()
if self.lifecycle_delivery:
self.store.settle_lifecycle_audit()
now = self.clock()
if now - self._last_reconciled >= 300:
stamp = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat(timespec="microseconds")
@ -112,6 +118,7 @@ class AuditPump:
class Runtime:
controller: ReviewController
pump: AuditPump
lifecycle: object | None = None
@classmethod
def from_file(cls, filename):
@ -122,11 +129,14 @@ 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", "intake_delivery"}) != {"schema", "evidence_db", "policy", "approval_origin", "audit"}
if ((set(data) - {"ordinary_approval_ids", "intake_delivery", "lifecycle_policy", "lifecycle_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"}):
raise ValueError("invalid review runtime configuration")
lifecycle_delivery = data.get("lifecycle_delivery", False)
if type(lifecycle_delivery) is not bool:
raise ValueError("invalid lifecycle delivery setting")
ordinary = ordinary_review_ids(data.get("ordinary_approval_ids", []))
if not Path(data["evidence_db"]).is_absolute():
raise ValueError("absolute evidence database path required")
@ -145,6 +155,17 @@ class Runtime:
if not isinstance(delivery, dict) or set(delivery) != {'hub_origin'}:
raise ValueError('invalid intake delivery configuration')
hub = HubDelivery(delivery['hub_origin'])
lifecycle_policy = None
if "lifecycle_policy" in data:
configuration = data["lifecycle_policy"]
if (not isinstance(configuration, dict)
or set(configuration) != {"origin", "package", "version", "package_digest", "caller_token_file"}):
raise ValueError("invalid lifecycle policy configuration")
if configuration["package"] == policy["package"]:
raise ValueError("lifecycle policy must have a separate admission package")
lifecycle_policy = PolicyClient(configuration["origin"], token_file(configuration["caller_token_file"]),
package=configuration["package"], version=configuration["version"],
package_digest=configuration["package_digest"], allow_internal_http=True)
store = Store(data["evidence_db"])
controller = ReviewController(store, client, lambda session: ApprovalHTTPClient(
approval_origin, session, allow_internal_http=True, ordinary_approval_ids=tuple(ordinary)))
@ -153,4 +174,10 @@ class Runtime:
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))
lifecycle = None
if lifecycle_policy is not None:
from .lifecycle import LifecycleRepository
from .lifecycle_service import LifecycleService
lifecycle = LifecycleService(LifecycleRepository(store), lifecycle_policy)
return cls(controller, AuditPump(store, sink, intake_worker=worker,
lifecycle_delivery=lifecycle_delivery), lifecycle)

View file

@ -20,7 +20,7 @@ import uuid
from .approval_client import EntryResult
from .disposition import Actor, BINDING_VERBS, DispositionRefused, Verb, record
from .evidence import EventClass, commit_disposition, commit_presentation, commit_stance_application, heartbeat, HEARTBEAT_CLASSES
from .evidence import Commitment, EventClass, commit_disposition, commit_presentation, commit_stance_application, heartbeat, HEARTBEAT_CLASSES
from .memo import Memo
from .presentation import render
from .provenance import Claim, Route, assert_human_control_dischargeable
@ -556,18 +556,50 @@ class Store:
"(SELECT id FROM outbox WHERE state='delivered')")
return result.rowcount
def stage_lifecycle_audit(self):
"""Opt-in commitment-only delivery through the existing audit outbox."""
with self._transaction() as db:
rows = db.execute("SELECT l.* FROM lifecycle_pending p JOIN lifecycle_events l ON l.id=p.event_id "
"ORDER BY l.sequence LIMIT 100").fetchall()
staged = 0
for row in rows:
if row["tenant"] != "tenant:platform":
raise Conflict("lifecycle audit tenant not admitted by this runtime")
content = {"event": dict(row), "memo": self._memo(db, row["memo_id"], row["version"])}
existing = db.execute("SELECT class,content FROM evidence WHERE id=?", (row["id"],)).fetchone()
if existing:
if existing["class"] != EventClass.LIFECYCLE.value or existing["content"] != dumps(content):
raise Conflict("lifecycle evidence id collision")
continue
commitment = Commitment(row["id"], EventClass.LIFECYCLE, row["at"], {
"memo_id": row["memo_id"], "memo_version": row["version"], "operation": row["kind"],
"content_sha256": "sha256:" + hashlib.sha256(dumps(content).encode()).hexdigest(),
"content_exists": True, "custody": "informed-decision:lifecycle:" + row["id"]})
self._event(db, commitment, content)
staged += 1
return staged
def settle_lifecycle_audit(self):
with self._transaction() as db:
return db.execute("DELETE FROM lifecycle_pending WHERE event_id IN "
"(SELECT o.id FROM outbox o JOIN evidence e ON e.id=o.id "
"WHERE o.state='delivered' AND o.receiver_reference IS NOT NULL AND e.class=?)",
(EventClass.LIFECYCLE.value,)).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):
def queue_heartbeats(self, now=None, *, include_posed=False, include_lifecycle=False):
now = time.time() if now is None else now
count = 0
with self._transaction() as db:
classes = {**HEARTBEAT_CLASSES}
if include_posed:
classes[EventClass.MEMO_POSED.value] = 86400
if include_lifecycle:
classes[EventClass.LIFECYCLE.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",

View file

@ -17,6 +17,7 @@ from .disposition import DispositionRefused, Verb
from .policy import PolicyError
from .provenance import HumanControlNotDischargeable
from .review import ReviewError
from .lifecycle_service import LifecycleError
from .store import Conflict, EvidenceUnavailable, StoreError
from .ui import STYLES, document, error_page, overview_section, review_page
@ -38,9 +39,10 @@ def _one(query: str, *, limit=8192, fields=16) -> dict[str, str]:
class App:
def __init__(self, login: KeyCapeLogin, review=None, *, readiness=lambda: False):
def __init__(self, login: KeyCapeLogin, review=None, *, readiness=lambda: False, lifecycle=None):
self.login = login
self.review, self.readiness = review, readiness
self.lifecycle = lifecycle
def __call__(self, environ, start_response):
path = environ.get("PATH_INFO", "/")
@ -84,6 +86,11 @@ class App:
}
status, body, content_type = exc.status, error_page(messages.get(exc.code,
"The review could not complete its required checks. Reload after the service is available.")), "text/html"
except LifecycleError as exc:
status, body, content_type = exc.status, document("Decision unavailable",
'<h1>Decision unavailable</h1><p>The request could not complete its required checks. '
'Sign in again if your session expired, or reload the decision before retrying.</p>'
'<p><a href="/decisions">Return to the workspace</a></p>'), "text/html"
except DispositionRefused as exc:
status, body, content_type = 409, error_page({
"G_ACK": "Record all required highlight acknowledgments before accepting or declining.",
@ -125,6 +132,14 @@ class App:
return 303, "", "text/plain", [("Location", "/"), _cookie(FLOW_COOKIE, "", 0),
_cookie(SESSION_COOKIE, new_sid, 900)]
session = self.login.session(sid)
if self.lifecycle is not None and (path == "/decisions" or path.startswith("/decisions/")):
if session is None:
raise LifecycleError(401, "identity_expired")
from .lifecycle_ui import LifecycleWorkspace
params = _one(environ.get("QUERY_STRING", ""))
form = self._form(environ, session) if method == "POST" else None
status, body, extra = LifecycleWorkspace(self.lifecycle).dispatch(method, path, params, session, form)
return status, body, "application/octet-stream" if isinstance(body, bytes) else "text/html", extra
if self.review is not None and (path == "/review" or path.startswith("/presentations/")):
if session is None:
raise ReviewError(401, "session_expired")
@ -188,6 +203,7 @@ class App:
if method == "GET" and path == "/":
if session:
content = (f"<p>Signed in as {html.escape(session.subject)}.</p>"
+ ('<p><a href="/decisions">Open the decision workspace</a></p>' if self.lifecycle is not None else '')
+ (self._overview(session) if self.review is not None else '')
+ ('<h2>Open a decision review</h2><p>Enter the memo identifier supplied with your review request.</p>'
'<form method="get" action="/review"><label>Memo identifier <input name="memo_id" required maxlength="256"></label>'
@ -244,7 +260,8 @@ def main():
runtime = Runtime.from_file(os.environ["INFD_REVIEW_CONFIG"])
runtime.pump.start()
app = App(login, runtime.controller if runtime else None,
readiness=runtime.pump.ready if runtime else lambda: False)
readiness=runtime.pump.ready if runtime else lambda: False,
lifecycle=runtime.lifecycle if runtime else None)
try:
serve(app, host=host, port=8080, threads=4)
finally:

View file

@ -1,4 +1,5 @@
import copy
import json
import importlib.util
from pathlib import Path
@ -75,3 +76,24 @@ def test_policy_revision_changes_immutable_configuration_name():
first=inputs();second=copy.deepcopy(first);second['policy']['version']='v2'
names=lambda data:[o['metadata']['name'] for o in module.render(data)['items'] if o['kind']=='ConfigMap']
assert names(first)!=names(second)
def test_optional_workspace_requires_distinct_pins_and_exact_policy_network_peer():
data = inputs()
data['lifecycle_policy'] = {**data['policy'], 'package': 'informed-decision.lifecycle',
'origin': 'http://flex-auth-lifecycle.flex-auth.svc.cluster.local:8080',
'pod_name': 'flex-auth-lifecycle'}
with pytest.raises(ValueError):
module.render(data)
data['lifecycle_delivery'] = True
objects = module.render(data)['items']
config = json.loads(next(row for row in objects if row['kind']=='ConfigMap')['data']['runtime.json'])
assert config['lifecycle_policy']['package']=='informed-decision.lifecycle'
assert config['lifecycle_delivery'] is True
review_network = next(row for row in objects if row['kind']=='NetworkPolicy' and row['metadata']['namespace']=='informed-decision')
assert any(peer.get('podSelector', {}).get('matchLabels', {}).get('app.kubernetes.io/name')=='flex-auth-lifecycle'
for rule in review_network['spec']['egress'] for peer in rule.get('to', []))
assert any(row['metadata']['name']=='informed-decision-lifecycle-ingress' for row in objects)
data['lifecycle_policy']['package'] = data['policy']['package']
with pytest.raises(ValueError):
module.render(data)

View file

@ -0,0 +1,80 @@
from dataclasses import replace
import hashlib
import json
import pytest
from informed_decision.audit import OutboxWorker
from informed_decision.evidence import EventClass
from informed_decision.lifecycle import LifecycleRepository
from informed_decision.runtime import AuditPump
from informed_decision.store import Conflict, Store
from test_durable_component import receiver
from test_lifecycle import AGENT, workspace
def register_platform(workspace):
repo, memo = workspace
actor = replace(AGENT, tenant="tenant:platform")
event_id = repo.register(actor, memo, key="register")
return repo.store, memo, event_id
def test_lifecycle_commitment_is_content_free_stable_and_verifiable(workspace):
store, memo, event_id = register_platform(workspace)
assert store.stage_lifecycle_audit() == 1
assert store.stage_lifecycle_audit() == 0
evidence = store.evidence()[0]
envelope = json.loads(evidence["envelope"])
assert envelope["id"] == event_id and envelope["type"] == EventClass.LIFECYCLE.value
assert memo.brief not in evidence["envelope"] and memo.question not in evidence["envelope"]
assert envelope["data"]["content_sha256"] == "sha256:" + hashlib.sha256(evidence["content"].encode()).hexdigest()
assert store.settle_lifecycle_audit() == 0
assert store.lifecycle_counts()["pending"] == 1
assert Store(store.path).evidence() == store.evidence()
def test_lifecycle_delivery_uses_actual_receiver_and_recovers_lost_reply(workspace, receiver):
store, _, event_id = register_platform(workspace)
sink, transport, _ = receiver
store.stage_lifecycle_audit()
transport.lose_reply = True
worker = OutboxWorker(store, sink)
assert worker.run_once()["retrying"] == 1
assert store.settle_lifecycle_audit() == 0
with store._connection() as db:
db.execute("UPDATE outbox SET next_attempt=0 WHERE id=?", (event_id,))
reopened = Store(store.path)
assert OutboxWorker(reopened, sink).run_once()["delivered"] == 1
assert reopened.settle_lifecycle_audit() == 1
assert reopened.lifecycle_counts() == {"records": 1, "events": 1, "pending": 0}
assert worker.reconcile("2020-01-01T00:00:00Z", "2100-01-01T00:00:00Z")["counts"][EventClass.LIFECYCLE.value] == {"source": 1, "receiver": 1}
def test_runtime_delivery_is_explicit_and_restores_readiness_only_after_receipts(workspace, receiver):
store, _, _ = register_platform(workspace)
sink, _, _ = receiver
disabled = AuditPump(store, sink)
disabled.tick()
assert not disabled.ready() and store.lifecycle_counts()["pending"] == 1
enabled = AuditPump(store, sink, lifecycle_delivery=True)
enabled.tick()
assert enabled.ready() and store.lifecycle_counts()["pending"] == 0
assert store.evidence()[0]["class"] != EventClass.LIFECYCLE.value # Legacy heartbeats predate opt-in.
def test_unadmitted_tenant_remains_pending_without_audit_relabelling(workspace):
repo, memo = workspace
repo.register(AGENT, memo, key="register")
with pytest.raises(Conflict, match="tenant not admitted"):
repo.store.stage_lifecycle_audit()
assert repo.store.evidence() == []
assert repo.store.lifecycle_counts()["pending"] == 1
def test_pending_events_do_not_get_a_nothing_to_report_heartbeat(workspace):
store, _, _ = register_platform(workspace)
store.stage_lifecycle_audit()
store.queue_heartbeats(include_lifecycle=True)
assert not any(json.loads(row["envelope"])["data"].get("class") == EventClass.LIFECYCLE.value
for row in store.evidence() if row["class"] == EventClass.HEARTBEAT.value)

View file

@ -0,0 +1,74 @@
import json
from urllib.parse import urlencode
import uuid
from informed_decision.audit import OutboxWorker
from informed_decision.lifecycle_http import LifecycleAPI
from informed_decision.lifecycle_service import Caller
from informed_decision.runtime import Runtime
from informed_decision.web import App, SESSION_COOKIE
from informed_decision.oidc import ORIGIN
from informed_decision.policy import PolicyClient
from informed_decision.records import dumps
from test_browser_auth import login, signing_key, finish, call
from test_deployment_candidate import inputs, module
from test_durable_component import receiver
from test_lifecycle_http import call as api_call
from test_policy_client import PolicyTransport
from test_review_runtime import config
from test_skeleton import make_memo
def test_agent_to_human_collaboration_and_audit_without_configuration_changes(tmp_path, login, receiver):
path, configuration = config(tmp_path)
configuration['lifecycle_policy'] = {**configuration['policy'], 'package': 'informed-decision.lifecycle'}
configuration['lifecycle_delivery'] = True
path.write_text(json.dumps(configuration))
original_configuration = path.read_bytes()
runtime = Runtime.from_file(path)
transport = PolicyTransport()
transport.change = lambda data: data['provenance'].update(policy_package='informed-decision.lifecycle')
lifecycle = runtime.lifecycle
lifecycle.policy = PolicyClient('https://policy.test', lambda: 'synthetic-caller',
package='informed-decision.lifecycle', version='v1', package_digest=configuration['policy']['package_digest'],
transport=transport)
# Match the immutable configured digest in the simulated PDP return.
transport.change = lambda data: data['provenance'].update(
policy_package='informed-decision.lifecycle', policy_package_digest=configuration['policy']['package_digest'])
sid = finish(login)
session = login[0].session(sid)
from informed_decision.lifecycle import Attribution
from informed_decision.disposition import Actor, ActorKind
agent = Caller(Attribution(session.tenant.value, Actor('synthetic-agent', ActorKind.AGENT)),
session.expires_at, '{"principal_type_source":"authentication-derived"}')
def authenticate(token):
if token != 'fixture':
raise ValueError()
return agent
api = LifecycleAPI(lifecycle, authenticate)
app = App(login[0], runtime.controller, readiness=runtime.pump.ready, lifecycle=lifecycle)
cookie = f'{SESSION_COOKIE}={sid}'
memo = make_memo(id='agent-posed', approval_id=None, packet=(), highlights=())
status, receipt, _ = api_call(api, '/v1/decisions', method='POST', body={'memo': json.loads(dumps(memo))})
assert status == 200 and receipt['workspace_url'] == ORIGIN + '/decisions/agent-posed?version=1'
assert call(app, '/decisions/agent-posed', cookie=cookie)['status'] == 200
def post(action, **values):
values.update(csrf=session.csrf, key=str(uuid.uuid4()))
return call(app, '/decisions/agent-posed/' + action, 'POST', cookie=cookie, origin=ORIGIN,
body=urlencode(values).encode())
assert post('publish', version='1', subjects=session.subject, groups='')['status'] == 303
assert post('comment', version='1', text='Please add the alternative.')['status'] == 303
assert post('revise', version='1', question=memo.question, brief='Alternative added.', summary='Responded to feedback')['status'] == 303
assert api_call(api, '/v1/decisions/agent-posed')[1]['version'] == 2
from dataclasses import replace
second = replace(memo, id='second-agent-decision')
assert api_call(api, '/v1/decisions', method='POST', body={'memo': json.loads(dumps(second))},
HTTP_IDEMPOTENCY_KEY='second')[0] == 200
assert not runtime.pump.ready()
runtime.pump.worker = OutboxWorker(runtime.controller.store, receiver[0])
runtime.pump.tick()
assert runtime.pump.ready()
assert runtime.controller.store.lifecycle_counts() == {'records': 2, 'events': 5, 'pending': 0}
assert path.read_bytes() == original_configuration
assert runtime.controller.store.memos_for(memo.binding.principal.id) == []
assert api_call(api, '/v1/decisions/agent-posed/accept', method='POST', body={})[0] == 404

View file

@ -0,0 +1,111 @@
from dataclasses import replace
import json
import time
import pytest
from informed_decision.lifecycle_service import LifecycleError, LifecycleService
from informed_decision.store import Conflict, EvidenceUnavailable, Store
from test_lifecycle import AGENT, workspace
from test_lifecycle_service import caller, service
from test_policy_client import policy
AUDIENCE = {"subjects": ["reviewer"], "groups": []}
def test_publication_is_revision_pinned_durable_and_replayable(workspace):
app, memo = service(workspace)
actor = caller()
app.register(actor, memo, key="register")
receipt = app.publish(actor, memo.id, 1, audience=AUDIENCE, key="publish")
assert app.publish(actor, memo.id, 1, audience=AUDIENCE, key="publish") == receipt
state = app.repository.policy_context(AGENT.tenant, memo.id)
assert state["published_audience"] == AUDIENCE
reopened = type(app.repository)(Store(app.repository.store.path))
assert reopened.policy_context(AGENT.tenant, memo.id) == state
assert reopened.events(AGENT.tenant, memo.id)[-1]["kind"] == "published"
assert app.policy.transport.calls[1][3]["context"]["publication_audience"] == AUDIENCE
app.revise(actor, memo.next_version(brief="revised"), expected_version=1, summary="Changed", key="revise")
assert app.repository.policy_context(AGENT.tenant, memo.id)["published_audience"] is None
assert app.repository.policy_context(AGENT.tenant, memo.id, 1)["published_audience"] == AUDIENCE
with pytest.raises(Conflict):
app.publish(actor, memo.id, 1, audience=AUDIENCE, key="new-publish")
with app.repository.store._connection() as db:
assert db.execute("SELECT count(*) FROM dispositions").fetchone()[0] == 0
assert db.execute("SELECT count(*) FROM submissions").fetchone()[0] == 0
@pytest.mark.parametrize("audience", [{}, {"public": True}, {"subjects": [], "groups": []},
{"subjects": ["*"], "groups": []},
{"subjects": "reviewer", "groups": []}])
def test_publication_requires_explicit_bounded_audience(workspace, audience):
app, memo = service(workspace)
app.register(caller(), memo, key="register")
with pytest.raises(ValueError):
app.publish(caller(), memo.id, 1, audience=audience, key="publish")
assert len(app.repository.events(AGENT.tenant, memo.id)) == 1
def test_audience_is_not_local_permission_and_denial_prevents_publication(workspace):
app, memo = service(workspace)
app.register(caller(), memo, key="register")
app.policy.transport.change = lambda data: data.update(effect="deny")
with pytest.raises(LifecycleError) as error:
app.publish(caller(), memo.id, 1, audience=AUDIENCE, key="publish")
assert error.value.status == 403
assert app.repository.policy_context(AGENT.tenant, memo.id)["published_audience"] is None
def test_audience_change_during_authorization_blocks_read_and_write(workspace, monkeypatch):
app, memo = service(workspace)
app.register(caller(), memo, key="register")
original = app.repository.annotate
def changed(*args, **kwargs):
app.repository.publish(AGENT, memo.id, 1, audience=AUDIENCE, key="concurrent-publish")
return original(*args, **kwargs)
monkeypatch.setattr(app.repository, "annotate", changed)
with pytest.raises(LifecycleError, match="record_changed"):
app.annotate(caller(), memo.id, 1, kind="comment", body={"text": "pending"}, key="comment")
original_read = app.repository.retrieve
def changed_read(*args, **kwargs):
result = original_read(*args, **kwargs)
app.repository.publish(AGENT, memo.id, 1, audience={"subjects": ["another"], "groups": []}, key="replace")
return result
monkeypatch.setattr(app.repository, "retrieve", changed_read)
with pytest.raises(LifecycleError, match="record_changed"):
app.retrieve(caller(), memo.id)
def test_search_filters_denied_records_and_opaque_cursor_is_caller_scoped(workspace):
app, memo = service(workspace)
for index in range(51):
app.repository.register(AGENT, replace(memo, id=f"record-{index:03}"), key=f"register-{index}")
# Actual PolicyClient still validates these synthetic PDP responses.
def decide(data):
if data["binding"]["resource"]["id"] == "memo:record-001":
data["effect"] = "deny"
app.policy.transport.change = decide
first = app.search(caller())
assert len(first["decisions"]) == 49
assert "record-001" not in json.dumps(first)
assert first["next_cursor"] and "record-049" not in first["next_cursor"]
assert app.search(caller(), cursor=first["next_cursor"])["decisions"][0]["memo_id"] == "record-050"
with pytest.raises(ValueError):
app.search(caller(), query="different", cursor=first["next_cursor"])
foreign = replace(caller(), attribution=replace(AGENT, tenant="tenant:other"))
with pytest.raises(ValueError):
app.search(foreign, cursor=first["next_cursor"])
with pytest.raises(ValueError):
LifecycleService(app.repository, policy()).search(caller(), cursor=first["next_cursor"])
app.policy.transport.change = lambda data: data.update(effect="deny")
with pytest.raises(LifecycleError):
app.search(caller(), cursor=first["next_cursor"])
def test_search_does_not_turn_policy_failure_into_empty_success(workspace):
app, memo = service(workspace)
app.repository.register(AGENT, memo, key="register")
app.policy.transport.status = 503
with pytest.raises(LifecycleError):
app.search(caller())

View file

@ -0,0 +1,92 @@
import re
import uuid
from urllib.parse import urlencode
import pytest
from informed_decision.lifecycle_service import Caller
from informed_decision.oidc import ORIGIN
from informed_decision.web import App, SESSION_COOKIE
from test_browser_auth import login, signing_key, finish, call
from test_lifecycle import workspace
from test_lifecycle_service import service
@pytest.fixture
def browser(workspace, login):
lifecycle, _ = service(workspace)
sid = finish(login)
session = login[0].session(sid)
return App(login[0], lifecycle=lifecycle), lifecycle, session, f"{SESSION_COOKIE}={sid}"
def post(browser, path, **values):
app, _, session, cookie = browser
values.setdefault("csrf", session.csrf)
values.setdefault("key", str(uuid.uuid4()))
return call(app, path, "POST", cookie=cookie, origin=ORIGIN, body=urlencode(values).encode())
def create(browser):
response = post(browser, "/decisions/new/prepare", question="Which storage option should we adopt?",
brief="Compare cost, recovery and maintenance.", scope_id="storage", scope_label="Storage strategy")
assert response["status"] == 303
return dict(response["headers"])["Location"]
def test_human_workspace_create_refine_publish_discuss_and_retrieve(browser):
app, lifecycle, session, cookie = browser
assert 'Open the decision workspace' in call(app, cookie=cookie)["body"]
path = create(browser)
page = call(app, path, cookie=cookie)
assert page["status"] == 200 and "Storage strategy" in page["body"]
assert "Human acceptance is not available" in page["body"]
assert post(browser, path + "/comment", version="1", text="Please include restore time.")["status"] == 303
assert post(browser, path + "/publish", version="1", subjects=session.subject, groups="")["status"] == 303
assert "published" in call(app, path, cookie=cookie)["body"]
assert post(browser, path + "/revise", version="1", question="Which storage option should we adopt?",
brief="Option A restores in one hour.", summary="Added recovery estimate")["status"] == 303
assert "Revision 2" in call(app, path, cookie=cookie)["body"]
assert "Compare cost" in call(app, path, cookie=cookie, query="version=1")["body"]
listing = call(app, "/decisions", cookie=cookie, query="q=storage")
assert listing["status"] == 200 and path in listing["body"]
memo_id = path.rsplit("/", 1)[1]
assert lifecycle.repository.policy_context(session.tenant.value, memo_id)["published_audience"] is None
assert lifecycle.repository.store.lifecycle_counts()["pending"] == 4
def test_same_create_form_replay_reuses_record(browser):
fields = {"question": "Which option?", "brief": "Consider alternatives", "scope_id": "target",
"scope_label": "Target", "key": str(uuid.uuid4())}
first = post(browser, "/decisions/new/prepare", **fields)
second = post(browser, "/decisions/new/prepare", **fields)
assert dict(first["headers"])["Location"] == dict(second["headers"])["Location"]
assert browser[1].repository.store.lifecycle_counts()["records"] == 1
def test_csrf_and_origin_are_enforced_before_any_mutation(browser):
app, lifecycle, session, cookie = browser
values = {"question": "Which option?", "brief": "Consider alternatives", "scope_id": "target",
"scope_label": "Target", "key": str(uuid.uuid4()), "csrf": session.csrf}
for origin, csrf in [(None, session.csrf), ("https://other.test", session.csrf), (ORIGIN, "wrong")]:
response = call(app, "/decisions/new/prepare", "POST", cookie=cookie, origin=origin,
body=urlencode({**values, "csrf": csrf}).encode())
assert response["status"] == 403
assert lifecycle.repository.store.lifecycle_counts()["records"] == 0
def test_workspace_is_opt_in_and_requires_human_session(browser):
app, _, _, cookie = browser
assert call(app, "/decisions")["status"] == 401
assert call(App(app.login), "/decisions", cookie=cookie)["status"] == 404
def test_workspace_escapes_content_and_policy_failure_returns_no_memo(browser):
app, lifecycle, _, cookie = browser
path = create(browser)
post(browser, path + "/comment", version="1", text='<script>alert("x")</script>')
page = call(app, path, cookie=cookie)["body"]
assert "<script>" not in page and "&lt;script&gt;" in page
lifecycle.policy.transport.change = lambda data: data.update(effect="deny")
response = call(app, path, cookie=cookie)
assert response["status"] == 403 and "Compare cost" not in response["body"]

View file

@ -63,6 +63,21 @@ def test_config_load_does_not_provision_or_read_credentials(tmp_path):
assert all(r['state']=='blocked' for r in runtime.controller.store.outbox())
def test_lifecycle_workspace_requires_separate_explicit_policy_pin(tmp_path):
path, data = config(tmp_path)
assert Runtime.from_file(path).lifecycle is None
data['lifecycle_policy'] = dict(data['policy'])
path.write_text(json.dumps(data))
with pytest.raises(ValueError, match='separate admission'):
Runtime.from_file(path)
data['lifecycle_policy']['package'] = 'informed-decision.lifecycle'
path.write_text(json.dumps(data))
runtime = Runtime.from_file(path)
assert runtime.lifecycle.repository.store is runtime.controller.store
assert runtime.lifecycle.policy is not runtime.controller.policy
assert not runtime.pump.ready()
@pytest.mark.parametrize('fault',['permissions','symlink','unknown-key','relative-store','missing-pin','unsafe-origin'])
def test_unsafe_or_incomplete_config_refused(tmp_path,fault):
path,data=config(tmp_path)

View file

@ -12,12 +12,7 @@ SHA = re.compile(r'sha256:[0-9a-f]{64}')
LABEL = {'app.kubernetes.io/name': NAME}
def render(inputs):
if not isinstance(inputs,dict) or (set(inputs)-{'ordinary_approval_ids'})!={'image','policy','keycape_egress_ips','storage_class'}:
raise ValueError('exact image, policy, KeyCape egress IPs and storage class inputs required')
if not re.fullmatch(r'forgejo\.coulomb\.social/coulomb/informed-decision@sha256:[0-9a-f]{64}',inputs['image']):
raise ValueError('immutable Informed Decision registry digest required')
policy=inputs['policy']
def validate_policy(policy):
if not isinstance(policy,dict) or set(policy)!={'origin','package','version','package_digest','pod_name'}:
raise ValueError('exact policy endpoint, package pins and pod selector required')
if not re.fullmatch(r'http://[a-z0-9-]+\.flex-auth\.svc\.cluster\.local:8080',policy['origin']):
@ -27,6 +22,15 @@ def render(inputs):
if not SHA.fullmatch(policy['package_digest']) or any(
not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}',policy[key]) for key in ['package','version']):
raise ValueError('exact policy package/version/digest required')
def render(inputs):
if not isinstance(inputs,dict) or (set(inputs)-{'ordinary_approval_ids','lifecycle_policy','lifecycle_delivery'})!={'image','policy','keycape_egress_ips','storage_class'}:
raise ValueError('exact image, policy, KeyCape egress IPs and storage class inputs required')
if not re.fullmatch(r'forgejo\.coulomb\.social/coulomb/informed-decision@sha256:[0-9a-f]{64}',inputs['image']):
raise ValueError('immutable Informed Decision registry digest required')
policy=inputs['policy']
validate_policy(policy)
if not re.fullmatch(r'[a-z0-9][a-z0-9.-]{0,62}',inputs['storage_class']):
raise ValueError('explicit storage class required')
ips=inputs['keycape_egress_ips']
@ -51,6 +55,18 @@ def render(inputs):
raise ValueError('exact ordinary approval ids required')
config['ordinary_approval_ids']=sorted(ids)
config['policy']['caller_token_file']='/var/run/secrets/informed-decision/policy/token'
lifecycle=inputs.get('lifecycle_policy')
if 'lifecycle_policy' in inputs:
validate_policy(lifecycle)
if lifecycle['package']==policy['package'] or inputs.get('lifecycle_delivery') is not True:
raise ValueError('separate lifecycle policy and explicit lifecycle delivery required')
config['lifecycle_policy']={key:lifecycle[key] for key in ['origin','package','version','package_digest']}
config['lifecycle_policy']['caller_token_file']=config['policy']['caller_token_file']
if 'lifecycle_delivery' in inputs:
if type(inputs['lifecycle_delivery']) is not bool:
raise ValueError('lifecycle delivery must be boolean')
config['lifecycle_delivery']=inputs['lifecycle_delivery']
raw=json.dumps(config,sort_keys=True,separators=(',',':'))
config_name=NAME+'-runtime-'+hashlib.sha256(raw.encode()).hexdigest()[:12]
def obj(api,kind,name,**values):
@ -103,7 +119,7 @@ def render(inputs):
'podSelector':{'matchLabels':{'k8s-app':'kube-dns'}}}],
'ports':[{'protocol':'UDP','port':53},{'protocol':'TCP','port':53}]},
{'to':[peer('approval-engine','approval-engine'),peer('audit-core','audit-core'),
peer('flex-auth',policy['pod_name'])],'ports':http},
peer('flex-auth',policy['pod_name'])]+([peer('flex-auth',lifecycle['pod_name'])] if lifecycle else []),'ports':http},
# Traefik's observed websecure target is 8443. Include
# its exact peer for CNI enforcement after Service DNAT.
{'to':[peer('kube-system','traefik')],'ports':[{'protocol':'TCP','port':8443}]},
@ -111,9 +127,10 @@ def render(inputs):
# Counterparty proposals travel in the review packet. Neither the existing
# Approval Engine namespace gate nor other Flex callers admits this pod.
# Audit Core already owns its prepared exact informed-decision ingress.
for namespace,name,task in [('approval-engine','approval-engine','APPROVAL-WP-0002-T01'),
('flex-auth',policy['pod_name'],'INFD-WP-0001-T08 policy-owner return')]:
item=obj('networking.k8s.io/v1','NetworkPolicy',NAME+'-review-ingress',spec={
for namespace,name,task in ([('approval-engine','approval-engine','APPROVAL-WP-0002-T01'),
('flex-auth',policy['pod_name'],'INFD-WP-0001-T08 policy-owner return')]
+ ([('flex-auth',lifecycle['pod_name'],'INFD-WP-0007 lifecycle policy-owner return')] if lifecycle else [])):
item=obj('networking.k8s.io/v1','NetworkPolicy',NAME+('-lifecycle-ingress' if task.startswith('INFD-WP-0007') else '-review-ingress'),spec={
'podSelector':{'matchLabels':{'app.kubernetes.io/name':name}},'policyTypes':['Ingress'],
'ingress':[{'from':[{'namespaceSelector':{'matchLabels':{'kubernetes.io/metadata.name':NAME}},
'podSelector':{'matchLabels':{**LABEL,'app.kubernetes.io/component':'review'}}}],'ports':http}]})

View file

@ -22,8 +22,8 @@ state_hub_workstream_id: "2b3e04ed-2137-584f-a6c2-f1abb29636fa"
Contract: `docs/specs/DecisionLifecycleService.md`. This extends the existing
memo and intake implementation. `INFD-WP-0005` continues to own intake runtime,
attribute admission, forwarding and live acceptance. Implementation started on
2026-10-04 at the founder's request. API/identity/publication contracts remain
open; the first persistence slice is local and not exposed by a service route.
2026-10-04 at the founder's request. Production identity/policy admission and the human binding handoff remain
open. The shared service now has a bearer API adapter and optional browser workspace.
## Record product direction and architecture
@ -90,7 +90,7 @@ uncertain external outcomes. The complete agent scenario must run without a UI.
```task
id: INFD-WP-0007-T05
status: todo
status: progress
priority: high
depends_on: [INFD-WP-0007-T04]
state_hub_task_id: "a6a7c9a8-760e-52dd-80d3-d36f43f0a86a"
@ -105,7 +105,7 @@ step-up understandable. Verify existing URLs and old records remain usable.
```task
id: INFD-WP-0007-T06
status: todo
status: progress
priority: high
depends_on: [INFD-WP-0007-T04, INFD-WP-0007-T05]
state_hub_task_id: "aa839bea-cc4c-5ee0-85e7-87c881e8e578"
@ -204,3 +204,25 @@ The adapter remains unmounted; T02/T03/T04 remain in progress.
Validation: cross-repository suite 598 passed, 5 skipped; subsequent HTTP tests
include shared L0/L3 decoding and preservation of requires_new_bind.
`git diff --check` passed. No live deployment or authentication admission.
## Workspace, publication and independent evidence — 2026-10-04
Continued T02–T06 with revision-specific explicit publication audiences, policy
record facts and race guards, bounded authorized search with opaque scoped cursors,
and packet retrieval. Added a human workspace through the same service: create,
research/evaluate, comment, publish, compare and refine. API receipts include
workspace links. Existing session, CSRF and origin protections are reused.
Runtime optionally composes a separately pinned lifecycle policy; the deployment
renderer produces corresponding exact network proposals. Opt-in lifecycle audit
commitments use the existing outbox, real receiver receipts, retries, heartbeats
and reconciliation. Tests cover lost replies, restart, audience races, denial,
HTML escaping, form replay and unchanged configuration across two new decisions
and a revision. The full cross-repository suite passes: 623 passed, 5 skipped.
T05/T06 are in progress, not complete. Live tasks retain: admitted lifecycle PDP
policy and workload identity/listener (T02/T04), human review/binding handoff and
broader authoring UI (T03/T05), full acceptance scenarios, immutable release and
rollback packet (T06), separately approved live rollout (T07). Source metadata
entry is implemented; automated fetching/retention is not. No deployment occurred.
Delivery and activation contract: `docs/specs/LifecycleWorkspaceDelivery.md`.