Ask Flex Auth list, not read, for each overview row (INFD-WP-0004-T03)

The overview now requests the new list action and its rows carry only the
memo id, version, question, live approval status and the person's own
responses without notes. Opening a memo still asks read; every act asks
its own action. The fixture Flex Auth package admits list under a
re-derived pin. Not deployable until flex-auth answers INFD-IN-0008.

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:53:31 +02:00
parent 180370ed44
commit 98c8de078e
10 changed files with 85 additions and 33 deletions

View file

@ -3,7 +3,7 @@
**From:** informed-decision (`INFD-WP-0004`, intake `INFD-IN-0008`)
**To:** flex-auth, as the owner of the `informed-decision.*` policy packages
**Date:** 2026-09-21
**Status:** draft, not yet sent
**Status:** sent 2026-09-21 to the flex-auth inbox (State Hub message `a7b9ef3b-f95c-4c50-bfc5-7628d16881f6`)
## What we ask

View file

@ -61,8 +61,13 @@ mandate from membership, the named recipient match or a successful login.
The assurance object above is illustrative; `build_request` carries the verified
issuer object without synthesizing stronger facts. Supported actions are
`read`, `acknowledge`, `accept`, `return`, `discuss`, `decline`. Read covers memo
rendering, original presentation retrieval and packet download. Other verbs
`read`, `list`, `acknowledge`, `accept`, `return`, `discuss`, `decline`. Read covers memo
rendering, original presentation retrieval and packet download. `list` covers only
one row of the signed-in person's decision overview (`INFD-WP-0004`): the memo id,
version and question, the live approval status, and the person's own responses
(verb, version, time and submission state, without notes). It never covers the
brief, terms, highlights, packet or others' responses, and it never stands in for
`read` or an act. See [the list-action request](flex-auth-request-list-action.md). Other verbs
always get their own decision. Only accept can cause an Approval Engine POST.
No `consume`, wildcard action, `view_hash`, presentation id, acknowledgment
state or presentation claim is supplied. This prevents the renderer's evidence

View file

@ -99,9 +99,11 @@ responses on every version.
The overview is not an inbox and not approval state:
- **It checks permission per row.** Each row gets its own fresh Flex Auth
`read` decision before its question is shown. It is the same check the
review uses, and each decision is stored as a policy observation. A refused
row does not call Approval Engine.
`list` decision before its question is shown, and each decision is stored as
a policy observation (`INFD-WP-0004`). `list` is not `read`: opening the
memo still asks `read`, and each act asks its own action. A refused row does
not call Approval Engine. Until the served package answers `list`, every row
shows as Not available.
- **It reads Approval Engine by id only.** The overview looks up the approval
id the memo already carries. It never polls for work. The status is shown
live and never stored. An unavailable engine, or an act digest that no longer

View file

@ -16,7 +16,7 @@ from .http_transport import JSONTransport, TransportError, fixed_origin
from .oidc import HumanSession
DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
ACTIONS = frozenset({"read", "acknowledge", "accept", "return", "discuss", "decline"})
ACTIONS = frozenset({"read", "list", "acknowledge", "accept", "return", "discuss", "decline"})
CONTRACT = "flex-auth.decision-record.v1"

View file

