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:
parent
1e94835c07
commit
f0a9bb1d4c
5 changed files with 208 additions and 3 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
|
|
@ -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) == []
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue