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`)
|
**From:** informed-decision (`INFD-WP-0004`, intake `INFD-IN-0008`)
|
||||||
**To:** flex-auth, as the owner of the `informed-decision.*` policy packages
|
**To:** flex-auth, as the owner of the `informed-decision.*` policy packages
|
||||||
**Date:** 2026-09-21
|
**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
|
## 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
|
The assurance object above is illustrative; `build_request` carries the verified
|
||||||
issuer object without synthesizing stronger facts. Supported actions are
|
issuer object without synthesizing stronger facts. Supported actions are
|
||||||
`read`, `acknowledge`, `accept`, `return`, `discuss`, `decline`. Read covers memo
|
`read`, `list`, `acknowledge`, `accept`, `return`, `discuss`, `decline`. Read covers memo
|
||||||
rendering, original presentation retrieval and packet download. Other verbs
|
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.
|
always get their own decision. Only accept can cause an Approval Engine POST.
|
||||||
No `consume`, wildcard action, `view_hash`, presentation id, acknowledgment
|
No `consume`, wildcard action, `view_hash`, presentation id, acknowledgment
|
||||||
state or presentation claim is supplied. This prevents the renderer's evidence
|
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:
|
The overview is not an inbox and not approval state:
|
||||||
|
|
||||||
- **It checks permission per row.** Each row gets its own fresh Flex Auth
|
- **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
|
`list` decision before its question is shown, and each decision is stored as
|
||||||
review uses, and each decision is stored as a policy observation. A refused
|
a policy observation (`INFD-WP-0004`). `list` is not `read`: opening the
|
||||||
row does not call Approval Engine.
|
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
|
- **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
|
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
|
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
|
from .oidc import HumanSession
|
||||||
|
|
||||||
DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
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"
|
CONTRACT = "flex-auth.decision-record.v1"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,15 @@ OPEN_STATUSES = ("requested", "approved")
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OverviewRow:
|
class OverviewRow:
|
||||||
|
"""Only what a `list` allow may disclose: never brief, packet, highlights or notes."""
|
||||||
memo_id: str
|
memo_id: str
|
||||||
group: str
|
group: str
|
||||||
memo: object = None
|
question: str | None = None
|
||||||
|
version: int | None = None
|
||||||
engine_status: str | None = None
|
engine_status: str | None = None
|
||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
intent: dict | None = None
|
approved_at: str | None = None
|
||||||
history: tuple = ()
|
history: tuple = () # ({"verb", "memo_version", "at", "state"}, ...), the person's own
|
||||||
|
|
||||||
|
|
||||||
class ReviewController:
|
class ReviewController:
|
||||||
|
|
@ -142,8 +144,9 @@ class ReviewController:
|
||||||
def overview(self, session):
|
def overview(self, session):
|
||||||
"""The signed-in person's memos, classified. Never presents, binds or stores engine state.
|
"""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
|
Every row passes its own fresh PDP `list` decision before its question
|
||||||
returned; a refused or failed row keeps only its id. Engine status is
|
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.
|
read live by the approval id the memo already carries — never a poll.
|
||||||
"""
|
"""
|
||||||
self._session(session)
|
self._session(session)
|
||||||
|
|
@ -152,7 +155,7 @@ class ReviewController:
|
||||||
|
|
||||||
def _overview_row(self, session, memo):
|
def _overview_row(self, session, memo):
|
||||||
try:
|
try:
|
||||||
_, observation = self._authorize(session, memo, "read")
|
_, observation = self._authorize(session, memo, "list")
|
||||||
except ReviewError as exc:
|
except ReviewError as exc:
|
||||||
if exc.code == "session_expired":
|
if exc.code == "session_expired":
|
||||||
raise
|
raise
|
||||||
|
|
@ -188,7 +191,10 @@ class ReviewController:
|
||||||
group = "open"
|
group = "open"
|
||||||
else:
|
else:
|
||||||
group = "closed"
|
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):
|
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)
|
||||||
|
|
|
||||||
|
|
@ -143,20 +143,20 @@ OVERVIEW_REASONS = {
|
||||||
|
|
||||||
def _overview_row(row):
|
def _overview_row(row):
|
||||||
link = "/review?memo_id=" + quote(row.memo_id, safe="")
|
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.")
|
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>'
|
return (f'<div class="record"><strong>{text(row.memo_id)}</strong>'
|
||||||
f'<p class="muted">{text(reason)}</p></div>')
|
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:
|
if row.engine_status:
|
||||||
facts.append(f'Approval status: {text(row.engine_status)}')
|
facts.append(f'Approval status: {text(row.engine_status)}')
|
||||||
elif row.reason:
|
elif row.reason:
|
||||||
facts.append(text(OVERVIEW_REASONS.get(row.reason, "Approval status is unavailable.")))
|
facts.append(text(OVERVIEW_REASONS.get(row.reason, "Approval status is unavailable.")))
|
||||||
if row.intent and row.intent.get("approved_at"):
|
if row.approved_at:
|
||||||
facts.append(f'Entry recorded {text(row.intent["approved_at"])}')
|
facts.append(f'Entry recorded {text(row.approved_at)}')
|
||||||
history = ''.join(f'<li>{text(d.verb.value.capitalize())} · version {d.memo_version} · {text(d.at)}'
|
history = ''.join(f'<li>{text(h["verb"].capitalize())} · version {h["memo_version"]} · {text(h["at"])}'
|
||||||
+ (f' · {text(s["state"])}' if s else '') + '</li>' for d, s in row.history)
|
+ (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.memo.question)}</strong></a>'
|
return (f'<div class="record"><a href="{link}"><strong>{text(row.question)}</strong></a>'
|
||||||
f'<p class="muted">{" · ".join(facts)}</p>'
|
f'<p class="muted">{" · ".join(facts)}</p>'
|
||||||
+ (f'<details><summary>Your responses</summary><ul>{history}</ul></details>' if history else '')
|
+ (f'<details><summary>Your responses</summary><ul>{history}</ul></details>' if history else '')
|
||||||
+ '</div>')
|
+ '</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",
|
"package": "informed-decision.fixture",
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a",
|
"package_digest": "sha256:f45e366d3a07430a5171716dc9b970dd97b2b1cfa68cfda5a042416f7a277236",
|
||||||
"fixture_only": true
|
"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
|
status: ready
|
||||||
package: flexauth.informed_decision.fixture
|
package: flexauth.informed_decision.fixture
|
||||||
allow_ttl: 60s
|
allow_ttl: 60s
|
||||||
actions: [read, acknowledge, accept, return, discuss, decline]
|
actions: [read, list, acknowledge, accept, return, discuss, decline]
|
||||||
owner: fixture-only
|
owner: fixture-only
|
||||||
fixtures: [fixtures.json]
|
fixtures: [fixtures.json]
|
||||||
caring:
|
caring:
|
||||||
|
|
@ -32,7 +32,7 @@ decision := {"effect": "allow", "reason": "synthetic_review_fixture"} if {
|
||||||
input.resource.system == "informed-decision"
|
input.resource.system == "informed-decision"
|
||||||
input.resource.type == "decision-memo"
|
input.resource.type == "decision-memo"
|
||||||
input.resource.id == "memo:memo-1"
|
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"
|
input.context.approval_id == "fixture"
|
||||||
} else := {"effect": "deny", "reason": "fixture_scope_refused"} if {
|
} else := {"effect": "deny", "reason": "fixture_scope_refused"} if {
|
||||||
true
|
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}=={
|
assert {r.memo_id:(r.group,r.engine_status) for r in rows}=={
|
||||||
'memo-1':('open','requested'),'memo-declined':('open','requested'),
|
'memo-1':('open','requested'),'memo-declined':('open','requested'),
|
||||||
'memo-returned':('open','requested'),'memo-closed':('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'])
|
p=opened(review).presentation;controller.acknowledge(session,p.id,['h-1'])
|
||||||
controller.act(session,p.id,Verb.ACCEPT,operation_id=str(uuid.uuid4()))
|
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)}
|
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',
|
assert {k:r.group for k,r in rows.items()}=={'memo-1':'accepted','memo-declined':'declined',
|
||||||
'memo-returned':'returned','memo-closed':'closed'}
|
'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 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.
|
# A revision after a return reopens the question on the new version.
|
||||||
controller.store.save_memo(replace(controller.store.memo('memo-returned'),version=2))
|
controller.store.save_memo(replace(controller.store.memo('memo-returned'),version=2))
|
||||||
assert groups(controller,session)['memo-returned']=='open'
|
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')
|
controller.policy.transport.change=lambda d:d.update(effect='deny')
|
||||||
before=len(transport.calls)
|
before=len(transport.calls)
|
||||||
[row]=controller.overview(session)
|
[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
|
assert len(transport.calls)==before
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -345,7 +345,7 @@ def test_overview_degrades_one_row_when_engine_is_unreachable(review):
|
||||||
transport.request=down
|
transport.request=down
|
||||||
[row]=controller.overview(session)
|
[row]=controller.overview(session)
|
||||||
assert (row.group,row.engine_status,row.reason)==('open',None,'engine_unavailable')
|
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
|
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)
|
monkeypatch.setattr(controller.store,'memos_for',broken)
|
||||||
r=home(review)
|
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']
|
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
|
```task
|
||||||
id: INFD-WP-0004-T02
|
id: INFD-WP-0004-T02
|
||||||
status: progress
|
status: wait
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "3d5f51ac-c226-5489-bfb6-d5829c70e6f6"
|
state_hub_task_id: "3d5f51ac-c226-5489-bfb6-d5829c70e6f6"
|
||||||
```
|
```
|
||||||
|
|
||||||
The request is drafted in `docs/flex-auth-request-list-action.md` and recorded
|
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
|
as `INFD-IN-0008`, owned by flex-auth. Sent 2026-09-21 to the flex-auth inbox
|
||||||
founder approves the draft. Done when flex-auth publishes a package, version
|
(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.
|
and digest that answers `list`, with fixtures.
|
||||||
|
|
||||||
## Consumer side: `list` action and limited-disclosure overview
|
## Consumer side: `list` action and limited-disclosure overview
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: INFD-WP-0004-T03
|
id: INFD-WP-0004-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e13ef7d2-bac0-5e2b-930d-4368e77bf1cb"
|
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
|
must not be deployed before T02. The live v2 package denies `list`, so
|
||||||
deploying early would change nothing visible.
|
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
|
## Admit and roll out
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue