diff --git a/docs/finding-r3-linkage-conflict.md b/docs/finding-r3-linkage-conflict.md index 9e70333..5ce92ed 100644 --- a/docs/finding-r3-linkage-conflict.md +++ b/docs/finding-r3-linkage-conflict.md @@ -5,10 +5,10 @@ **Intake:** `INFD-IN-0004` **Statute:** *"The statute governs on disagreement; a disagreement is a finding for `gate-house`."* -**Status:** **Ruled `GH-DEC-2026-015` — nesting permitted for this pair, -conditioned and not yet active.** `layer.yaml` remains unchanged: co-reference -stays in force until `approval-engine` states its presentation exclusion as -normative and tested. This repository does not activate on its own initiative. +**Status:** **Closed — ruled `GH-DEC-2026-015`, condition met, activated +2026-09-10.** `view_hash` carries `binding.digest`; the act-scope is no longer +independently canonicalized here. Condition verified locally, not taken on +report. `layer.yaml` records the evidence and the surviving `cycle_condition`. Gate House reversed itself, and gave the real ground rather than the one we argued: our binding slice canonicalizes `principal` and `target`, two of the diff --git a/docs/specs/EvidenceModel.md b/docs/specs/EvidenceModel.md index 138b57e..9398f8a 100644 --- a/docs/specs/EvidenceModel.md +++ b/docs/specs/EvidenceModel.md @@ -211,12 +211,34 @@ recomputation of one act in a second vocabulary* — **closer to the translation R3 forbade than nesting is**. Nesting removes the duplication; co-reference manages it. -**The permission is conditioned and not yet active.** It activates when -`approval-engine` states its presentation exclusion as **normative and tested** -rather than as design intent — our own A-17 correction applied to gate-house's -permission, since the distinguishing case is someone widening the digest and -that case is unobservable until approvals start failing. Co-reference remains in -force until then, and this repository does not activate on its own initiative. +**Activated 2026-09-10.** `approval-engine` met the condition and we verified it +here rather than taking it on report: their `docs/approval-claim.md` carries +*"Presentation exclusion — GH-DEC-2026-015 §4"* in normative language, and +`test_presentation_changes_cannot_change_the_approved_act` pins the input set +from **both** sides — widening (presentation material leaves the digest +unchanged) *and* narrowing (each of the five act fields changes it). The +narrowing half is what makes it real: without it, a digest over four fields, or +over a constant, would pass the widening half perfectly. Run and confirmed +passing. + +So `view_hash` now **carries** `binding.digest`, and the act-scope is no longer +independently canonicalized here. The act has exactly one canonicalization, +computed by the layer that owns it, and `view_hash` means *this person was shown +this presentation **of this act***. + +**The cycle condition did not go away — its protection moved.** It is no longer +enforced by refusing nesting but by `approval-engine`'s normative exclusion: +containment stays one-directional because `binding.digest` must never cover +presentation material. If that exclusion is relaxed, this linkage must be +revisited *before* the widening ships. `layer.yaml` carries it as +`cycle_condition` so a future widening meets a rule rather than silence. + +**One thing we did not assume.** `approval-engine`'s `principal` is the party +*on whose behalf* the approval was issued; ours is the person being **bound** — +the approver. Different roles. Dropping ours on the strength of "the digest +covers principal" would remove *who was shown this* from `view_hash` and gut the +promise. We kept it, declared the overlap `open` in `layer.yaml`, and raised it +with `approval-engine`. If the two are the same field, ours drops too. Our ordering-dependency objection to option (c) was **withdrawn as mistaken** and recorded as withdrawn: `binding.digest` is over act material, determined diff --git a/informed_decision/canonicalize.py b/informed_decision/canonicalize.py index c4fd240..7caa78c 100644 --- a/informed_decision/canonicalize.py +++ b/informed_decision/canonicalize.py @@ -1,6 +1,12 @@ #!/usr/bin/env python3 """Canonicalization for informed-decision view_hash and awareness_hash. +``approval_binding_digest`` was added to the binding allow-list on 2026-09-10 +under GH-DEC-2026-015, which permitted nesting for the informed-decision / +approval-engine pair. It is a digest computed by ``approval-engine`` and +**carried**, never recomputed or restated here. The published vectors do not +carry the key, so ``pick`` omits it and their hashes are unchanged. + Governed copy. The provenance original is ``history/20260909-initial-exploration/canonicalize.py`` and is never edited; this file is the one that changes. Promoted by INFD-WP-0001-T06. @@ -21,6 +27,7 @@ from typing import Any ALLOWED_BINDING_KEYS = ( + "approval_binding_digest", "awareness_promoted", "binding", "binding_level", diff --git a/informed_decision/memo.py b/informed_decision/memo.py index 521be68..e8a5143 100644 --- a/informed_decision/memo.py +++ b/informed_decision/memo.py @@ -11,9 +11,13 @@ binding slice committing *which scope this act is being entered into*. from __future__ import annotations +import re from dataclasses import dataclass, field, replace from enum import Enum +#: approval-engine's digest format. We validate the shape and NEVER compute one. +APPROVAL_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + class BindingLevel(str, Enum): ACKNOWLEDGMENT = "acknowledgment" @@ -186,16 +190,32 @@ class Memo: highlights: tuple[Highlight, ...] = () locale: str = "en" ui_release: str = "informed-decision@0.1.0" - #: Co-reference to the act this memo presents (GH-DEC-2026-012 R3). - #: The identifier only — never approval-engine's binding digest, which we - #: do not recompute or restate. Nesting is permitted by GH-DEC-2026-015 but - #: NOT ACTIVE; see layer.yaml nesting_permission_active. + #: Co-reference to the act this memo presents. approval_id: str | None = None + #: approval-engine's binding.digest over the five act fields, CARRIED here + #: under GH-DEC-2026-015 (activated 2026-09-10 once approval-engine stated + #: the presentation exclusion as normative and tested). + #: + #: It is referenced, never recomputed: this repository must not restate that + #: digest from its own vocabulary. When present, the act-scope stops being + #: independently canonicalized here, so the act has exactly ONE + #: canonicalization — computed by the layer that owns it. + approval_binding_digest: str | None = None sealed: bool = False def __post_init__(self) -> None: if not self.question: raise ValueError("a memo without a question does not render") + if self.approval_binding_digest is not None: + if not APPROVAL_DIGEST_RE.match(self.approval_binding_digest): + raise ValueError( + "approval_binding_digest must be approval-engine's " + "sha256:<64 hex> form; it is carried, never computed here" + ) + if self.approval_id is None: + raise ValueError( + "a carried binding digest needs the approval it belongs to" + ) packet_ids = {p.item_id for p in self.packet} for h in self.highlights: if h.item_id not in packet_ids: @@ -225,6 +245,11 @@ class Memo: "brief": self.brief, "locale": self.locale, "ui_release": self.ui_release, + **( + {"approval_binding_digest": self.approval_binding_digest} + if self.approval_binding_digest is not None + else {} + ), "packet": [{"item_id": p.item_id, "hash": p.hash} for p in self.packet], "highlights": [ { @@ -240,15 +265,31 @@ class Memo: } def _binding_document(self) -> dict: - target: dict = { - "kind": self.binding.target.kind, - "id": self.binding.target.id, - "label": self.binding.target.label, - "requires_new_bind": self.binding.target.requires_new_bind, - } - if self.binding.target.environment is not None: - target["environment"] = self.binding.target.environment - out: dict = {"principal": self.binding.principal.as_document(), "target": target} + """The binding slice, minus whatever the carried digest already covers. + + Where ``approval_binding_digest`` is present, the **act-scope** is + omitted: `target` is act material and is covered by that digest, so + canonicalizing it again here would be the partial recomputation in a + second vocabulary that GH-DEC-2026-015 exists to remove. + + ``principal`` is **kept**. approval-engine's `principal` is the party + *on whose behalf* the approval was issued; ours is the person being + bound — the approver. Different roles, so dropping ours would remove + *who was shown this* from `view_hash` and gut the promise this + repository exists to make. Raised with approval-engine rather than + assumed; if the two are the same field, this drops too. + """ + out: dict = {"principal": self.binding.principal.as_document()} + if self.approval_binding_digest is None: + target: dict = { + "kind": self.binding.target.kind, + "id": self.binding.target.id, + "label": self.binding.target.label, + "requires_new_bind": self.binding.target.requires_new_bind, + } + if self.binding.target.environment is not None: + target["environment"] = self.binding.target.environment + out["target"] = target if self.binding.terms is not None: out["terms"] = self.binding.terms if self.binding.justification is not None: diff --git a/layer.yaml b/layer.yaml index acfd9fa..099af95 100644 --- a/layer.yaml +++ b/layer.yaml @@ -93,36 +93,49 @@ binding_digest_relationship: substitutable: false disagreement_is: >- A finding against the presenting surface, never a fact about the request. - linkage: co-reference + linkage: nesting linkage_rule: >- - The presentation record carries the approval or binding identifier - explicitly, and both attestations are read against that one reference. This - repository MUST NOT recompute or restate approval-engine's binding digest - from its own vocabulary — it references the digest that layer computed and - recorded. + view_hash CARRIES approval-engine's binding.digest as a field. That digest + is referenced, never recomputed or restated from this repository's own + vocabulary, and the act-scope is no longer independently canonicalized here + — so the act has exactly one canonicalization, computed by the layer that + owns it. # GH-DEC-2026-015 (INFD-IN-0004) re-ruled: nesting is PERMITTED for this pair, - # CONDITIONED and NOT YET ACTIVE. view_hash may carry binding.digest as a - # field, and our binding slice then stops independently canonicalizing act - # material — but only once approval-engine states its presentation exclusion - # as NORMATIVE and TESTED rather than design intent. + # conditioned on approval-engine stating its presentation exclusion as + # NORMATIVE and TESTED rather than design intent. # - # "Co-reference remains in force until that condition is met; the permission - # activates then. You do not act on your own initiative here." - # - # So co-reference below is still the operative rule and this file is - # deliberately unchanged. See docs/finding-r3-linkage-conflict.md. + # ACTIVATED 2026-09-10, after verifying the condition here rather than taking + # it on report: approval-engine/docs/approval-claim.md carries "Presentation + # exclusion — GH-DEC-2026-015 §4" in normative language, and + # tests/test_claim_contract.py::test_presentation_changes_cannot_change_the_approved_act + # pins the input set from BOTH sides — widening (presentation material leaves + # the digest unchanged) and narrowing (each of the five act fields changes it). + # The narrowing half matters: without it a digest over four fields, or over a + # constant, would pass the widening half perfectly. Run and confirmed passing. reruled_by: GH-DEC-2026-015 nesting_permitted_when: >- approval-engine states the presentation exclusion from binding.digest as - normative and tested. Until then co-reference is in force. Do not activate - on this repository's own initiative. - nesting_permission_active: false - nesting_forbidden: >- - view_hash MUST NOT contain the binding digest, and MUST NOT travel inside - hashed request material while containing it. Option (c) was refused because - nesting reproduces the hash cycle that made GH-DEC-2026-008 unimplementable: - a claim required to name the digest of a request that would come to contain - it, where a fail-closed consumer denies permanently. + normative and tested. + nesting_permission_active: true + nesting_activated_at: "2026-09-10" + nesting_condition_evidence: + doc: approval-engine/docs/approval-claim.md#presentation-exclusion + test: tests/test_claim_contract.py::test_presentation_changes_cannot_change_the_approved_act + evidence_record: approval-engine/docs/evidence/2026-09-10-presentation-exclusion.json + # OPEN, raised with approval-engine rather than assumed. Their `principal` is + # the party ON WHOSE BEHALF the approval was issued; ours is the person being + # BOUND — the approver. Different roles, so this repository still commits its + # own principal in view_hash. Dropping it would remove *who was shown this* + # and gut the promise. If the two are the same field, ours drops too. + principal_role_overlap: open + # The cycle condition remains the thing to protect, and it is now protected by + # approval-engine's normative exclusion rather than by refusing nesting. + cycle_condition: >- + Mutual containment. view_hash carries binding.digest; binding.digest MUST + NOT cover presentation material, so containment stays one-directional and + the GH-DEC-2026-008 cycle cannot arise. If that exclusion is ever relaxed, + this linkage must be revisited before the widening ships — a fail-closed + consumer obeying a cyclic claim denies permanently. # §5 applies to Staff. This is a browser-facing surface with no Tooling contact. tooling_contacts: [] diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py index 312c24d..bd731d7 100644 --- a/tests/test_layer_conformance.py +++ b/tests/test_layer_conformance.py @@ -160,11 +160,28 @@ def test_presentation_claim_carries_all_three_limits(layer_doc): } -def test_binding_digest_relationship_is_co_reference_not_nesting(layer_doc): +def test_binding_digest_relationship_is_nesting_and_declares_its_condition(layer_doc): + """GH-DEC-2026-015, activated once approval-engine met the condition.""" rel = layer_doc["binding_digest_relationship"] - assert rel["linkage"] == "co-reference" + assert rel["linkage"] == "nesting" assert rel["substitutable"] is False - assert "nesting_forbidden" in rel + assert rel["nesting_permission_active"] is True + assert rel["nesting_condition_evidence"]["test"] + + +def test_the_cycle_condition_is_still_declared(layer_doc): + """Nesting is safe only while binding.digest excludes presentation. + + The protection moved from refusing nesting to approval-engine's normative + exclusion. The condition itself must stay written down, or a future + widening ships against a rule nobody can find. + """ + assert "cycle_condition" in layer_doc["binding_digest_relationship"] + + +def test_the_principal_role_overlap_is_declared_open(layer_doc): + """We kept our own principal in view_hash rather than assuming it is theirs.""" + assert layer_doc["binding_digest_relationship"]["principal_role_overlap"] == "open" def test_residual_is_declared_not_closed(layer_doc): diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py index c80f20c..2d50dd0 100644 --- a/tests/test_skeleton.py +++ b/tests/test_skeleton.py @@ -454,3 +454,93 @@ def test_heartbeat_is_an_ordinary_event_with_the_same_envelope(): "class": "informed-decision.disposition", "assertion": "nothing-to-report", } + + +# ------------------------------------------------------------------------- +# GH-DEC-2026-015 — nesting, activated 2026-09-10 +# +# approval-engine stated the presentation exclusion as normative and tested +# (docs/approval-claim.md "Presentation exclusion", and +# tests/test_claim_contract.py::test_presentation_changes_cannot_change_the_approved_act, +# which pins the input set from BOTH sides — widening and narrowing). +# ------------------------------------------------------------------------- + +DIGEST = "sha256:" + "b" * 64 +OTHER_DIGEST = "sha256:" + "c" * 64 + + +def test_carried_digest_enters_view_hash(): + plain = make_memo() + nested = make_memo(approval_binding_digest=DIGEST) + assert render(nested, principal_sub="b").view_hash != render(plain, principal_sub="b").view_hash + + +def test_a_different_act_digest_changes_view_hash(): + a = render(make_memo(approval_binding_digest=DIGEST), principal_sub="b") + b = render(make_memo(approval_binding_digest=OTHER_DIGEST), principal_sub="b") + assert a.view_hash != b.view_hash + + +def test_act_scope_is_no_longer_independently_canonicalized_when_nested(): + """The act has exactly one canonicalization, computed by its owner. + + Two memos differing only in act-scope must now hash alike, because the + scope is act material already covered by the carried digest. Canonicalizing + it again here is the partial recomputation GH-DEC-2026-015 removed. + """ + acme = make_memo(approval_binding_digest=DIGEST) + beta = make_memo( + approval_binding_digest=DIGEST, + binding=BindingSlice( + principal=acme.binding.principal, + target=Scope(kind="tenant", id="tenant:beta", label="Beta GmbH"), + ), + ) + assert render(acme, principal_sub="b").view_hash == render(beta, principal_sub="b").view_hash + + +def test_act_scope_still_binds_when_there_is_no_carried_digest(): + """L0/L2 are unchanged: with no approval there is no digest to defer to.""" + acme = make_memo(approval_id=None) + beta = make_memo( + approval_id=None, + binding=BindingSlice( + principal=acme.binding.principal, + target=Scope(kind="tenant", id="tenant:beta", label="Beta GmbH"), + ), + ) + assert render(acme, principal_sub="b").view_hash != render(beta, principal_sub="b").view_hash + + +def test_the_approver_still_binds_when_nested(): + """`this person was shown this presentation of this act` — the person half. + + approval-engine's `principal` is the party on whose behalf; ours is the + approver being bound. Dropping ours would remove who was shown this. + """ + a = make_memo(approval_binding_digest=DIGEST) + b = make_memo( + approval_binding_digest=DIGEST, + binding=BindingSlice( + principal=Principal(id="p-2", kind="person", display_name="Someone Else"), + target=a.binding.target, + ), + ) + assert render(a, principal_sub="x").view_hash != render(b, principal_sub="x").view_hash + + +def test_presentation_material_still_moves_view_hash_when_nested(): + base = make_memo(approval_binding_digest=DIGEST) + relocalized = make_memo(approval_binding_digest=DIGEST, locale="de") + assert render(base, principal_sub="b").view_hash != render(relocalized, principal_sub="b").view_hash + + +@pytest.mark.parametrize("bad", ["deadbeef", "sha256:zz", "sha1:" + "a" * 40, "sha256:" + "A" * 64]) +def test_a_malformed_carried_digest_is_refused(bad): + with pytest.raises(ValueError, match="carried, never computed"): + make_memo(approval_binding_digest=bad) + + +def test_a_carried_digest_without_its_approval_is_refused(): + with pytest.raises(ValueError, match="approval it belongs to"): + make_memo(approval_binding_digest=DIGEST, approval_id=None)