From b2a4b863a8c064719d89a6439bf7e0e2076a63f5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 4 Oct 2026 18:25:21 +0200 Subject: [PATCH] Add protected headless lifecycle application service Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f --- SCOPE.md | 8 +- docs/specs/LifecycleFoundationContract.md | 7 +- docs/specs/LifecycleServiceContract.md | 90 +++++++++ informed_decision/lifecycle.py | 19 +- informed_decision/lifecycle_service.py | 142 +++++++++++++++ tests/test_lifecycle_service.py | 171 ++++++++++++++++++ ...INFD-WP-0007-decision-lifecycle-service.md | 14 ++ 7 files changed, 439 insertions(+), 12 deletions(-) create mode 100644 docs/specs/LifecycleServiceContract.md create mode 100644 informed_decision/lifecycle_service.py create mode 100644 tests/test_lifecycle_service.py diff --git a/SCOPE.md b/SCOPE.md index bcf3d1f..fac43f6 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -15,8 +15,12 @@ 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; external delivery and authenticated API exposure -are not implemented. See `docs/specs/LifecycleFoundationContract.md`. Frictionless intake has local implementation +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. 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. diff --git a/docs/specs/LifecycleFoundationContract.md b/docs/specs/LifecycleFoundationContract.md index e9750ca..f15ad7d 100644 --- a/docs/specs/LifecycleFoundationContract.md +++ b/docs/specs/LifecycleFoundationContract.md @@ -1,7 +1,8 @@ # Lifecycle foundation: first implementation contract 2026-10-04; `INFD-WP-0007-T02/T03`. This is the internal persistence slice of -`DecisionLifecycleService.md`, not a public API or completed application service. +`DecisionLifecycleService.md`. The protected application interface built on this +slice is documented in `LifecycleServiceContract.md`; HTTP exposure is pending. ## Implemented boundary @@ -13,7 +14,7 @@ conversion of legacy/approval-bound records remain future work. Each call carries `Attribution(tenant, Actor(subject, kind))` from its trusted caller. This is audit attribution, not an authentication assertion. Like `Store`, -the repository is an internal persistence interface: the future application/API +the repository is an internal persistence interface: the application/API layer must authenticate callers and enforce current access-engine authorization before invoking it. Supplying a tenant string is not an access grant. No route or runtime configuration exposes these operations in this increment. @@ -84,7 +85,7 @@ The existing readiness check does not account for it yet. ## Remaining contracts -T02 remains in progress: authenticated service ports, versioned HTTP schemas, +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 and non-approval binding integration. T03 is started with this persistence slice; diff --git a/docs/specs/LifecycleServiceContract.md b/docs/specs/LifecycleServiceContract.md new file mode 100644 index 0000000..9aae419 --- /dev/null +++ b/docs/specs/LifecycleServiceContract.md @@ -0,0 +1,90 @@ +# Protected lifecycle application service + +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. + +## Caller and authority + +The identity adapter supplies an immutable `Caller`: tenant, actor subject/kind, +finite expiry and JSON identity attributes. This is trusted in-process context, +not a client-controlled payload or credential. `Caller.from_human_session` +imports the existing verified KeyCape session, preserving tenant provenance, +authentication-derived human identity, assurance, roles and groups. It carries +neither the browser token nor CSRF material into policy requests or stored events. + +Agent adapters must authenticate the workload before constructing Caller. This +increment does not add an issuer, infer a tenant from a request body, treat a +claimed agent name as authentication, or authorize a new workload audience. +The existing intake TokenReview audience is not silently reused for this service. +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; +review and human disposition contracts remain unchanged. + +The request carries the authenticated subject, tenant, resource `memo:`, +system `informed-decision`, type `decision-memo`, exact configured policy pin, +and context: + +* `contract`: `informed-decision.lifecycle.v1`; +* `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. + +The command digest correlates the requested operation; it is not `view_hash`, +an approval binding digest or an authorization verdict. No presentation evidence +is supplied as an input to the decision it presents for. Advisory content is not +sent to the PDP in clear text. Identity attributes are supplied by trusted +adapters, not copied from memo content. + +## Freshness, replay and evidence + +Every operation, including an idempotent retry, obtains a new policy observation +and persists it using the existing Store. A deny, expired or mismatched response, +unsupported obligation or unavailable policy service prevents the operation. +The existing PolicyClient validates resource/subject/context, request digests, +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 +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. + +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 +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. + +## 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 +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. + +Before enabling a listener, T02/T04 must provide: reviewed lifecycle PDP package +and action/context fixtures; admitted human/workload identity adapters; closed +HTTP schemas with canonical URLs and request limits; service admission and +publication audience contracts; lifecycle audit receiver admission and readiness +accounting. Test fixtures exercise the real PolicyClient against synthetic policy +transport. They are not evidence of a deployed lifecycle policy grant. diff --git a/informed_decision/lifecycle.py b/informed_decision/lifecycle.py index ca5c319..1157431 100644 --- a/informed_decision/lifecycle.py +++ b/informed_decision/lifecycle.py @@ -90,8 +90,10 @@ class LifecycleRepository: return None @staticmethod - def _append(db, scope, digest, memo, kind, body): + def _append(db, scope, digest, memo, kind, body, policy_id=None): event_id = str(uuid.uuid4()) + if policy_id is not None: + body = {**body, "policy_observation_id": policy_id} db.execute("INSERT INTO lifecycle_events " "(id,tenant,memo_id,version,kind,actor,at,body) VALUES (?,?,?,?,?,?,?,?)", (event_id, scope[0], memo.id, memo.version, kind, scope[1], _now(), dumps(body))) @@ -99,12 +101,13 @@ class LifecycleRepository: db.execute("INSERT INTO lifecycle_operations VALUES (?,?,?,?,?,?)", (*scope, digest, event_id)) return event_id - def register(self, context: Attribution, memo: Memo, *, key: str) -> str: + def register(self, context: Attribution, memo: Memo, *, key: str, guard=None) -> str: """Register a complete, unbound version-one memo without creating approval.""" if memo.version != 1 or memo.sealed or memo.approval_id is not None: raise ValueError("registration requires an unbound version-one memo") scope, digest = self._request(context, "register", key, memo) with self.store._transaction() as db: + policy_id = guard(db) if guard is not None else None replay = self._replay(db, scope, digest) if replay: return replay @@ -113,10 +116,10 @@ class LifecycleRepository: raise Conflict("memo identifier already exists") self.store._save_memo(db, memo) db.execute("INSERT INTO lifecycle_records VALUES (?,?,?)", (memo.id, context.tenant, _now())) - return self._append(db, scope, digest, memo, "registered", {"memo_version": memo.version}) + return self._append(db, scope, digest, memo, "registered", {"memo_version": memo.version}, policy_id) def revise(self, context: Attribution, memo: Memo, *, expected_version: int, - summary: str, key: str) -> str: + summary: str, key: str, guard=None) -> str: """Compare-and-append; retain existing Store guards on outstanding entries.""" if type(expected_version) is not int or expected_version < 1: raise ValueError("expected version must be a positive integer") @@ -127,6 +130,7 @@ class LifecycleRepository: scope, digest = self._request(context, "revise", key, {"memo": memo, "expected_version": expected_version, "summary": summary}) 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: @@ -137,10 +141,10 @@ class LifecycleRepository: if current.sealed or current.approval_id is not None: raise Conflict("bound memo requires the review integration path") self.store._save_memo(db, memo) - return self._append(db, scope, digest, memo, "revised", {"summary": summary}) + return self._append(db, scope, digest, memo, "revised", {"summary": summary}, policy_id) def annotate(self, context: Attribution, memo_id: str, version: int, *, kind: str, - body: dict, key: str) -> str: + body: dict, key: str, guard=None) -> str: """Append a source, advisory evaluation or discussion; never a disposition.""" self._validate_annotation(kind, body) if type(version) is not int or version < 1: @@ -148,6 +152,7 @@ class LifecycleRepository: scope, digest = self._request(context, kind, key, {"memo_id": memo_id, "version": version, "body": body}) 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: @@ -159,7 +164,7 @@ class LifecycleRepository: "AND memo_id=? AND version=? AND kind='source'", (source, context.tenant, memo_id, version)).fetchone(): raise EvidenceUnavailable("evaluation source unavailable for this revision") - return self._append(db, scope, digest, memo, kind, body) + return self._append(db, scope, digest, memo, kind, body, policy_id) @staticmethod def _validate_annotation(kind, body): diff --git a/informed_decision/lifecycle_service.py b/informed_decision/lifecycle_service.py new file mode 100644 index 0000000..3d68a07 --- /dev/null +++ b/informed_decision/lifecycle_service.py @@ -0,0 +1,142 @@ +"""Headless application service consuming fresh, exact external policy decisions. + +Identity adapters supply Caller after authentication; no request body can mint +one. Runtime composition and lifecycle policy admission remain separate work. +""" + +from dataclasses import dataclass +import hashlib +import json +import math +import time +import uuid + +from .disposition import Actor, ActorKind +from .lifecycle import Attribution, LifecycleRepository +from .oidc import HumanSession +from .policy import PolicyError +from .provenance import Route +from .records import dumps, memo_from +from .store import _text + + +@dataclass(frozen=True) +class Caller: + attribution: Attribution + expires_at: float + attributes_json: str + + def __post_init__(self): + if (type(self.expires_at) not in (int, float) or not math.isfinite(self.expires_at) + or not isinstance(self.attributes_json, str) or len(self.attributes_json) > 16384): + raise ValueError("invalid authenticated caller") + attributes = json.loads(self.attributes_json) + if not isinstance(attributes, dict): + raise ValueError("invalid caller attributes") + + @classmethod + def from_human_session(cls, session: HumanSession): + if (not isinstance(session, HumanSession) + or session.principal_type.value != "human" + or session.principal_type.route is not Route.AUTHENTICATION + or session.tenant.route not in (Route.DIRECTORY, Route.REGISTRATION)): + raise ValueError("verified human identity required") + return cls(Attribution(session.tenant.value, Actor(session.subject, ActorKind.PERSON)), + session.expires_at, dumps({ + "tenant_source": session.tenant.route.value, + "principal_type_source": session.principal_type.route.value, + "assurance": session.assurance, + "roles": list(session.roles), "groups": list(session.groups)})) + + +class LifecycleError(RuntimeError): + def __init__(self, status, code): + self.status, self.code = status, code + super().__init__(code) + + +class LifecycleService: + def __init__(self, repository: LifecycleRepository, policy, *, clock=time.time): + self.repository, self.policy, self.clock = repository, policy, clock + + def _caller(self, caller): + if not isinstance(caller, Caller) or caller.expires_at <= self.clock(): + raise LifecycleError(401, "identity_expired") + + def _authorize(self, caller, memo_id, operation, payload): + self._caller(caller) + _text(memo_id, "memo id") + actor = caller.attribution.actor + tenant = caller.attribution.tenant + command = dumps(payload).encode() + if len(command) > 256 * 1024: + raise ValueError("lifecycle command too large") + 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", + "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"), + "command_digest": "sha256:" + hashlib.sha256(command).hexdigest()}, + "policy_version": self.policy.version} + observation = self.policy.check(request) + # Defend the application boundary even with an incorrectly composed client. + if json.loads(observation.request_json) != request: + raise LifecycleError(503, "policy_request_mismatch") + policy_id = self.repository.store.observe_policy(observation) + + def current(): + self._caller(caller) + try: + observation.require_current(self.clock()) + except PolicyError: + raise LifecycleError(403 if observation.outcome == "policy_denied" else 503, + observation.outcome if observation.outcome != "allow" else "decision_expired") from None + + def guard(db): + current() + recorded = db.execute("SELECT request,outcome,usable_until FROM policy_observations WHERE id=?", + (policy_id,)).fetchone() + 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") + return policy_id + + current() + return current, guard + + def register(self, caller, memo, *, key): + # Snapshot before the PDP call: mutable caller-owned objects cannot change + # the command between authorization and persistence. + memo = memo_from(json.loads(dumps(memo))) + _, guard = self._authorize(caller, memo.id, "register", {"memo": memo, "key": key}) + return self.repository.register(caller.attribution, memo, key=key, guard=guard) + + def revise(self, caller, memo, *, expected_version, summary, key): + memo = memo_from(json.loads(dumps(memo))) + _, guard = self._authorize(caller, memo.id, "revise", { + "memo": memo, "expected_version": expected_version, "summary": summary, "key": key}) + return self.repository.revise(caller.attribution, memo, expected_version=expected_version, + summary=summary, key=key, guard=guard) + + def annotate(self, caller, memo_id, version, *, kind, body, key): + body = json.loads(dumps(body)) + self.repository._validate_annotation(kind, body) + _, guard = self._authorize(caller, memo_id, kind, { + "memo_id": memo_id, "version": version, "body": body, "key": key}) + return self.repository.annotate(caller.attribution, memo_id, version, kind=kind, + body=body, key=key, guard=guard) + + def retrieve(self, caller, memo_id, version=None): + current, _ = self._authorize(caller, memo_id, "read", {"version": version}) + result = self.repository.retrieve(caller.attribution.tenant, memo_id, version) + current() + return result + + 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) + current() + return result diff --git a/tests/test_lifecycle_service.py b/tests/test_lifecycle_service.py new file mode 100644 index 0000000..b0041c3 --- /dev/null +++ b/tests/test_lifecycle_service.py @@ -0,0 +1,171 @@ +from dataclasses import replace +import json +import time + +import pytest + +from informed_decision.lifecycle_service import Caller, LifecycleError, LifecycleService +from informed_decision.policy import Observation +from informed_decision.provenance import Claim, Route +from informed_decision.store import EvidenceUnavailable +from test_lifecycle import AGENT, workspace +from test_policy_client import human, policy, PolicyTransport + + +def caller(): + return Caller(AGENT, time.time() + 600, '{"principal_type_source":"authentication-derived"}') + + +def service(workspace, transport=None, **kwargs): + repo, memo = workspace + return LifecycleService(repo, policy(transport), **kwargs), memo + + +def test_agent_and_human_use_shared_service_and_replay_requires_new_decision(workspace): + app, memo = service(workspace) + agent = caller() + event = app.register(agent, memo, key="new") + assert app.register(agent, memo, key="new") == event + assert len(app.policy.transport.calls) == 2 + person = Caller.from_human_session(replace(human(), tenant=Claim(AGENT.tenant, Route.DIRECTORY))) + app.annotate(person, memo.id, 1, kind="comment", body={"text": "Consider alternative B"}, key="comment") + app.revise(agent, memo.next_version(brief="Alternative B assessed"), expected_version=1, + summary="Addressed review", key="revise") + assert app.retrieve(person, memo.id).version == 2 + events = app.events(agent, memo.id) + assert len(events) == 3 + assert events[1]["actor"]["kind"] == "person" + observations = app.repository.store.policy_observations() + assert all(row["decision_attributable"] == 0 for row in observations) + identifiers = {row["id"] for row in observations} + assert all(event["body"]["policy_observation_id"] in identifiers for event in events) + request = app.policy.transport.calls[0][3] + assert request["action"] == "lifecycle.register" + assert request["subject"]["type"] == "agent" + assert request["resource"]["tenant"] == AGENT.tenant + assert "view_hash" not in json.dumps(request) + assert "synthetic-browser-token" not in json.dumps(observations) + + +@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"]) +def test_no_nonallow_effect_can_register(workspace, effect): + transport = PolicyTransport() + transport.change = lambda data: data.update(effect=effect) + app, memo = service(workspace, transport) + with pytest.raises(LifecycleError): + app.register(caller(), memo, key="new") + with pytest.raises(EvidenceUnavailable): + app.repository.retrieve(AGENT.tenant, memo.id) + assert len(app.repository.store.policy_observations()) == 1 + + +@pytest.mark.parametrize("mutate", [ + lambda data: data["binding"].update(action="read"), + lambda data: data["binding"]["context"].update(command_digest="sha256:" + "0" * 64), + lambda data: data["provenance"].update(policy_package="unadmitted"), + lambda data: data.update(obligations=[{"type": "unknown"}]), +]) +def test_policy_response_must_match_exact_lifecycle_request(workspace, mutate): + transport = PolicyTransport() + transport.change = mutate + app, memo = service(workspace, transport) + with pytest.raises(LifecycleError): + app.register(caller(), memo, key="new") + with pytest.raises(EvidenceUnavailable): + app.repository.store.memo(memo.id) + + +def test_revoked_permission_denies_replay_and_existing_and_missing_reads_equally(workspace): + app, memo = service(workspace) + agent = caller() + app.register(agent, memo, key="new") + app.policy.transport.change = lambda data: data.update(effect="deny") + operations = [lambda: app.register(agent, memo, key="new"), + lambda: app.retrieve(agent, memo.id), lambda: app.retrieve(agent, "missing"), + lambda: app.events(agent, memo.id), + lambda: app.annotate(agent, memo.id, 1, kind="comment", body={"text": "No"}, key="no")] + for operation in operations: + with pytest.raises(LifecycleError) as error: + operation() + assert error.value.status == 403 + assert len(app.repository.events(AGENT.tenant, memo.id)) == 1 + + +def test_identity_expiry_refuses_before_policy_call(workspace): + app, memo = service(workspace) + with pytest.raises(LifecycleError) as error: + app.register(replace(caller(), expires_at=1), memo, key="new") + assert error.value.status == 401 + assert app.policy.transport.calls == [] + + +@pytest.mark.parametrize("expiry", ["identity", "policy"]) +def test_expiry_while_waiting_for_write_transaction_refuses_mutation(workspace, monkeypatch, expiry): + now = [time.time()] + app, memo = service(workspace, clock=lambda: now[0]) + agent = replace(caller(), expires_at=now[0] + (10 if expiry == "identity" else 600)) + original = app.repository.register + + def delayed(*args, **kwargs): + now[0] += 40 + return original(*args, **kwargs) + monkeypatch.setattr(app.repository, "register", delayed) + with pytest.raises(LifecycleError) as error: + app.register(agent, memo, key="new") + assert error.value.code == ("identity_expired" if expiry == "identity" else "decision_expired") + with pytest.raises(EvidenceUnavailable): + app.repository.store.memo(memo.id) + + +def test_command_is_snapshotted_before_external_policy_call(workspace): + app, memo = service(workspace) + agent = caller() + app.register(agent, memo, key="new") + body = {"text": "authorized content"} + app.policy.transport.change = lambda data: body.update(text="changed during network call") + app.annotate(agent, memo.id, 1, kind="comment", body=body, key="comment") + event = app.repository.events(AGENT.tenant, memo.id)[1] + assert event["body"]["text"] == "authorized content" + + +def test_read_checks_expiry_after_loading_content(workspace, monkeypatch): + now = [time.time()] + app, memo = service(workspace, clock=lambda: now[0]) + agent = caller() + app.register(agent, memo, key="new") + original = app.repository.retrieve + def delayed(*args, **kwargs): + result = original(*args, **kwargs) + now[0] += 40 + return result + monkeypatch.setattr(app.repository, "retrieve", delayed) + with pytest.raises(LifecycleError, match="decision_expired"): + app.retrieve(agent, memo.id) + + +def test_observation_from_another_request_is_refused(workspace, monkeypatch): + app, memo = service(workspace) + monkeypatch.setattr(app.policy, "check", lambda req: Observation('{}', None, "allow", time.time()+60, None)) + with pytest.raises(LifecycleError, match="policy_request_mismatch"): + app.register(caller(), memo, key="new") + assert app.repository.store.policy_observations() == [] + + +def test_missing_persisted_authorization_prevents_write(workspace, monkeypatch): + app, memo = service(workspace) + monkeypatch.setattr(app.repository.store, "observe_policy", lambda observed: "not-persisted") + with pytest.raises(LifecycleError, match="policy_observation_unavailable"): + app.register(caller(), memo, key="new") + with pytest.raises(EvidenceUnavailable): + app.repository.store.memo(memo.id) + + +@pytest.mark.parametrize("expiry", [float("nan"), float("inf"), True]) +def test_unbounded_identity_lifetime_refused(expiry): + with pytest.raises(ValueError): + replace(caller(), expires_at=expiry) + + +def test_human_identity_must_be_authentication_derived(): + with pytest.raises(ValueError): + Caller.from_human_session(replace(human(), principal_type=Claim("human", Route.REGISTRATION))) diff --git a/workplans/INFD-WP-0007-decision-lifecycle-service.md b/workplans/INFD-WP-0007-decision-lifecycle-service.md index 48afc71..b6df56c 100644 --- a/workplans/INFD-WP-0007-decision-lifecycle-service.md +++ b/workplans/INFD-WP-0007-decision-lifecycle-service.md @@ -149,3 +149,17 @@ No production route or deployment was changed. Validation: `uv run --extra dev python -m pytest -q` — 480 passed, 72 skipped. The system interpreter had dependency-related failures; the project-managed environment passes. `git diff --check` passes. + +## Protected application service — 2026-10-04 + +Added `LifecycleService` above the repository: verified human-session import, +trusted workload-caller interface, fresh PolicyClient observations for every +operation/retry, immutable command snapshots, transaction-time authorization +guards and post-read expiry checks. Mutation events link the retained policy +observation. Existing human disposition and policy action contracts are unchanged. +`docs/specs/LifecycleServiceContract.md` records the proposed lifecycle action +names/context and remaining production identity/policy/HTTP/audit admission work. +T02/T03 remain in progress; no listener or policy grant was deployed. + +Validation: `uv run --extra dev python -m pytest -q` — 501 passed, 72 skipped; +`git diff --check` passed.