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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 359683@bnt-lap001
Assistant-Session: eebdc939-7a9b-4e50-9d39-c8437e8a14ec
This commit is contained in:
tegwick 2026-09-21 22:02:21 +02:00
parent 1e94835c07
commit f0a9bb1d4c
5 changed files with 208 additions and 3 deletions

View file

@ -32,6 +32,22 @@ class ReviewPage:
dispositions: tuple = () 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: class ReviewController:
def __init__(self, store, policy, approval_factory, *, clock=time.time): def __init__(self, store, policy, approval_factory, *, clock=time.time):
self.store, self.policy, self.approval_factory = store, policy, approval_factory 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"], self.store.memo(memo.id).version != memo.version, current["status"],
tuple(self.store.dispositions_for(p.id))) 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): def acknowledge(self, session, presentation_id, highlight_ids):
memo, p = self._presentation(session, presentation_id, current=True) memo, p = self._presentation(session, presentation_id, current=True)
policy_id, observation = self._authorize(session, memo, "acknowledge") policy_id, observation = self._authorize(session, memo, "acknowledge")

View file

@ -386,6 +386,28 @@ class Store:
return [disposition_from(json.loads(r[0])) for r in db.execute( return [disposition_from(json.loads(r[0])) for r in db.execute(
"SELECT body FROM dispositions WHERE presentation_id=? ORDER BY rowid", (presentation_id,))] "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): def presentation_content(self, presentation_id):
with self._connection() as db: with self._connection() as db:
row = db.execute("SELECT content FROM evidence WHERE class=? AND " row = db.execute("SELECT content FROM evidence WHERE class=? AND "

View file

@ -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] 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 {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) 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) == []

View file

@ -1,5 +1,6 @@
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timedelta, timezone
import json import json
import sqlite3 import sqlite3
import time 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 'Enter the memo identifier only' in response['body']
assert not controller.store.policy_observations() assert not controller.store.policy_observations()
assert not engine.get('fixture').entries 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'

View file

@ -4,7 +4,7 @@ type: workplan
title: "My decisions overview — pending memos and the outcome of past dispositions" title: "My decisions overview — pending memos and the outcome of past dispositions"
domain: infotech domain: infotech
repo: informed-decision repo: informed-decision
status: proposed status: active
owner: claude owner: claude
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-09-21" created: "2026-09-21"
@ -63,7 +63,7 @@ dispositions on older memo versions appear as a short history under the row.
```task ```task
id: INFD-WP-0003-T01 id: INFD-WP-0003-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "2b878f29-492a-5187-a594-36a12aaa932d" state_hub_task_id: "2b878f29-492a-5187-a594-36a12aaa932d"
``` ```
@ -78,7 +78,7 @@ read-only. Tests go in `tests/test_durable_store.py`.
```task ```task
id: INFD-WP-0003-T02 id: INFD-WP-0003-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "b4172f0c-7cd3-5780-bf50-62670df7e416" state_hub_task_id: "b4172f0c-7cd3-5780-bf50-62670df7e416"
``` ```