@ -39,13 +39,15 @@ OPEN_STATUSES = ("requested", "approved")
@dataclass(frozen=True)
class OverviewRow:
"""Only what a `list` allow may disclose: never brief, packet, highlights or notes."""
memo_id: str
group: str
memo: object = None
question: str | None = None
version: int | None = None
engine_status: str | None = None
reason: str | None = None
intent: dict | None = None
history: tuple = ()
approved_at: str | None = None
history: tuple = () # ({"verb", "memo_version", "at", "state"}, ...), the person's own
class ReviewController:
@ -142,8 +144,9 @@ class ReviewController:
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
Every row passes its own fresh PDP `list` decision before its question
is returned; a refused or failed row keeps only its id. `list` never
stands in for `read`: opening or acting on a memo asks for its own. Engine status is
read live by the approval id the memo already carries never a poll.
"""
self._session(session)
@ -152,7 +155,7 @@ class ReviewController:
def _overview_row(self, session, memo):
try:
_, observation = self._authorize(session, memo, "read")
_, observation = self._authorize(session, memo, "list")
except ReviewError as exc:
if exc.code == "session_expired":
raise
@ -188,7 +191,10 @@ class ReviewController:
group = "open"
else:
group = "closed"
return OverviewRow(memo.id, group, memo, status, reason, intent, history)
return OverviewRow(memo.id, group, memo.question, memo.version, status, reason,
intent.get("approved_at") if intent else None,
tuple({"verb": d.verb.value, "memo_version": d.memo_version, "at": d.at,
"state": s["state"] if s else None} for d, s in history))
def acknowledge(self, session, presentation_id, highlight_ids):
memo, p = self._presentation(session, presentation_id, current=True)

View file

@ -143,20 +143,20 @@ OVERVIEW_REASONS = {
def _overview_row(row):
link = "/review?memo_id=" + quote(row.memo_id, safe="")
if row.memo is None:
if row.question is None:
reason = OVERVIEW_REASONS.get(row.reason, "The permission check could not complete. Reload later.")
return (f'<div class="record"><strong>{text(row.memo_id)}</strong>'
f'<p class="muted">{text(reason)}</p></div>')
facts = [f'Memo {text(row.memo_id)} · version {row.memo.version}']
facts = [f'Memo {text(row.memo_id)} · version {row.version}']
if row.engine_status:
facts.append(f'Approval status: {text(row.engine_status)}')
elif row.reason:
facts.append(text(OVERVIEW_REASONS.get(row.reason, "Approval status is unavailable.")))
if row.intent and row.intent.get("approved_at"):
facts.append(f'Entry recorded {text(row.intent["approved_at"])}')
history = ''.join(f'<li>{text(d.verb.value.capitalize())} · version {d.memo_version} · {text(d.at)}'
+ (f' · {text(s["state"])}' if s else '') + '</li>' for d, s in row.history)
return (f'<div class="record"><a href="{link}"><strong>{text(row.memo.question)}</strong></a>'
if row.approved_at:
facts.append(f'Entry recorded {text(row.approved_at)}')
history = ''.join(f'<li>{text(h["verb"].capitalize())} · version {h["memo_version"]} · {text(h["at"])}'
+ (f' · {text(h["state"])}' if h["state"] else '') + '</li>' for h in row.history)
return (f'<div class="record"><a href="{link}"><strong>{text(row.question)}</strong></a>'
f'<p class="muted">{" · ".join(facts)}</p>'
+ (f'<details><summary>Your responses</summary><ul>{history}</ul></details>' if history else '')
+ '</div>')

View file

@ -1,6 +1,6 @@
{
"package": "informed-decision.fixture",
"version": "v1",
"package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a",
"package_digest": "sha256:f45e366d3a07430a5171716dc9b970dd97b2b1cfa68cfda5a042416f7a277236",
"fixture_only": true
}

View file

@ -6,7 +6,7 @@ version: v1
status: ready
package: flexauth.informed_decision.fixture
allow_ttl: 60s
actions: [read, acknowledge, accept, return, discuss, decline]
actions: [read, list, acknowledge, accept, return, discuss, decline]
owner: fixture-only
fixtures: [fixtures.json]
caring:
@ -32,7 +32,7 @@ decision := {"effect": "allow", "reason": "synthetic_review_fixture"} if {
input.resource.system == "informed-decision"
input.resource.type == "decision-memo"
input.resource.id == "memo:memo-1"
input.action in {"read", "acknowledge", "accept", "return", "discuss", "decline"}
input.action in {"read", "list", "acknowledge", "accept", "return", "discuss", "decline"}
input.context.approval_id == "fixture"
} else := {"effect": "deny", "reason": "fixture_scope_refused"} if {
true

View file

@ -308,7 +308,7 @@ def test_overview_classifies_without_presenting_or_binding(review):
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==()
assert rows[0].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()))
@ -320,9 +320,9 @@ def test_overview_classifies_without_presenting_or_binding(review):
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'].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]
assert [h['verb'] for h in rows['memo-declined'].history]==['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'
@ -333,7 +333,7 @@ def test_overview_redacts_rows_the_pdp_does_not_allow(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 (row.memo_id,row.group,row.reason,row.question,row.history)==('memo-1','unavailable','policy_denied',None,())
assert len(transport.calls)==before
@ -345,7 +345,7 @@ def test_overview_degrades_one_row_when_engine_is_unreachable(review):
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
assert row.question==memo.question
transport.request=original
@ -415,3 +415,38 @@ def test_home_keeps_the_memo_form_when_the_overview_fails(review,monkeypatch):
monkeypatch.setattr(controller.store,'memos_for',broken)
r=home(review)
assert r['status']==200 and 'could not be listed right now' in r['body'] and 'Open a decision review' in r['body']
def only(action,effect='deny'):
return lambda d:d.update(effect=effect) if d['binding']['action']==action else None
def test_overview_asks_list_and_a_list_allow_never_opens_the_memo(review):
controller,session,app,memo,engine,transport=review
controller.policy.transport.change=only('read')
r=home(review)
assert 'Open for you (1)' in r['body'] and 'Approve the synthetic factory delivery?' in r['body']
assert [json.loads(o['request'])['action'] for o in controller.store.policy_observations()]==['list']
opened_=call(app,'/review',query='memo_id=memo-1',cookie=SESSION_COOKIE+'=fixture-session')
assert opened_['status']==403 and 'private-brief-sentinel' not in opened_['body']
assert controller.store.evidence()==[]
def test_list_row_discloses_no_brief_packet_highlight_or_note(review):
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
controller.acknowledge(session,p.id,['h-1'])
controller.act(session,p.id,Verb.DISCUSS,operation_id=str(uuid.uuid4()),note='private-note-sentinel')
controller.policy.transport.change=only('read')
[row]=controller.overview(session)
assert row.group=='returned' and row.history[0]['verb']=='discuss' and 'note' not in row.history[0]
body=home(review)['body']
for sentinel in ('private-brief-sentinel','private-packet-sentinel','private-note-sentinel',
'Disposable test target','Full fixture terms'):
assert sentinel not in body
def test_a_list_deny_redacts_even_when_read_would_allow(review):
controller,session,*_=review
controller.policy.transport.change=only('list')
[row]=controller.overview(session)
assert (row.group,row.question,row.reason)==('unavailable',None,'policy_denied')

View file

@ -61,21 +61,21 @@ regression and needs fixing regardless of `list`. Record the finding under
```task
id: INFD-WP-0004-T02
status: progress
status: wait
priority: high
state_hub_task_id: "3d5f51ac-c226-5489-bfb6-d5829c70e6f6"
```
The request is drafted in `docs/flex-auth-request-list-action.md` and recorded
as `INFD-IN-0008`, owned by flex-auth. Send it to the flex-auth inbox once the
founder approves the draft. Done when flex-auth publishes a package, version
as `INFD-IN-0008`, owned by flex-auth. Sent 2026-09-21 to the flex-auth inbox
(message `a7b9ef3b-f95c-4c50-bfc5-7628d16881f6`); waiting on flex-auth. Done when flex-auth publishes a package, version
and digest that answers `list`, with fixtures.
## Consumer side: `list` action and limited-disclosure overview
```task
id: INFD-WP-0004-T03
status: todo
status: done
priority: high
state_hub_task_id: "e13ef7d2-bac0-5e2b-930d-4368e77bf1cb"
```
@ -93,6 +93,10 @@ This task can be built and tested against fixtures before T02 lands, but it
must not be deployed before T02. The live v2 package denies `list`, so
deploying early would change nothing visible.
**Done 2026-09-21:** the source is ready and not deployed. There are 436
tests. The fixture Flex Auth package adds `list`, and its re-derived pin has
digest `sha256:f45e366d…`. Chromium passes 13 of 13 through the real Flex Auth.
## Admit and roll out
```task