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:
parent
180370ed44
commit
98c8de078e
10 changed files with 85 additions and 33 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>')
|
||||
|
|
|
|||
2
tests/fixtures/review-policy/pin.json
vendored
2
tests/fixtures/review-policy/pin.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"package": "informed-decision.fixture",
|
||||
"version": "v1",
|
||||
"package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a",
|
||||
"package_digest": "sha256:f45e366d3a07430a5171716dc9b970dd97b2b1cfa68cfda5a042416f7a277236",
|
||||
"fixture_only": true
|
||||
}
|
||||
|
|
|
|||
4
tests/fixtures/review-policy/policy.md
vendored
4
tests/fixtures/review-policy/policy.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue