From f0a9bb1d4c7e9a26c1538289a58f7b982a739138 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 21 Sep 2026 22:02:21 +0200 Subject: [PATCH] Add the my-decisions overview store queries and controller (INFD-WP-0003 T01, T02) Store.memos_for / dispositions_by list the signed-in person's memos and their own dispositions. ReviewController.overview classifies each row after its own fresh PDP read and a live get-by-id engine status; refused rows keep only the memo id, and no presentation is created. Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 359683@bnt-lap001 Assistant-Session: eebdc939-7a9b-4e50-9d39-c8437e8a14ec --- informed_decision/review.py | 67 ++++++++++++++ informed_decision/store.py | 22 +++++ tests/test_durable_store.py | 25 +++++ tests/test_review_controller.py | 91 +++++++++++++++++++ .../INFD-WP-0003-my-decisions-overview.md | 6 +- 5 files changed, 208 insertions(+), 3 deletions(-) diff --git a/informed_decision/review.py b/informed_decision/review.py index 503e2d4..8b05f7e 100644 --- a/informed_decision/review.py +++ b/informed_decision/review.py @@ -32,6 +32,22 @@ class ReviewPage: dispositions: tuple = () +# Overview groups, in display order. "unavailable" rows carry the memo id only. +GROUPS = ("attention", "open", "accepted", "declined", "returned", "closed", "unavailable") +OPEN_STATUSES = ("requested", "approved") + + +@dataclass(frozen=True) +class OverviewRow: + memo_id: str + group: str + memo: object = None + engine_status: str | None = None + reason: str | None = None + intent: dict | None = None + history: tuple = () + + class ReviewController: def __init__(self, store, policy, approval_factory, *, clock=time.time): self.store, self.policy, self.approval_factory = store, policy, approval_factory @@ -123,6 +139,57 @@ class ReviewController: self.store.memo(memo.id).version != memo.version, current["status"], tuple(self.store.dispositions_for(p.id))) + def overview(self, session): + """The signed-in person's memos, classified. Never presents, binds or stores engine state. + + Every row passes its own fresh PDP read before any memo content is + returned; a refused or failed row keeps only its id. Engine status is + read live by the approval id the memo already carries — never a poll. + """ + self._session(session) + rows = [self._overview_row(session, memo) for memo in self.store.memos_for(session.subject)] + return sorted(rows, key=lambda r: (GROUPS.index(r.group), r.memo_id)) + + def _overview_row(self, session, memo): + try: + _, observation = self._authorize(session, memo, "read") + except ReviewError as exc: + if exc.code == "session_expired": + raise + return OverviewRow(memo.id, "unavailable", reason=exc.code) + status, reason = None, None + try: + current = self.approval_factory(session).get_approval(memo.approval_id) + if current["binding"]["digest"] == memo.approval_binding_digest: + status = current["status"] + else: + reason = "binding_changed" + except (ApprovalEngineError, ValueError): + reason = "engine_unavailable" + try: + observation.require_current(self.clock()) + except PolicyError as exc: + return OverviewRow(memo.id, "unavailable", reason=str(exc)) + self._session(session) + intent = self.store.intent_for(memo.approval_id, session.subject) + history = tuple(self.store.dispositions_by(session.subject, memo.id)) + latest = [d.verb for d, _ in history if d.memo_version == memo.version and d.verb is not Verb.ACCEPT] + if intent and intent["state"] in ("in_flight", "unresolved"): + group = "attention" + elif intent and intent["state"] == "confirmed": + group = "accepted" + elif latest and latest[-1] is Verb.DECLINE: + group = "declined" + elif latest and latest[-1] in (Verb.RETURN, Verb.DISCUSS): + group = "returned" + elif status is None or status in OPEN_STATUSES or intent: + # Unknown engine status stays open: the review page re-checks + # before anything can be bound. A prepared intent is finishable. + group = "open" + else: + group = "closed" + return OverviewRow(memo.id, group, memo, status, reason, intent, history) + def acknowledge(self, session, presentation_id, highlight_ids): memo, p = self._presentation(session, presentation_id, current=True) policy_id, observation = self._authorize(session, memo, "acknowledge") diff --git a/informed_decision/store.py b/informed_decision/store.py index e51123a..b3ec6aa 100644 --- a/informed_decision/store.py +++ b/informed_decision/store.py @@ -386,6 +386,28 @@ class Store: return [disposition_from(json.loads(r[0])) for r in db.execute( "SELECT body FROM dispositions WHERE presentation_id=? ORDER BY rowid", (presentation_id,))] + def memos_for(self, subject): + """Latest version of every held memo naming ``subject`` as the person bound.""" + with self._connection() as db: + rows = db.execute("SELECT m.body FROM memos m WHERE m.version=" + "(SELECT MAX(version) FROM memos WHERE id=m.id) ORDER BY m.id").fetchall() + memos = (memo_from(json.loads(r[0])) for r in rows) + return [m for m in memos if m.binding.principal.id == subject and m.binding.principal.kind == "person"] + + def dispositions_by(self, subject, memo_id): + """``subject``'s dispositions on every version of a memo, oldest first, with any submission.""" + with self._connection() as db: + rows = db.execute("SELECT d.body,s.state,s.approved_at FROM dispositions d " + "JOIN presentations p ON p.id=d.presentation_id " + "LEFT JOIN submissions s ON s.disposition_id=d.id " + "WHERE p.memo_id=? ORDER BY d.rowid", (memo_id,)).fetchall() + result = [] + for row in rows: + d = disposition_from(json.loads(row["body"])) + if d.actor.sub == subject: + result.append((d, {"state": row["state"], "approved_at": row["approved_at"]} if row["state"] else None)) + return result + def presentation_content(self, presentation_id): with self._connection() as db: row = db.execute("SELECT content FROM evidence WHERE class=? AND " diff --git a/tests/test_durable_store.py b/tests/test_durable_store.py index a2d2ca1..a527df6 100644 --- a/tests/test_durable_store.py +++ b/tests/test_durable_store.py @@ -316,3 +316,28 @@ def test_heartbeats_do_not_mask_backlog_and_are_per_class(storage): beats = [json.loads(r["envelope"]) for r in store.evidence() if r["class"] == EventClass.HEARTBEAT.value] assert {b["data"]["class"] for b in beats} == {EventClass.DISPOSITION.value, EventClass.STANCE_APPLICATION.value} assert all(b["data"]["assertion"] == "nothing-to-report" for b in beats) + + +def test_overview_lists_latest_memos_for_subject_and_their_dispositions(storage): + store, memo = storage + mine = replace(memo, id="memo-mine", binding=replace(memo.binding, + principal=replace(memo.binding.principal, id=HUMAN.sub))) + store.save_memo(mine) + store.save_memo(replace(mine, version=2, brief="revised")) + other_kind = replace(mine, id="memo-group", binding=replace(mine.binding, + principal=replace(mine.binding.principal, kind="group"))) + store.save_memo(other_kind) + listed = store.memos_for(HUMAN.sub) + assert [(m.id, m.version) for m in listed] == [("memo-mine", 2)] + assert store.memos_for("nobody") == [] + + old = store.present("memo-mine", principal_sub=HUMAN.sub, expected_version=2, + tenant=Claim("tenant:platform", Route.REGISTRATION), principal_type=Claim("human", Route.AUTHENTICATION)) + store.acknowledge(old.id, HUMAN, ["h-1"]) + store.record_disposition(old.id, HUMAN, Verb.DISCUSS, operation_id="talk") + store.record_disposition(old.id, HUMAN, Verb.ACCEPT, operation_id="yes") + history = store.dispositions_by(HUMAN.sub, "memo-mine") + assert [(d.verb, s) for d, s in history] == [ + (Verb.DISCUSS, None), (Verb.ACCEPT, {"state": "prepared", "approved_at": None})] + assert store.dispositions_by("someone-else", "memo-mine") == [] + assert store.dispositions_by(HUMAN.sub, memo.id) == [] diff --git a/tests/test_review_controller.py b/tests/test_review_controller.py index 064e0cc..004b8e9 100644 --- a/tests/test_review_controller.py +++ b/tests/test_review_controller.py @@ -1,5 +1,6 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import replace +from datetime import datetime, timedelta, timezone import json import sqlite3 import time @@ -276,3 +277,93 @@ def test_invalid_memo_input_explains_correction_before_policy(review, query): assert 'Enter the memo identifier only' in response['body'] assert not controller.store.policy_observations() assert not engine.get('fixture').entries + + +def add_memo(review, approval_id): + controller,session,app,memo,engine,transport=review + now=datetime.now(timezone.utc) + engine.create({"actor":"synthetic-requester","principal":"factory-fixture","action":"deliver", + "purpose":"local-component-proof","target":{"resource":approval_id}}, + {"not_before":(now-timedelta(seconds=1)).isoformat(),"expires_at":(now+timedelta(minutes=10)).isoformat()}, + human_control=True,approval_id=approval_id) + digest=controller.approval_factory(session).get_approval(approval_id)['binding']['digest'] + added=replace(memo,id='memo-'+approval_id,approval_id=approval_id,approval_binding_digest=digest, + question='Question for '+approval_id) + controller.store.save_memo(added) + return added + + +def groups(controller,session): + return {r.memo_id:r.group for r in controller.overview(session)} + + +def test_overview_classifies_without_presenting_or_binding(review): + controller,session,app,memo,engine,transport=review + for name in ('declined','returned','closed'):add_memo(review,name) + other=replace(memo,id='memo-other',binding=replace(memo.binding,principal=Principal('someone-else','person','Other'))) + controller.store.save_memo(other) + before=len(controller.store.evidence()) + rows=controller.overview(session) + assert len(controller.store.evidence())==before and not any(m=='POST' for m,_ in transport.calls) + assert {r.memo_id:(r.group,r.engine_status) for r in rows}=={ + 'memo-1':('open','requested'),'memo-declined':('open','requested'), + 'memo-returned':('open','requested'),'memo-closed':('open','requested')} + assert rows[0].memo.question and rows[0].history==() + + p=opened(review).presentation;controller.acknowledge(session,p.id,['h-1']) + controller.act(session,p.id,Verb.ACCEPT,operation_id=str(uuid.uuid4())) + for name,verb in (('declined',Verb.DECLINE),('returned',Verb.RETURN)): + q=controller.open(session,'memo-'+name).presentation;controller.acknowledge(session,q.id,['h-1']) + controller.act(session,q.id,verb,operation_id=str(uuid.uuid4()), + reasons=('clarification_needed',) if verb is Verb.RETURN else ()) + engine.revoke('closed') + rows={r.memo_id:r for r in controller.overview(session)} + assert {k:r.group for k,r in rows.items()}=={'memo-1':'accepted','memo-declined':'declined', + 'memo-returned':'returned','memo-closed':'closed'} + assert rows['memo-1'].intent['state']=='confirmed' and rows['memo-1'].intent['approved_at'] + assert rows['memo-1'].engine_status=='approved' and rows['memo-closed'].engine_status=='revoked' + assert [d.verb for d,_ in rows['memo-declined'].history]==[Verb.DECLINE] + # A revision after a return reopens the question on the new version. + controller.store.save_memo(replace(controller.store.memo('memo-returned'),version=2)) + assert groups(controller,session)['memo-returned']=='open' + + +def test_overview_redacts_rows_the_pdp_does_not_allow(review): + controller,session,app,memo,engine,transport=review + controller.policy.transport.change=lambda d:d.update(effect='deny') + before=len(transport.calls) + [row]=controller.overview(session) + assert (row.memo_id,row.group,row.reason,row.memo,row.history)==('memo-1','unavailable','policy_denied',None,()) + assert len(transport.calls)==before + + +def test_overview_degrades_one_row_when_engine_is_unreachable(review): + controller,session,app,memo,engine,transport=review + original=transport.request + def down(method,url,**kwargs): + raise TransportError('down') + transport.request=down + [row]=controller.overview(session) + assert (row.group,row.engine_status,row.reason)==('open',None,'engine_unavailable') + assert row.memo.question==memo.question + transport.request=original + + +def test_overview_surfaces_unconfirmed_submission(review): + controller,session,app,memo,engine,transport=review;p=opened(review).presentation + controller.acknowledge(session,p.id,['h-1']) + original=transport.request + def lose_reply(method,url,**kwargs): + result=original(method,url,**kwargs) + if method=='POST':raise TransportError('lost response') + return result + transport.request=lose_reply + controller.act(session,p.id,Verb.ACCEPT,operation_id='lost') + assert groups(controller,session)=={'memo-1':'attention'} + + +def test_overview_requires_a_live_human_session(review): + controller,session,*_=review + with pytest.raises(ReviewError) as error: + controller.overview(replace(session,expires_at=0)) + assert error.value.code=='session_expired' diff --git a/workplans/INFD-WP-0003-my-decisions-overview.md b/workplans/INFD-WP-0003-my-decisions-overview.md index f93c874..c4817d4 100644 --- a/workplans/INFD-WP-0003-my-decisions-overview.md +++ b/workplans/INFD-WP-0003-my-decisions-overview.md @@ -4,7 +4,7 @@ type: workplan title: "My decisions overview — pending memos and the outcome of past dispositions" domain: infotech repo: informed-decision -status: proposed +status: active owner: claude topic_slug: netkingdom created: "2026-09-21" @@ -63,7 +63,7 @@ dispositions on older memo versions appear as a short history under the row. ```task id: INFD-WP-0003-T01 -status: todo +status: done priority: high state_hub_task_id: "2b878f29-492a-5187-a594-36a12aaa932d" ``` @@ -78,7 +78,7 @@ read-only. Tests go in `tests/test_durable_store.py`. ```task id: INFD-WP-0003-T02 -status: todo +status: done priority: high state_hub_task_id: "b4172f0c-7cd3-5780-bf50-62670df7e416" ```