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 = ()
# 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")

View file

@ -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 "