diff --git a/informed_decision/intake_service.py b/informed_decision/intake_service.py index db1046a..5f9b193 100644 --- a/informed_decision/intake_service.py +++ b/informed_decision/intake_service.py @@ -192,7 +192,9 @@ class IntakeService: metadata = {"memo_class": submission.memo_class, "review_group": submission.review_group, "drafted_by": submission.drafted_by, "serves": submission.serves} if profile is not None: - metadata.update(profile="present_existing", approval_requester=profile.approval_actor) + # approval_action: the verified binding.action, for the PDP (compact-sitting v14). + metadata.update(profile="present_existing", approval_requester=profile.approval_actor, + approval_action=submission.approval_binding["action"]) recovered = None # Reserve before any network side effect. SQLite serializes concurrent poses. with self.store._transaction() as db: diff --git a/informed_decision/policy.py b/informed_decision/policy.py index b1d9232..ef4be2e 100644 --- a/informed_decision/policy.py +++ b/informed_decision/policy.py @@ -57,12 +57,18 @@ def submitted_digest(request): return "sha256:" + hashlib.sha256(encoded.encode()).hexdigest() +INTAKE_CONTEXT = frozenset({"review_group", "memo_class", "intake_submitter", "approval_requester", + "approval_human_control"}) + + def build_request(session: HumanSession, memo, action, policy_version, *, intake_context=None): if action not in ACTIONS: raise ValueError("unsupported review action") extra = {} if intake_context is None else dict(intake_context) - if extra and set(extra) != {"review_group", "memo_class", "intake_submitter", "approval_requester", "approval_human_control"}: + if extra and set(extra) not in (INTAKE_CONTEXT, INTAKE_CONTEXT | {"approval_action"}): raise ValueError("exact intake policy context required") + if "approval_action" in extra and extra["approval_requester"] == "informed-decision": + raise ValueError("approval_action is sent only for a present-existing requester") if extra and action == "list": extra["review_recipient"] = memo.binding.principal.id return {"id": str(uuid.uuid4()), "tenant": "tenant:platform", diff --git a/informed_decision/review.py b/informed_decision/review.py index 13dd6fd..7243397 100644 --- a/informed_decision/review.py +++ b/informed_decision/review.py @@ -85,7 +85,9 @@ class ReviewController: if context is not None: current = self._approval(session, memo) if (current['binding'].get('actor') != context['approval_requester'] - or current['binding'].get('human_control') is not True): + or current['binding'].get('human_control') is not True + or ('approval_action' in context + and current['binding'].get('action') != context['approval_action'])): raise ReviewError(409, 'binding_changed') request = build_request(session, memo, action, self.policy.version, intake_context=context) observation = self.policy.check(request) diff --git a/informed_decision/store.py b/informed_decision/store.py index ba253b5..5fe86f3 100644 --- a/informed_decision/store.py +++ b/informed_decision/store.py @@ -281,11 +281,17 @@ class Store: if (receipt['approval_id'] != memo.approval_id or receipt['approval_binding_digest'] != memo.approval_binding_digest): raise Conflict('intake receipt does not match memo') - return {'review_group': metadata['review_group'], 'memo_class': metadata['memo_class'], - 'intake_submitter': row['submitter'], - # present_existing names the approval's own actor; the create path is informed-decision. - 'approval_requester': metadata.get('approval_requester', 'informed-decision'), - 'approval_human_control': True} + context = {'review_group': metadata['review_group'], 'memo_class': metadata['memo_class'], + 'intake_submitter': row['submitter'], + # present_existing names the approval's own actor; the create path is informed-decision. + 'approval_requester': metadata.get('approval_requester', 'informed-decision'), + 'approval_human_control': True} + if metadata.get('profile') == 'present_existing': + # compact-sitting v14 admits a present-existing requester only for a named action. + # A row recorded before this key existed fails closed at the PDP instead of guessing. + if 'approval_action' in metadata: + context['approval_action'] = metadata['approval_action'] + return context def intake_context(self, memo): with self._connection() as db: diff --git a/tests/test_intake_present_existing.py b/tests/test_intake_present_existing.py index c913069..3015d37 100644 --- a/tests/test_intake_present_existing.py +++ b/tests/test_intake_present_existing.py @@ -106,6 +106,7 @@ def test_presents_existing_without_creating(setup): context = store.intake_context(memo) assert context['approval_requester'] == ACTOR assert context['intake_submitter'] == 'nk-bind-executor' + assert context['approval_action'] == 'bounded-execution' # compact-sitting v14 # Replay is served from the ledger without another read. assert request(service, envelope(), token='executor-token') == (200, receipt) assert reads == [APPROVAL] @@ -175,7 +176,9 @@ def test_existing_submitters_keep_create_path(setup): status, receipt = request(service, base_envelope(), token='hub-token') assert status == 201 and len(created) == 1 and reads == [] assert created[0]['binding']['actor'] == 'informed-decision' - assert store.intake_context(store.memo(receipt['memo_id']))['approval_requester'] == 'informed-decision' + context = store.intake_context(store.memo(receipt['memo_id'])) + assert context['approval_requester'] == 'informed-decision' + assert 'approval_action' not in context # v13 shape unchanged for the create path # A create-path submitter still cannot pin an approval. assert request(service, envelope(), token='hub-token')[0] == 400 @@ -248,3 +251,72 @@ def test_real_engine_object_is_presentable(setup, tmp_path): other['idempotency_key'] = 'bea-2026-0001-r9' other['memo']['id'] = 'bea-2026-0001-r9' assert request(service, other, token='executor-token') == (409, {'error': 'approval_already_presented'}) + + +class _StopAtPolicy(Exception): + pass + + +class _RecordingPolicy: + version = 'v14' + + def __init__(self): + self.requests = [] + + def check(self, body): + self.requests.append(body) + raise _StopAtPolicy() + + +def _review(store, live): + from dataclasses import replace + from informed_decision.review import ReviewController + from test_policy_client import human + + class Engine: + def get_approval(self, approval_id): + return json.loads(json.dumps(live)) + policy = _RecordingPolicy() + controller = ReviewController(store, policy, lambda session: Engine(), intake_attributes=True) + return controller, policy, replace(human(), subject='keycape-human') + + +def test_review_sends_approval_action_to_the_pdp(setup): + service, store, _, _, _ = setup + assert request(service, envelope(), token='executor-token')[0] == 201 + controller, policy, session = _review(store, approval()) + with pytest.raises(_StopAtPolicy): + controller.open(session, 'bea-2026-0001-r1') + context = policy.requests[0]['context'] + assert context['approval_action'] == 'bounded-execution' + assert context['approval_requester'] == ACTOR and context['intake_submitter'] == 'nk-bind-executor' + + +def test_review_refuses_changed_live_action_before_policy(setup): + from informed_decision.review import ReviewError + service, store, _, _, _ = setup + assert request(service, envelope(), token='executor-token')[0] == 201 + controller, policy, session = _review(store, approval(**{'binding.action': 'deploy'})) + with pytest.raises(ReviewError, match='binding_changed'): + controller.open(session, 'bea-2026-0001-r1') + assert policy.requests == [] + + +def test_build_request_admits_approval_action_only_for_present_existing(setup): + from dataclasses import replace + from informed_decision.policy import build_request + from test_policy_client import human + service, store, _, _, _ = setup + assert request(service, envelope(), token='executor-token')[0] == 201 + memo = store.memo('bea-2026-0001-r1') + session = replace(human(), subject='keycape-human') + context = store.intake_context(memo) + assert build_request(session, memo, 'read', 'v14', intake_context=context)['context']['approval_action'] \ + == 'bounded-execution' + with pytest.raises(ValueError): + build_request(session, memo, 'read', 'v14', + intake_context={**context, 'approval_requester': 'informed-decision'}) + with pytest.raises(ValueError): + build_request(session, memo, 'read', 'v14', intake_context={**context, 'approval_scope': 'x'}) + without = {k: v for k, v in context.items() if k != 'approval_action'} + assert 'approval_action' not in build_request(session, memo, 'read', 'v13', intake_context=without)['context']