diff --git a/docs/flex-auth-request-list-action.md b/docs/flex-auth-request-list-action.md index a0a5484..34b9e66 100644 --- a/docs/flex-auth-request-list-action.md +++ b/docs/flex-auth-request-list-action.md @@ -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 diff --git a/docs/flex-auth-review-contract.md b/docs/flex-auth-review-contract.md index 6fb9343..480e2e2 100644 --- a/docs/flex-auth-review-contract.md +++ b/docs/flex-auth-review-contract.md @@ -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 diff --git a/docs/protected-browser-review.md b/docs/protected-browser-review.md index a1f4673..3e6e918 100644 --- a/docs/protected-browser-review.md +++ b/docs/protected-browser-review.md @@ -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 diff --git a/informed_decision/policy.py b/informed_decision/policy.py index 72c0370..d95fec2 100644 --- a/informed_decision/policy.py +++ b/informed_decision/policy.py @@ -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" diff --git a/informed_decision/review.py b/informed_decision/review.py index 8b05f7e..d45dc57 100644 --- a/informed_decision/review.py +++ b/informed_decision/review.py @@ -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) diff --git a/informed_decision/ui.py b/informed_decision/ui.py index a57321b..930d839 100644 --- a/informed_decision/ui.py +++ b/informed_decision/ui.py @@ -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'
{text(row.memo_id)}' f'

{text(reason)}

') - 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'
  • {text(d.verb.value.capitalize())} · version {d.memo_version} · {text(d.at)}' - + (f' · {text(s["state"])}' if s else '') + '
  • ' for d, s in row.history) - return (f'
    {text(row.memo.question)}' + if row.approved_at: + facts.append(f'Entry recorded {text(row.approved_at)}') + history = ''.join(f'
  • {text(h["verb"].capitalize())} · version {h["memo_version"]} · {text(h["at"])}' + + (f' · {text(h["state"])}' if h["state"] else '') + '
  • ' for h in row.history) + return (f'
    {text(row.question)}' f'

    {" · ".join(facts)}

    ' + (f'
    Your responses
      {history}
    ' if history else '') + '
    ') diff --git a/tests/fixtures/review-policy/pin.json b/tests/fixtures/review-policy/pin.json index d1a7dc0..b47e7b7 100644 --- a/tests/fixtures/review-policy/pin.json +++ b/tests/fixtures/review-policy/pin.json @@ -1,6 +1,6 @@ { "package": "informed-decision.fixture", "version": "v1", - "package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a", + "package_digest": "sha256:f45e366d3a07430a5171716dc9b970dd97b2b1cfa68cfda5a042416f7a277236", "fixture_only": true } diff --git a/tests/fixtures/review-policy/policy.md b/tests/fixtures/review-policy/policy.md index 2d7e500..5cae7ac 100644 --- a/tests/fixtures/review-policy/policy.md +++ b/tests/fixtures/review-policy/policy.md @@ -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 diff --git a/tests/test_review_controller.py b/tests/test_review_controller.py index f737cd7..adc69b9 100644 --- a/tests/test_review_controller.py +++ b/tests/test_review_controller.py @@ -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') diff --git a/workplans/INFD-WP-0004-comfortable-decision-review.md b/workplans/INFD-WP-0004-comfortable-decision-review.md index a9e2702..d5b9ddc 100644 --- a/workplans/INFD-WP-0004-comfortable-decision-review.md +++ b/workplans/INFD-WP-0004-comfortable-decision-review.md @@ -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