From 9e1f77e32bcd30fb909d12781184bee9ebf4935c Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 10 Sep 2026 20:38:20 +0200 Subject: [PATCH] Build the T08 domain core with the engine behind a seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit approval-engine APPROVAL-WP-0002-T01 is still progress and its namespace has no pods, so the live end-to-end proof cannot run. Built everything that does not depend on it, with the engine behind a Protocol plus a fake carrying its real refusal semantics, so its arrival is a wiring change rather than a build. - memo.py: the Decision Memo, versions, binding document. Principal, Scope, Awareness and Hat are dataclasses rather than dicts because the canonicalizer requires a shape and a missing key should fail at construction rather than deep inside hashing — which is exactly how it failed twice while building this. Field names follow the governed canonicalizer (item_id, severity, locator): the published vectors are the contract, so the object was aligned to them rather than the reverse. - presentation.py: the sole writer of view_hash. One writer, one canonicalizer, one place to audit. Acknowledgment is an explicit method call and nothing infers it from scroll, dwell or focus. - disposition.py: verbs and guards G_NOAGENT, G_STEP, G_PRES, G_ACK, G_REASONS, G_SEALED. accept is ABSENT from weak steps rather than present-and-disabled, because a greyed-out accept still teaches the wrong model. Only accept reaches the engine; a memo return is not represented there at all. - provenance.py: claim routes per A-16. assert_human_control_dischargeable refuses a registration-supplied human, so PR-11's limitation fires at the point of use instead of sitting in a document. - evidence.py: local outbox, commitment-only records carrying the GH-DEC-2026-014 §4 existence assertion, per-class reconciliation counts, and a custody-locator guard that rejects credentialed URLs (PR-12). - approval_client.py: 409 duplicate_approver is success, 409 conflict terminal, 503 fail-closed, approval:consume refused before a token is requested. 87 tests pass, including every negative case in the Use Case Catalog and that a fail-closed outcome is recorded as a stance application with no verb field — never as a decline, because the human did not make one. T08 stays progress: the live proof is the remainder. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3W1dQG7GFFM9d94jFx7iR Assistant: claude-code Assistant-Model: opus Assistant-Process: 1565372@bnt-lap001 Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568 --- SCOPE.md | 27 +- informed_decision/__init__.py | 22 +- .../__pycache__/__init__.cpython-312.pyc | Bin 514 -> 1441 bytes .../approval_client.cpython-312.pyc | Bin 0 -> 6244 bytes .../__pycache__/disposition.cpython-312.pyc | Bin 0 -> 8189 bytes .../__pycache__/evidence.cpython-312.pyc | Bin 0 -> 11798 bytes .../__pycache__/memo.cpython-312.pyc | Bin 0 -> 13121 bytes .../__pycache__/presentation.cpython-312.pyc | Bin 0 -> 4012 bytes .../__pycache__/provenance.cpython-312.pyc | Bin 0 -> 4508 bytes informed_decision/approval_client.py | 114 +++++ informed_decision/disposition.py | 174 +++++++ informed_decision/evidence.py | 239 +++++++++ informed_decision/memo.py | 263 ++++++++++ informed_decision/presentation.py | 92 ++++ informed_decision/provenance.py | 91 ++++ ...test_skeleton.cpython-312-pytest-7.4.4.pyc | Bin 0 -> 57576 bytes tests/test_skeleton.py | 456 ++++++++++++++++++ ...ounding-specs-and-approver-ui-ownership.md | 35 +- 18 files changed, 1503 insertions(+), 10 deletions(-) create mode 100644 informed_decision/__pycache__/approval_client.cpython-312.pyc create mode 100644 informed_decision/__pycache__/disposition.cpython-312.pyc create mode 100644 informed_decision/__pycache__/evidence.cpython-312.pyc create mode 100644 informed_decision/__pycache__/memo.cpython-312.pyc create mode 100644 informed_decision/__pycache__/presentation.cpython-312.pyc create mode 100644 informed_decision/__pycache__/provenance.cpython-312.pyc create mode 100644 informed_decision/approval_client.py create mode 100644 informed_decision/disposition.py create mode 100644 informed_decision/evidence.py create mode 100644 informed_decision/memo.py create mode 100644 informed_decision/presentation.py create mode 100644 informed_decision/provenance.py create mode 100644 tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc create mode 100644 tests/test_skeleton.py diff --git a/SCOPE.md b/SCOPE.md index db13283..161bd19 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -6,15 +6,28 @@ ## Status — 2026-09-09 -**Specification and declaration are complete; no service is deployed.** +**Specification, declaration and the domain core are complete. No service is +deployed.** -What exists and is tested: the layer and stance declarations (`layer.yaml`, -`pep-stance.yaml`, `informed_decision/stance.py`), the governed canonicalizer -and schema, and the four specs under `docs/specs/`. 46 tests pass. +What exists and is tested (87 tests): -What does not exist: any HTTP surface, any storage, any UI, any deployment. The -walking skeleton is `INFD-WP-0001-T08` and is gated on an external decision -(§"Open" below). +- layer and stance declarations — `layer.yaml`, `pep-stance.yaml`, + `informed_decision/stance.py`, with published-equals-shipped asserted; +- the governed canonicalizer and schema, with the three published vectors + reproducing byte for byte and all four isolation properties pinned; +- the **domain core**: `memo.py` (the Decision Memo, its versions and the + binding document), `presentation.py` (the sole writer of `view_hash`), + `disposition.py` (the verb vocabulary and guards `G_NOAGENT`, `G_STEP`, + `G_PRES`, `G_ACK`, `G_REASONS`, `G_SEALED`), `provenance.py` (claim routes, + A-16), `evidence.py` (the local outbox and commitment records); +- `approval_client.py` — the seam to `approval-engine` plus a fake carrying its + actual refusal semantics. + +What does not exist: any HTTP surface, any persistence, any UI, any deployment. +The origin `decisions.coulomb.social` is live but serves an nginx placeholder. + +`INFD-WP-0001-T08` remains open for the live end-to-end proof, which is gated on +`APPROVAL-WP-0002-T01` and a deployed `approval-engine`. ## One-liner diff --git a/informed_decision/__init__.py b/informed_decision/__init__.py index f8c3383..933c715 100644 --- a/informed_decision/__init__.py +++ b/informed_decision/__init__.py @@ -1,9 +1,27 @@ """informed-decision — presentation and binding surface for decisions. This package must never contain an authorization decision. See ``INTENT.md`` -and ``AGENTS.md``: ``access-engine`` is the only policy decision point. +and ``AGENTS.md``: ``access-engine`` is the only policy decision point. What is +recorded here is evidence that a human performed an act, never a verdict on +whether the act was permitted. """ from .canonicalize import awareness_hash, view_hash +from .disposition import Actor, ActorKind, Disposition, DispositionRefused, Verb, legal_verbs, record +from .evidence import Commitment, EventClass, Outbox, heartbeat +from .memo import Awareness, BindingLevel, BindingSlice, Highlight, Memo, PacketItem, Principal, Scope, StepKind +from .presentation import Phase, Presentation, render +from .provenance import Claim, Route +from .stance import STANCE, resolve -__all__ = ["view_hash", "awareness_hash"] +__all__ = [ + "view_hash", "awareness_hash", + "Memo", "BindingSlice", "Principal", "Scope", "PacketItem", "Highlight", + "Awareness", "BindingLevel", "StepKind", + "render", "Presentation", "Phase", + "record", "Disposition", "Verb", "Actor", "ActorKind", "DispositionRefused", + "legal_verbs", + "Outbox", "Commitment", "EventClass", "heartbeat", + "Claim", "Route", + "STANCE", "resolve", +] diff --git a/informed_decision/__pycache__/__init__.cpython-312.pyc b/informed_decision/__pycache__/__init__.cpython-312.pyc index d4250157085ed2dbbb177751fa1ad87d21f20c63..9d4e61d1e1232dd27e41d5e7c3dcc4b8ddea0b53 100644 GIT binary patch literal 1441 zcmZXUO>Z1E7{@*PzO$Q`<~?bWx+JZ%sNG!XrK+exh@b+}s2hRg%d9i=WXCWbk38Ol z^aS4liF}IltSNow z%NA=DV=75o&afFd%Vy;qo0IcwzQ|XT8*+gy$VIj&m)MeAX3KJgt;kijD%aSWTxaWY zgKZRJYspQy$u{K{+mhRCyU5j(Taq#=Z?oG)+(_P$J8VbpvR#i1NYs4cu{)Q3r!{=) z5hBtvr6ol7BM3zPhCM<<6zCtVr!`#u)z_xC?? ziC&R;=t3TbkmvUy9f%bAed_9&7^q5Tr5)4rC-F{xN*LQQ0WLF8iNL$ z2Wpz=TyVM1DeS?<57>K(5 zAI?(JDcM$l(W7a=Bcdvtl=WfK)p*dAw+hz=0n^WMX z({xoQ%53|C@veUAwko-bdd^F*&5y+(PTbGf%4Z;z^^e`|f_Y*<+UBtqX(%$D*lI6S z8Q4b8K!&Y!%C>s!S_a!bo;pORHjO|#YpPD1khc0v4Gp-s);rlhIykg-H(Vv>(Am=N z(h*%oEFl&UD~MIZGGY-ikGO$YL)=7cAl4BrL>fE*UAc#~L1hyUWRHY*1iFgiUm8jRYqU$}JDpm+6Di$h=vZ%~9CZ}*pK%oa! z*ot(fPc>M0P&8dwQh0D}3AS(B)3!U`d;j~lxXJ{R3%wTvA{8bGy4mHM#v`esVFK^#du}nj8~HoUXkoql8wlNm*l~S?2gFJ-xa@A`nIm$ Hb<_U?CFh^J delta 138 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 F{Vx;p%F_S< literal 0 HcmV?d00001 diff --git a/informed_decision/__pycache__/memo.cpython-312.pyc b/informed_decision/__pycache__/memo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f64e1d2eee8c488a947ca442f5815dd9f0653f5 GIT binary patch literal 13121 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 QX`ZTyvifxk6`9KZ4{UCSg8%>k literal 0 HcmV?d00001 diff --git a/informed_decision/__pycache__/presentation.cpython-312.pyc b/informed_decision/__pycache__/presentation.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2662bcb6c0ed2fd7dfc4a3bdf3829057776e1e25 GIT binary patch literal 4012 zcmZ`+U5pgR5$@TY{hj^0-M!m8gYCgU?$*E^*u+W*0ptHO_(0f>l!0g%&-Af#o}W$6 zEazLFrHddEJQ-w5XDf~p8qEh7joK>>RO4+U` z72eNVV|G=k3M4^ZCWds07zJ9R`Rih2jZ!!2M)8uUj3)?98KoQL>tG!=KE49u$F3(L z$@S<(lnEn=KU!a!9f(F=M)eahNB-Z>7&mH{(#qsYvN_Rt7M422s7D=N^G(;08P#28 zc=A8jK9v0yl|9#@@-j1h%4By@K6mbdNte}@=C#h9o6YA>Iz#PV*#%zL9M>^*%`&@` zMWVK)=~VVzS?d_4KPw-TJy^prhj!D?6?) zYmV%;ebY9*$jX^>$C%y%qjbN+=yO(wwwdYp=j6Zs@#!X*bxdxLZi>$r?F=^ov(XE% z<7n}g)^1aW8ng1e&sWB-vCnm^l|y+7CY#QAzBiah)>DXW6sl$+xwyZHQ z$n&_H!(8lfob%XF8H%q_+l7x)&s&;l>m%n+2Js_oPJ(ouP@)JB>j^`E*iI;-kyKJE zVlx${PJz3@6mXXMmgyLw$Z4+adOjB{G}B>LRUOTysv721)pm`Jg?vF(f7#KjfhK$E z^o#0q^9wJ8BB!}iCtrVq3;skl(X#KJool%^om+O9r?=)zXVGOgHTD@)H$8~ESfa*~ z(G#3)udoVC!^s3N-WZTeBq&d%fB&KV`$Iu#rWu+LCLMQ~WjNRI^)P38F1kfw+mPn|GIdLu>q*bwk&bUj$8WxJ>t{djNy;rz z;nTeVrll8|+l5evN#X|X#3pQh4)j5Uy$KjfA;u1alygbWi6BeGvUJR4VlEqVxtPnx zoW!{T%u?iB31YYmy~?1MGRB}+9rV(?SHm74YKCTS)Q88SUG`<)0PXcN5g!_R2j7> zCYA(wY*0oGRr47}1jE|IgE&mJG4~d_d00zcl@|aB072Sq=3amqP2;-)&+DNo?Ndhs z8%hsW^;g;m9%TSZtz-G>qNe*UTj`df^YW;F2Il@nSkv^k!GxB1zGcDV!{nYm8u=+O zis#1>V4y5wNhG%L9+bocSxu~xRk9Rk1q>OqRiPzxAuMksN1|Hn{`;8zHbkFAjq^5w zEP^ip?mKPE)J=eL-3Ne{2Wn8^SKl!2RMu-ukCow=4J0|){3y3StNSfV^GU{i!y*|C`*nHrl85@g{bhr5nG-NWh@@g(34Gp?TTJM!dEhsV(h0LSFwRY z^r*EM7Df+)>pjdUa0=TF?=ZV;`YjdCtvfy%n6pFi(=f=}0c4GQTOzrcf0QS#`k&SB z9GKrYFn{yx&4rBv%I)%*TiG)_i<=_Dh-XclJIyE*O{ZI$9-Vr+DX}_^8ApN;8BFLc zyDG!O6XH3Lu4DKQwV)Wb;a%*#H0TF-IMm^LRlGj<>b;DQv+`yLJ|a19OW7g!;60r6 zH{`dFcuV{)`#_5vNDEsfTq=OP^gVL2goX2==GTUT;d(xvJ8!G$q1{Ge3M77GpdhQ6 zt(w&otch2QhZ;ae5ls`mbbgO|2hs~N5O0s8C4Z2p z1FU!hGZ_BxO3D*lG83Z)Sv!s3?oJq$D4I<^R300Xcvu@j&<3Xk_b zkTueOgwz^$s?!_Q>0Y(Dc08z0-l^}|sPFl7zE^){?X|m7BPdosJh*=7+My2)U!A=r z?FcIM^~|+QFg~?@_S)HCVrNjUz5ni&cY|7E{phu$ck7cMymYs^?W5zp>ddF(8`ZhH zjorVudyRviy}8l&Nl>csHFj;(cLn9~pt1Y@wo)P8C#6(6LaqeEqQsG_Sz`0ZV_Zd! zKs<|5`XF)~l{klf=~m*q@}P)vPeu1gJ}Jk9sW7ABDMk0&#SZ>M0a%9#&0a!s2nm)y z4tV@V7G1_f3b%HK&J7D&{_o*MUcxW9y_l`s=rE7f9qYJG8RjbAR%8%AbTo)zy77Z~ z2u8ol9*%C`N7!i?g4p8y8Av}V2*Njsj39nnCBn|Hi1a0y`I1b3LB{_@p8k^T_<}ro zKb;h|Uw-~-{XPL^vmgj(gm1DWAzj^dTWI_zu}wHD^a;|YAZl*PP5f&B^jUp4~_*E7hE#@wC(TNp3?|EwW!f`sFta)v}@f zv}(WnHh^z~Jin1jEDSgWXXswKI@lU6j>LD-JnLC*gEd94%6-eXIs3=@FW5|JYMNQi zrsr~pMFF#^Q^=ya8?rDKH4FXhzz^dF4=3{ZrQ5t!vaKdxV!Yu-AzO;bShU0}-(gEl z;rh1Qv^+CvQ6DR0%a-6QjPPM4oM1CM#zfw>d_Ra-#66E$Y}N8&j%hg?b6mmgC=e|S z54ni2Fl#x8(G_2tT!eu?#%dyHu)ycc%8v=Y;$j9XaswZWPMp~tW5YRL4sS8rYS1(y zh*#=kC(kZ)1`}3UteN>`u$}l_`5MyL2-clGHw(bOJGst^==R+*iAoj zJ+v|>;Nf_FPvqc8Ae@k`)m^*JFlnTGRNlgzy9)1?TMUb@bAjnBcmUrKEkraQ`;J?y zae?K#76n2DlD+iZh0?`Wi{motK3~O9#|`beC04L#i`C;W#EKhh2U8Z;u2b-HNh6^9QM?t=!~WGOdF#`;w!=a8b^R^PFpjc?3^27 zUjbua9#M68lcNBnYzt;7jWA{f3!$m&w0H2P;f9{I40O{wMmHw%|EAn5>YV`zeTml) zul*P$+M+%x|CvTbx9jFhQ=DV@wC13hX z(+O-e_s`8QlxG*;Uif}zaG`vC{+-2znZ^0HE1lt)n~OKfmBsm4^*%ITnJX`r7v7q$ z%q*5a*0w2dUtXK62Ms>C76_!qr0drL(clgiXS*R_-fc0vEj@YNY>7S?k;U_*XocrNhVsxkXB6cwgerj`n&pxKm0gt}@EtI?a zcoSy>?IidvytX@C?s7d8hAQy36=|tz2=SlbI!AivNQ#pjAmyYMa;G zsNR!n$dp~CLBcXTNC+eW($5F2$uvTu1(JY}Z`N(eBD>azeL)+9E6L$n8QL-b5xxSH%zpJ;jqETs| z?hR-o#xj;Ug5{TZqZze|hByv$o&5Lc*eVMVh>wxFA@^z_8DioveI7E+W)McE>${O@ zwhzkp4cb4HIPhjfB9 z!rRO<&KzYYv|3Ob$W=w22c6u>!7njD27AwOa2GNDK8-NJ_MPqO$hv}+)Z$Pcuw84A z8LKXoPFKGW?|Tpzw#}Q7?CS&9I8WRf2_@z9zN4~htpSClq(gEj^`7pw7s$!oNCBFh zDW(Ncj3S&Nsuid>g`yWur`*uoS%N9uHIXT%igx>~Lf2RInI?m0QLJlQquR)k_uD^d z|C;|c_*L*p{BUyi!Q|}b>AB6q+-7c0g050DI$6P^SoocE1!@bpKZD_&^l}h*#hmEJ ziy%!b2B;XMZw1rrIo?cDyg;ova0C__wFIM{hp3Q@Nr@tk?G!Jf5>BA_k@h5=$>g?% zwZX!BZ#)_pdGF2bw2`^CmD94rpBuSMS>H~f`b1WhDY9bY*L{+l*0)E{w9B}}w=JWX z>YTbkI%!r(qe>9X?Pw@#+2ifa1NM~{?%JgLq8)eWS+4c{pJd2GS z{q`#``+N`Q8VB?8hlZ2-O==_AySqAi9l&4VxgJYt^f`dV4r2Weaog&(PKqf$hRLqS zmT(v(!ojq`CK)vM)YQ)T893OLefOO!3L{IjI~Ls$B;ZL+(}9-ULV5+%I7mjy&!vBXrJBJ zC28-`9xVPD5c1kr=8`;WiO%V58CKrFFZ5A>ZHKkov4?#p zAM~C4<=UrxFMc*W_Gp-W^uoh4mmi$Dyve@t$Dz%^?`)@xLVim#`ty*OgC`yiOn*8s zOC3b6*ls9 zABTLIyQ;MPHE|JNDVM{WD7I3%u0JtG^z?tvYx=o=X=nbf9etY3>*s$w`@xZ?8Y)jt tXvV+?=l`ld_l0p>FY8+xmEd~g_?B#VI-SvTAHDii!{5`9Lwa61{6Bys0r&s_ literal 0 HcmV?d00001 diff --git a/informed_decision/approval_client.py b/informed_decision/approval_client.py new file mode 100644 index 0000000..6399394 --- /dev/null +++ b/informed_decision/approval_client.py @@ -0,0 +1,114 @@ +"""The seam to `approval-engine`. + +A Protocol plus a fake, because the engine is not deployed +(`APPROVAL-WP-0002-T01` is `progress`, no pods). When it deploys this becomes a +wiring change rather than a build. + +Hard rules, from `approval-engine/docs/approver-surface-requirements.md`: + +- `GET /v1/approvals/{id}` and `/claim` need ``approval:read``; + `POST …/entries` needs ``approval:approve``. +- **Never** ``/consume``. Human principals are refused there in code, and + consumption belongs to the PEP causing the side effect (`GH-DEC-2026-003`). +- No path containing ``check`` or ending ``/authorize`` exists to call. +- ``POST /entries`` **discards its request body** — identity, assurance and + ``evidence_ref`` come only from the verified token. So ``view_hash`` does not + ride into the entry; correlation is ``(approval_id, subject, approved_at)``. +- **No inbox.** Get-by-id only. Never poll for work. +- `approved` is a state of an object, never permission to act. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +FORBIDDEN_SCOPE = "approval:consume" +REQUIRED_SCOPES = ("openid", "approval:read", "approval:approve") + + +class ApprovalEngineError(Exception): + def __init__(self, status: int, reason: str) -> None: + super().__init__(f"{status} {reason}") + self.status = status + self.reason = reason + + +@dataclass(frozen=True) +class EntryResult: + """What comes back after a successful entry. + + These three fields are the correlation triple that ties the entry back to + our presentation record — `GOAL.md` DoD-3. + """ + + approval_id: str + subject: str + approved_at: str + status: str + duplicate: bool = False + + @property + def correlation(self) -> tuple[str, str, str]: + return (self.approval_id, self.subject, self.approved_at) + + +class ApprovalEngine(Protocol): + def get_approval(self, approval_id: str) -> dict: ... + def add_entry(self, approval_id: str) -> EntryResult: ... + + +def assert_scopes_permissible(scopes: tuple[str, ...]) -> None: + """Refuse ``approval:consume`` before a token is ever requested.""" + if FORBIDDEN_SCOPE in scopes: + raise ValueError( + f"{FORBIDDEN_SCOPE} must never be requested: approval-engine refuses it " + "for human principals and consumption belongs to the PEP causing the " + "side effect (GH-DEC-2026-003)" + ) + + +def is_success(err: ApprovalEngineError) -> bool: + """`409 duplicate_approver` is SUCCESS, not failure. + + A browser double-submit is routine and the first entry stands. Rendering it + as an error is a lie to the approver. + """ + return err.status == 409 and err.reason == "duplicate_approver" + + +class FakeApprovalEngine: + """In-process stand-in with the engine's actual refusal semantics.""" + + def __init__(self, approvals: dict[str, dict] | None = None) -> None: + self._approvals = approvals or {} + self._entries: dict[str, set[str]] = {} + self.available = True + + def get_approval(self, approval_id: str) -> dict: + if not self.available: + raise ApprovalEngineError(503, "store_unavailable") + if approval_id not in self._approvals: + raise ApprovalEngineError(404, "not_found") + return dict(self._approvals[approval_id]) + + def add_entry(self, approval_id: str, subject: str = "approver") -> EntryResult: + if not self.available: + raise ApprovalEngineError(503, "store_unavailable") + approval = self._approvals.get(approval_id) + if approval is None: + raise ApprovalEngineError(404, "not_found") + if approval.get("status") in ("revoked", "superseded", "consumed", "expired"): + raise ApprovalEngineError(409, "conflict") + seen = self._entries.setdefault(approval_id, set()) + duplicate = subject in seen + seen.add(subject) + if not duplicate and len(seen) >= approval.get("required_count", 1): + approval["status"] = "approved" + return EntryResult( + approval_id=approval_id, + subject=subject, + approved_at="2026-09-10T15:00:00Z", + status=approval.get("status", "requested"), + duplicate=duplicate, + ) diff --git a/informed_decision/disposition.py b/informed_decision/disposition.py new file mode 100644 index 0000000..aa8d14f --- /dev/null +++ b/informed_decision/disposition.py @@ -0,0 +1,174 @@ +"""The disposition vocabulary and its guards. + +Approve/reject is the wrong vocabulary for judgment. `return` is success, not +failure, and `accept` on a Kenntnisnahme step is illegal on purpose. + +Nothing here is an authorization decision. A disposition is evidence that a +human performed an act; it never answers whether the act was permitted +(`access-engine`, statute §6). +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum + +from .memo import Memo, StepKind +from .presentation import Presentation + + +class Verb(str, Enum): + COMMENT = "comment" + DISCUSS = "discuss" + RETURN = "return" + FORWARD = "forward" + ESCALATE = "escalate" + ACKNOWLEDGE = "acknowledge" + ACCEPT = "accept" + DECLINE = "decline" + WITHDRAW = "withdraw" + CONFIGURE = "configure" + + +#: Verbs that bind an identity to an act. Guarded hardest. +BINDING_VERBS = frozenset({Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE}) + +#: Always available while the memo is circulating, whatever the step kind. +OVERLAY_VERBS = frozenset({Verb.COMMENT, Verb.DISCUSS}) + + +class ActorKind(str, Enum): + PERSON = "person" + AGENT = "agent" + SERVICE = "service" + SYSTEM = "system" + + +@dataclass(frozen=True) +class Actor: + sub: str + kind: ActorKind + + +class DispositionRefused(Exception): + """A verb was refused. Never an authorization verdict — see module docstring.""" + + def __init__(self, guard: str, message: str) -> None: + super().__init__(f"{guard}: {message}") + self.guard = guard + + +def legal_verbs(step_kind: StepKind) -> frozenset[Verb]: + """Which verbs the UI may offer for a step kind. + + `accept` is ABSENT for weak steps, not present-and-disabled: Kenntnisnahme + is not approval, and a greyed-out accept still teaches the wrong model. + """ + base = set(OVERLAY_VERBS) | {Verb.RETURN, Verb.FORWARD, Verb.ESCALATE} + if step_kind.is_weak: + return frozenset(base | {Verb.ACKNOWLEDGE}) + return frozenset(base | {Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE}) + + +@dataclass(frozen=True) +class Disposition: + id: str + memo_id: str + memo_version: int + presentation_id: str + verb: Verb + actor: Actor + at: str + reasons: tuple[str, ...] = () + note: str | None = None + #: Co-reference to the act (GH-DEC-2026-012 R3), identifier only. + approval_id: str | None = None + + @property + def reaches_approval_engine(self) -> bool: + """Only `accept` becomes a POST to /entries. + + `return`, `discuss`, `escalate` and the rest are dispositions of a MEMO; + approval-engine models entries against an approval object and knows + nothing of them. A memo `return` must not be represented there at all. + """ + return self.verb is Verb.ACCEPT + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def record( + memo: Memo, + presentation: Presentation, + verb: Verb, + actor: Actor, + *, + reasons: tuple[str, ...] = (), + note: str | None = None, +) -> Disposition: + """Record a disposition, or refuse it. + + Guards, in the order a defect is most likely to be caught: + + - ``G_NOAGENT`` humans bind, agents draft. No upstream backstop exists. + - ``G_STEP`` the verb must be legal for this step kind. + - ``G_PRES`` the presentation must be of this memo AND this version. + - ``G_ACK`` required highlights acked before any binding verb. + - ``G_REASONS`` a return carries at least one coded reason. + - ``G_SEALED`` binding verbs on a sealed version are illegal. + """ + if verb in BINDING_VERBS and actor.kind is not ActorKind.PERSON: + raise DispositionRefused( + "G_NOAGENT", + f"{actor.kind.value} principals may draft but never bind " + "(INTENT principle 10; approval-engine provides no upstream backstop)", + ) + + if verb not in legal_verbs(memo.step_kind): + detail = "" + if verb is Verb.ACCEPT and memo.step_kind.is_weak: + detail = " — Kenntnisnahme is not approval" + raise DispositionRefused( + "G_STEP", f"{verb.value} is not legal on a {memo.step_kind.value} step{detail}" + ) + + if presentation.memo_id != memo.id: + raise DispositionRefused("G_PRES", "presentation belongs to a different memo") + if presentation.memo_version != memo.version: + raise DispositionRefused( + "G_PRES", + f"presentation is of version {presentation.memo_version}, memo is at " + f"{memo.version} — no silent upgrade", + ) + + if verb in BINDING_VERBS: + if memo.sealed: + raise DispositionRefused("G_SEALED", "binding verbs on a sealed version are illegal") + outstanding = memo.required_ack_ids - presentation.acked_highlight_ids + if outstanding: + raise DispositionRefused( + "G_ACK", + "required highlights not acknowledged: " + ", ".join(sorted(outstanding)), + ) + + if verb is Verb.RETURN and not reasons: + raise DispositionRefused( + "G_REASONS", "a return carries at least one coded reason; free text is not a return" + ) + + return Disposition( + id=f"disp-{uuid.uuid4()}", + memo_id=memo.id, + memo_version=memo.version, + presentation_id=presentation.id, + verb=verb, + actor=actor, + at=_now(), + reasons=reasons, + note=note, + approval_id=memo.approval_id, + ) diff --git a/informed_decision/evidence.py b/informed_decision/evidence.py new file mode 100644 index 0000000..5b7336d --- /dev/null +++ b/informed_decision/evidence.py @@ -0,0 +1,239 @@ +"""The local transactional outbox and the commitment records it queues. + +Payload is **commitment-only**, granted for Stage 1 by `GH-DEC-2026-014`: +hashes, principal, timestamps, acks, the co-referenced approval id. Never the +brief, never the packet. + +What that does and does not establish is not a detail — see +`docs/specs/EvidenceModel.md` §8d: + +- It satisfies **non-alteration**. It does **not** satisfy + **reconstructability**, and must never be described as doing so. +- It moves *integrity* out of our control and leaves *availability* entirely + inside it. The party that can withhold the content is the party the evidence + is about. + +Which is why every record carries the §4 existence assertion. +""" + +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum + +from .disposition import Disposition +from .presentation import Presentation + + +class EventClass(str, Enum): + """One source, distinct type values per class (`audit-core`, `AUDIT-IN-0003`).""" + + PRESENTATION = "informed-decision.presentation" + DISPOSITION = "informed-decision.disposition" + STANCE_APPLICATION = "informed-decision.stance_application" + HEARTBEAT = "audit-core.heartbeat" + + +#: Heartbeat gap per class, seconds. Declared per class rather than per source: +#: a per-source heartbeat from a mixed-volume emitter is satisfied by its +#: chattiest class and says nothing about the quiet, security-relevant one. +HEARTBEAT_CLASSES: dict[str, int] = { + EventClass.PRESENTATION.value: 86400, + EventClass.DISPOSITION.value: 86400, + EventClass.STANCE_APPLICATION.value: 86400, +} + + +class CustodyLocatorRejected(Exception): + """The custody locator looked secret-shaped (PR-12). + + `audit-core` applies ``secret_policy: redact``, which scans ``data``. A + credentialed URL is redacted out and the existence declaration arrives + without its pointer — visibly (``details.redaction.paths`` records it), but + the declaration is then useless while *looking* complete. Build-breaking, + not a warning. + """ + + +#: Anything carrying userinfo, a query string, or a token-shaped segment. +_SECRET_SHAPED = re.compile( + r"(://[^/@\s]*@)|([?&](token|key|secret|sig|password|access[_-]?token)=)|(\bBearer\b)", + re.IGNORECASE, +) + + +def assert_custody_locator_safe(locator: str) -> None: + if not locator: + raise CustodyLocatorRejected("custody locator must be non-empty") + if _SECRET_SHAPED.search(locator): + raise CustodyLocatorRejected( + f"custody locator {locator!r} is secret-shaped; use a stable " + "identifier the custodian resolves, never a credentialed URL (PR-12)" + ) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +@dataclass(frozen=True) +class Commitment: + """One emitted record. `data` is stored verbatim by `audit-core` and chained.""" + + id: str + event_class: EventClass + at: str + data: dict + + def as_envelope(self) -> dict: + return {"type": self.event_class.value, "data": self.data} + + +def _existence(custody: str) -> dict: + """The `GH-DEC-2026-014` §4 assertion. + + A commitment with no assertion that something is being committed to is + indistinguishable from a commitment to nothing. Failure to produce at + retrieval is then a conformance failure attributable to the custodian — + which is us. + """ + assert_custody_locator_safe(custody) + return {"content_exists": True, "custody": custody} + + +def commit_presentation(p: Presentation, *, custody: str) -> Commitment: + data = { + "memo_id": p.memo_id, + "memo_version": p.memo_version, + "presentation_id": p.id, + "principal_sub": p.principal_sub, + "locale": p.locale, + "ui_release": p.ui_release, + "rendered_at": p.rendered_at, + "view_hash": p.view_hash, + "awareness_hash": p.awareness_hash, + "phase": p.phase.value, + "acked_highlight_ids": sorted(p.acked_highlight_ids), + "approval_id": p.approval_id, + } + # Claims travel with their route (PR-09/PR-11), never as bare strings. + if p.tenant is not None: + data["tenant"] = {"value": p.tenant.value, "route": p.tenant.route.value} + if p.principal_type is not None: + data["principal_type"] = { + "value": p.principal_type.value, + "route": p.principal_type.route.value, + } + data.update(_existence(custody)) + return Commitment(f"ev-{uuid.uuid4()}", EventClass.PRESENTATION, _now(), data) + + +def commit_disposition(d: Disposition, *, custody: str) -> Commitment: + data = { + "memo_id": d.memo_id, + "memo_version": d.memo_version, + "presentation_id": d.presentation_id, + "disposition_id": d.id, + "verb": d.verb.value, + "actor_sub": d.actor.sub, + "actor_kind": d.actor.kind.value, + "at": d.at, + "reasons": list(d.reasons), + "approval_id": d.approval_id, + } + data.update(_existence(custody)) + return Commitment(f"ev-{uuid.uuid4()}", EventClass.DISPOSITION, _now(), data) + + +def commit_stance_application( + *, memo_id: str, memo_version: int, binding_level: str | None, + binding_level_state: str, stance: str, dependency: str, custody: str, +) -> Commitment: + """A fail-closed outcome is recorded as a STANCE APPLICATION. + + Never as a decline: no disposition exists, because the human did not make + one. Conflating them would put a refusal in the record that no person + authored. + """ + data = { + "memo_id": memo_id, + "memo_version": memo_version, + "binding_level": binding_level, + "binding_level_state": binding_level_state, + "stance_applied": stance, + "unreachable_dependency": dependency, + # GH-DEC-2026-010: we can show a decision was obtained and what it said; + # we cannot show access-engine said it. Pinned false until FLEX-WP-0024. + "decision_attributable": False, + "at": _now(), + } + data.update(_existence(custody)) + return Commitment(f"ev-{uuid.uuid4()}", EventClass.STANCE_APPLICATION, _now(), data) + + +def heartbeat(event_class: EventClass) -> Commitment: + """An ORDINARY event — same envelope, same chain. + + Deliberately so: a heartbeat stored outside the chain would be the one + record that could be back-dated. + """ + return Commitment( + f"ev-{uuid.uuid4()}", + EventClass.HEARTBEAT, + _now(), + {"class": event_class.value, "assertion": "nothing-to-report"}, + ) + + +class Outbox: + """Local, transactional. Written in the same transaction as the state change. + + Emit-after-commit is a defect. The queue is local so an `audit-core` outage + never blocks a binding act — the same reasoning that keeps it from blocking + a revocation upstream. + + This in-memory implementation stands in for the transactional store; the + property it must preserve is that :meth:`append` cannot succeed while the + state change fails, or vice versa. + """ + + def __init__(self) -> None: + self._pending: list[Commitment] = [] + self._drained: list[Commitment] = [] + + def append(self, commitment: Commitment) -> None: + self._pending.append(commitment) + + @property + def pending(self) -> tuple[Commitment, ...]: + return tuple(self._pending) + + @property + def drained(self) -> tuple[Commitment, ...]: + return tuple(self._drained) + + def drain(self, sink) -> int: + """Deliver to `audit-core`. Failure leaves the record pending, never lost.""" + sent = 0 + while self._pending: + c = self._pending[0] + sink(c.as_envelope()) + self._drained.append(self._pending.pop(0)) + sent += 1 + return sent + + def counts_by_class(self) -> dict[str, int]: + """Our side of reconciliation, keyed by class. + + Bounded: where the emitter is compromised this count is suppressed + alongside the event, and reconciliation agrees with it. Covers loss, + outage, drain failure and accident — not adversarial omission by us. + """ + counts: dict[str, int] = {} + for c in [*self._drained, *self._pending]: + counts[c.event_class.value] = counts.get(c.event_class.value, 0) + 1 + return counts diff --git a/informed_decision/memo.py b/informed_decision/memo.py new file mode 100644 index 0000000..521be68 --- /dev/null +++ b/informed_decision/memo.py @@ -0,0 +1,263 @@ +"""The Decision Memo object. + +One question per memo. A memo carries the question, the requested act, the +binding level, the brief, a hashed packet, highlights pointing into it, and the +binding slice committing *which scope this act is being entered into*. + +`binding.target` is the **act-scope**. It is never derived from the token's +`tenant` claim, which is a membership fact about the principal +(`GH-DEC-2026-013` §5, PR-08). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from enum import Enum + + +class BindingLevel(str, Enum): + ACKNOWLEDGMENT = "acknowledgment" + ORGANIZATIONAL = "organizational" + AES = "aes" + QES = "qes" + + +class StepKind(str, Enum): + # Weak — acknowledgment only. `accept` is illegal here on purpose. + INFORM = "inform" + COMMENT = "comment" + REVIEW = "review" + ACKNOWLEDGE = "acknowledge" + # Co-sign + MITZEICHNUNG = "mitzeichnung" + APPROVE = "approve" + # Bind + SCHLUSSZEICHNUNG = "schlusszeichnung" + SIGN = "sign" + SEAL = "seal" + + @property + def is_weak(self) -> bool: + return self in (StepKind.INFORM, StepKind.COMMENT, StepKind.REVIEW, StepKind.ACKNOWLEDGE) + + @property + def is_bind(self) -> bool: + return self in (StepKind.SCHLUSSZEICHNUNG, StepKind.SIGN, StepKind.SEAL) + + +@dataclass(frozen=True) +class PacketItem: + """A hashed document in the packet. + + Field names follow ``canonicalize.normalize_packet``, which is the governed + contract the published vectors depend on — ``item_id``, not ``id``. ``label`` + is UI-only and is deliberately outside the hash: renaming a document does + not change what was shown. + """ + + item_id: str + label: str + hash: str + + +@dataclass(frozen=True) +class Highlight: + """A pointer into the packet. + + Highlights are an aid to attention, never a filter. Acknowledging them does + not narrow what is bound — the whole instrument is bound (PR-12/W-highlights). + """ + + id: str + item_id: str + note: str + required_ack: bool = False + severity: str = "informational" + locator: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class Identifier: + scheme: str + value: str + + +@dataclass(frozen=True) +class Principal: + """A party in the binding slice. + + A dataclass rather than a dict because the canonicalizer requires + ``display_name``, ``id`` and ``kind``, and a missing one is a runtime + KeyError deep inside hashing rather than a visible defect at construction. + """ + + id: str + kind: str + display_name: str + role: str | None = None + identifiers: tuple[Identifier, ...] = () + + def as_document(self) -> dict: + out: dict = {"display_name": self.display_name, "id": self.id, "kind": self.kind} + if self.role: + out["role"] = self.role + if self.identifiers: + out["identifiers"] = [ + {"scheme": i.scheme, "value": i.value} for i in self.identifiers + ] + return out + + +@dataclass(frozen=True) +class Scope: + """The act-scope. What the person is entering, not who they are.""" + + kind: str + id: str + label: str + environment: str | None = None + requires_new_bind: bool = False + + +@dataclass(frozen=True) +class BindingSlice: + """Committed and signed. Inside ``view_hash``.""" + + principal: Principal + target: Scope + terms: str | None = None + justification: str | None = None + + +@dataclass(frozen=True) +class Hat: + """A role. Not a scope — `hats are not scopes` (INTENT invariant).""" + + id: str + label: str + elevates: bool = False + + def as_document(self) -> dict: + return {"id": self.id, "label": self.label, "elevates": self.elevates} + + +@dataclass(frozen=True) +class Awareness: + """Shown on the same surface, hashed separately, never signed. + + Typed for the same reason ``Principal`` is: the canonicalizer requires a + shape, and a malformed one should fail at construction rather than deep + inside hashing. + + Nothing here enters ``view_hash``. Defaulting a hat to last-used is required + for situational awareness and forbidden from silently entering the signed + payload (INTENT principle 11). + """ + + proposed_hat: Hat | None = None + proposed_hat_source: str | None = None + available_hats: tuple[Hat, ...] = () + situation_note: str | None = None + + def as_document(self) -> dict: + out: dict = {} + if self.proposed_hat is not None: + out["proposed_hat"] = self.proposed_hat.as_document() + if self.proposed_hat_source is not None: + out["proposed_hat_source"] = self.proposed_hat_source + if self.available_hats: + out["available_hats"] = [h.as_document() for h in self.available_hats] + if self.situation_note is not None: + out["situation_note"] = self.situation_note + return out + + +@dataclass(frozen=True) +class Memo: + id: str + version: int + question: str + requested_act: str + binding_level: BindingLevel + brief: str + binding: BindingSlice + step_kind: StepKind + packet: tuple[PacketItem, ...] = () + 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. + approval_id: 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") + packet_ids = {p.item_id for p in self.packet} + for h in self.highlights: + if h.item_id not in packet_ids: + raise ValueError(f"highlight {h.id} points outside the packet") + + @property + def required_ack_ids(self) -> frozenset[str]: + return frozenset(h.id for h in self.highlights if h.required_ack) + + def next_version(self, **changes) -> "Memo": + """A change creates version n+1. Outstanding presentations die with it.""" + return replace(self, version=self.version + 1, **changes) + + def binding_document(self) -> dict: + """The document ``view_hash`` is computed over. + + Note what is here and what is not: the packet and highlights are here + because they were shown; the token's ``tenant`` claim is not, because it + is not the act-scope. + """ + return { + "memo_id": self.id, + "memo_version": self.version, + "question": self.question, + "requested_act": self.requested_act, + "binding_level": self.binding_level.value, + "brief": self.brief, + "locale": self.locale, + "ui_release": self.ui_release, + "packet": [{"item_id": p.item_id, "hash": p.hash} for p in self.packet], + "highlights": [ + { + "id": h.id, + "item_id": h.item_id, + "required_ack": h.required_ack, + "severity": h.severity, + "locator": h.locator, + } + for h in self.highlights + ], + "binding": self._binding_document(), + } + + 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} + if self.binding.terms is not None: + out["terms"] = self.binding.terms + if self.binding.justification is not None: + out["justification"] = self.binding.justification + return out + + def awareness_document(self, awareness: "Awareness | None" = None) -> dict: + """Shown on the same surface, hashed separately, never signed.""" + doc = {"memo_id": self.id, "memo_version": self.version, "locale": self.locale} + if awareness is not None: + doc.update(awareness.as_document()) + return doc diff --git a/informed_decision/presentation.py b/informed_decision/presentation.py new file mode 100644 index 0000000..14a9b11 --- /dev/null +++ b/informed_decision/presentation.py @@ -0,0 +1,92 @@ +"""Presentation records — the sole writer of ``view_hash``. + +One writer, one canonicalizer, one place to audit. A second path that computes +a hash is a defect, not an optimisation (`ArchitectureBlueprint` §3). + +A presentation is a record of an event that happened. It is append-only: +editing one is falsifying evidence. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum + +from .canonicalize import awareness_hash, view_hash +from .memo import Awareness, Memo +from .provenance import Claim + + +class Phase(str, Enum): + PRE_BIND = "pre_bind" + BIND = "bind" + POST_BIND = "post_bind" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +@dataclass(frozen=True) +class Presentation: + id: str + memo_id: str + memo_version: int + principal_sub: str + locale: str + ui_release: str + rendered_at: str + view_hash: str + awareness_hash: str + phase: Phase + #: Co-reference to the act (GH-DEC-2026-012 R3). The identifier only. + approval_id: str | None = None + #: Claims stored WITH their route, never as bare strings (PR-09, PR-11). + tenant: Claim | None = None + principal_type: Claim | None = None + acked_highlight_ids: frozenset[str] = field(default_factory=frozenset) + + def with_ack(self, highlight_id: str) -> "Presentation": + """Acknowledgment is an explicit act. + + Never inferred from scroll position, dwell time, focus or viewport + intersection (PR-21). Only this method records one, and only a + deliberate control activation calls it. + """ + from dataclasses import replace + + return replace(self, acked_highlight_ids=self.acked_highlight_ids | {highlight_id}) + + +def render( + memo: Memo, + *, + principal_sub: str, + tenant: Claim | None = None, + principal_type: Claim | None = None, + awareness: Awareness | None = None, + phase: Phase = Phase.PRE_BIND, +) -> Presentation: + """Render a memo, producing exactly one presentation record. + + This is the only function in the package that computes ``view_hash``. + """ + binding_doc = memo.binding_document() + awareness_doc = memo.awareness_document(awareness) + return Presentation( + id=f"pres-{uuid.uuid4()}", + memo_id=memo.id, + memo_version=memo.version, + principal_sub=principal_sub, + locale=memo.locale, + ui_release=memo.ui_release, + rendered_at=_now(), + view_hash=view_hash(binding_doc)["hex"], + awareness_hash=awareness_hash(awareness_doc)["hex"], + phase=phase, + approval_id=memo.approval_id, + tenant=tenant, + principal_type=principal_type, + ) diff --git a/informed_decision/provenance.py b/informed_decision/provenance.py new file mode 100644 index 0000000..b356e53 --- /dev/null +++ b/informed_decision/provenance.py @@ -0,0 +1,91 @@ +"""Claim provenance — A-16 applied to claims this surface consumes. + +`key-cape` emits `tenant` and `principal_type` as bare strings. A consumer +cannot tell a value the *directory asserted about the person* from one a +*registration supplied about the client they came through*. + +`GH-DEC-2026-013` §5 requires the claim to carry its provenance. Until it does, +this surface records which route the value arrived by rather than storing an +undifferentiated string (PR-09), and never discharges a human-in-the-loop +control on a registration-supplied assertion of humanity (PR-11, +`GH-DEC-2026-016` §5). + +A-16's rider applies to this module: where the route marker is written by the +party whose conduct the route describes, it constrains a defect but not an +adversary. These markers are written by us about claims we received, which is +the semi-independent case — we gain nothing by mislabelling them. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class Route(str, Enum): + """How a claim value reached us. Never collapsed, never defaulted.""" + + #: Asserted by the directory about the person. Strong. + DIRECTORY = "directory-asserted" + #: Supplied by the client registration. The GH-DEC-2026-013 bounded gap. + REGISTRATION = "registration-supplied" + #: Derived from the authentication event itself. + AUTHENTICATION = "authentication-derived" + #: Present, but its route is not determinable. Never treated as any of the above. + INDETERMINATE = "indeterminate" + + +@dataclass(frozen=True) +class Claim: + """A claim value with the route it arrived by. + + There is no constructor that takes a value without a route. A claim whose + provenance is unknown is ``INDETERMINATE``, explicitly — not defaulted to + the strongest reading. + """ + + value: str + route: Route + + def __post_init__(self) -> None: + if not self.value: + raise ValueError("claim value must be non-empty") + + @property + def is_verified_about_the_person(self) -> bool: + """True only where the claim was asserted about the *person*. + + A registration-supplied claim says something about the client the person + came through, not about the person. `GH-DEC-2026-016` §5: refusing a + service principal while accepting an unverified assertion of humanity + moves the defect rather than closing it. + """ + return self.route in (Route.DIRECTORY, Route.AUTHENTICATION) + + +class HumanControlNotDischargeable(Exception): + """Raised when a human-in-the-loop control cannot be discharged. + + Not an authorization decision. This surface is not saying the actor may not + act; it is saying *this claim cannot carry that weight*. + """ + + +def assert_human_control_dischargeable(principal_type: Claim) -> None: + """Guard for `GH-DEC-2026-016` §5 / PR-11. + + Today `principal_type: human` is a property of the client registration, so + this raises. That is correct and deliberate: the guard exists so the + limitation is visible at the point of use rather than buried in a document. + """ + if principal_type.value != "human": + raise HumanControlNotDischargeable( + f"principal_type is {principal_type.value!r}, not 'human'" + ) + if not principal_type.is_verified_about_the_person: + raise HumanControlNotDischargeable( + "principal_type 'human' arrived by " + f"{principal_type.route.value}; a human-in-the-loop control must not " + "be discharged on a claim that describes the client rather than the " + "person (GH-DEC-2026-016 §5, A-16)" + ) diff --git a/tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d5a6e7a8502085ccdd6531cfce816cf9a1f47a6 GIT binary patch literal 57576 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 Memo: + kw = dict( + id="memo-1", + version=1, + question="Approve rotation of the production database credential for T-1183?", + requested_act="approve", + binding_level=BindingLevel.ORGANIZATIONAL, + brief="The credential is 400 days old.", + binding=BindingSlice( + principal=Principal(id="p-1", kind="person", display_name="Bernd Worsch"), + target=Scope(kind="tenant", id="tenant:acme", label="ACME", environment="prod"), + ), + step_kind=StepKind.APPROVE, + packet=(PacketItem("doc-1", "Change request", "sha256:" + "a" * 64),), + highlights=( + Highlight("h-1", "doc-1", "Target is production", required_ack=True, severity="critical"), + ), + approval_id="appr-1", + ) + kw.update(over) + return Memo(**kw) + + +# ------------------------------------------------------------------------- +# Happy path +# ------------------------------------------------------------------------- + + +def test_one_approval_end_to_end_in_process(): + memo = make_memo() + engine = FakeApprovalEngine({"appr-1": {"status": "requested", "required_count": 1}}) + outbox = Outbox() + + pres = render( + memo, + principal_sub="bernd", + tenant=Claim("tenant:platform", Route.REGISTRATION), + principal_type=Claim("human", Route.REGISTRATION), + ) + outbox.append(commit_presentation(pres, custody=CUSTODY)) + + pres = pres.with_ack("h-1") + disp = record(memo, pres, Verb.ACCEPT, HUMAN) + outbox.append(commit_disposition(disp, custody=CUSTODY)) + + assert disp.reaches_approval_engine + result = engine.add_entry(memo.approval_id, "bernd") + + assert result.correlation == ("appr-1", "bernd", "2026-09-10T15:00:00Z") + assert result.status == "approved" + assert len(outbox.pending) == 2 + + +def test_the_presentation_is_reachable_from_the_correlation_triple(): + """DoD-3 — satisfied by the triple, not by a hash on the entry.""" + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + engine = FakeApprovalEngine({"appr-1": {"status": "requested", "required_count": 1}}) + result = engine.add_entry("appr-1", "bernd") + assert pres.approval_id == result.approval_id + assert pres.principal_sub == result.subject + assert pres.view_hash + + +# ------------------------------------------------------------------------- +# NC-01 — accept on a Kenntnisnahme step +# ------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kind", [StepKind.INFORM, StepKind.COMMENT, StepKind.REVIEW, StepKind.ACKNOWLEDGE] +) +def test_accept_is_absent_from_weak_steps_not_merely_disabled(kind): + assert Verb.ACCEPT not in legal_verbs(kind) + assert Verb.ACKNOWLEDGE in legal_verbs(kind) + + +def test_accept_on_a_weak_step_is_refused_at_the_api(): + memo = make_memo(step_kind=StepKind.REVIEW, highlights=()) + pres = render(memo, principal_sub="bernd") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, Verb.ACCEPT, HUMAN) + assert e.value.guard == "G_STEP" + assert "Kenntnisnahme is not approval" in str(e.value) + + +# ------------------------------------------------------------------------- +# NC-02 — bind with unacknowledged required highlights +# ------------------------------------------------------------------------- + + +def test_bind_without_required_ack_fails_closed_and_creates_nothing(): + memo = make_memo() + pres = render(memo, principal_sub="bernd") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, Verb.ACCEPT, HUMAN) + assert e.value.guard == "G_ACK" + assert "h-1" in str(e.value) + + +def test_acknowledgment_is_explicit_never_inferred(): + memo = make_memo() + pres = render(memo, principal_sub="bernd") + assert pres.acked_highlight_ids == frozenset() + assert pres.with_ack("h-1").acked_highlight_ids == {"h-1"} + + +# ------------------------------------------------------------------------- +# NC-03 — agents never bind. There is no upstream backstop. +# ------------------------------------------------------------------------- + + +@pytest.mark.parametrize("verb", [Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE]) +def test_agent_cannot_perform_a_binding_verb(verb): + memo = make_memo() + pres = render(memo, principal_sub="bot").with_ack("h-1") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, verb, AGENT) + assert e.value.guard == "G_NOAGENT" + + +def test_agent_may_still_comment(): + memo = make_memo() + pres = render(memo, principal_sub="bot") + assert record(memo, pres, Verb.COMMENT, AGENT).verb is Verb.COMMENT + + +# ------------------------------------------------------------------------- +# NC-05 / NC-06 — the binding/awareness split, on live objects +# ------------------------------------------------------------------------- + + +def test_awareness_never_enters_view_hash(): + memo = make_memo() + plain = render(memo, principal_sub="bernd") + oriented = render( + memo, + principal_sub="bernd", + awareness=Awareness( + proposed_hat=Hat("hat:fc", "Finance Controller"), + proposed_hat_source="last_used", + situation_note="changed after the presentation was taken", + ), + ) + assert plain.view_hash == oriented.view_hash + assert plain.awareness_hash != oriented.awareness_hash + + +def test_changing_the_act_scope_changes_view_hash(): + memo = make_memo() + before = render(memo, principal_sub="bernd").view_hash + moved = memo.next_version( + binding=BindingSlice( + principal=memo.binding.principal, + target=Scope(kind="tenant", id="tenant:beta", label="Beta GmbH"), + ) + ) + assert render(moved, principal_sub="bernd").view_hash != before + + +def test_the_tenant_claim_is_not_the_act_scope(): + """PR-08 — two facts, one field upstream; never conflated here.""" + memo = make_memo() + a = render(memo, principal_sub="bernd", tenant=Claim("tenant:platform", Route.REGISTRATION)) + b = render(memo, principal_sub="bernd", tenant=Claim("tenant:coulomb", Route.DIRECTORY)) + assert a.view_hash == b.view_hash, "the tenant claim must not reach view_hash" + assert memo.binding.target.id == "tenant:acme" + + +# ------------------------------------------------------------------------- +# NC-07 — no silent version upgrade +# ------------------------------------------------------------------------- + + +def test_bind_against_a_stale_presentation_is_refused(): + memo = make_memo() + stale = render(memo, principal_sub="bernd").with_ack("h-1") + advanced = memo.next_version(brief="revised") + with pytest.raises(DispositionRefused) as e: + record(advanced, stale, Verb.ACCEPT, HUMAN) + assert e.value.guard == "G_PRES" + + +# ------------------------------------------------------------------------- +# NC-08 — return is structured, and is not decline +# ------------------------------------------------------------------------- + + +def test_return_without_coded_reasons_is_refused(): + memo = make_memo() + pres = render(memo, principal_sub="bernd") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, Verb.RETURN, HUMAN, note="please clarify") + assert e.value.guard == "G_REASONS" + + +def test_return_is_distinguishable_from_decline_and_never_reaches_the_engine(): + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + returned = record(memo, pres, Verb.RETURN, HUMAN, reasons=("insufficient-context",)) + declined = record(memo, pres, Verb.DECLINE, HUMAN) + assert returned.verb is not declined.verb + assert not returned.reaches_approval_engine + assert not declined.reaches_approval_engine + + +def test_only_accept_reaches_the_engine(): + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + assert record(memo, pres, Verb.ACCEPT, HUMAN).reaches_approval_engine + for verb in (Verb.COMMENT, Verb.DISCUSS, Verb.FORWARD, Verb.ESCALATE): + assert not record(memo, pres, verb, HUMAN).reaches_approval_engine + + +# ------------------------------------------------------------------------- +# Engine semantics +# ------------------------------------------------------------------------- + + +def test_duplicate_approver_is_success_not_failure(): + assert is_success(ApprovalEngineError(409, "duplicate_approver")) + assert not is_success(ApprovalEngineError(409, "conflict")) + assert not is_success(ApprovalEngineError(503, "store_unavailable")) + + +def test_consume_scope_is_refused_before_a_token_is_requested(): + with pytest.raises(ValueError, match="approval:consume"): + assert_scopes_permissible(("openid", "approval:read", "approval:consume")) + + +def test_store_unavailable_fails_closed(): + engine = FakeApprovalEngine({"appr-1": {"status": "requested"}}) + engine.available = False + with pytest.raises(ApprovalEngineError) as e: + engine.add_entry("appr-1", "bernd") + assert e.value.status == 503 + + +def test_terminal_approval_conflicts(): + engine = FakeApprovalEngine({"appr-1": {"status": "revoked"}}) + with pytest.raises(ApprovalEngineError) as e: + engine.add_entry("appr-1", "bernd") + assert (e.value.status, e.value.reason) == (409, "conflict") + + +# ------------------------------------------------------------------------- +# GH-DEC-2026-016 §5 / PR-11 — humanity provenance +# ------------------------------------------------------------------------- + + +def test_human_control_not_dischargeable_on_a_registration_supplied_claim(): + with pytest.raises(HumanControlNotDischargeable, match="registration-supplied"): + assert_human_control_dischargeable(Claim("human", Route.REGISTRATION)) + + +@pytest.mark.parametrize("route", [Route.DIRECTORY, Route.AUTHENTICATION]) +def test_human_control_dischargeable_when_asserted_about_the_person(route): + assert_human_control_dischargeable(Claim("human", route)) + + +def test_service_principal_is_refused_outright(): + with pytest.raises(HumanControlNotDischargeable, match="not 'human'"): + assert_human_control_dischargeable(Claim("service", Route.DIRECTORY)) + + +def test_claims_are_stored_with_their_route_never_bare(): + memo = make_memo() + pres = render( + memo, + principal_sub="bernd", + tenant=Claim("tenant:platform", Route.REGISTRATION), + principal_type=Claim("human", Route.REGISTRATION), + ) + data = commit_presentation(pres, custody=CUSTODY).data + assert data["tenant"] == {"value": "tenant:platform", "route": "registration-supplied"} + assert data["principal_type"]["route"] == "registration-supplied" + + +# ------------------------------------------------------------------------- +# GH-DEC-2026-014 §4 — the existence assertion, and PR-12 +# ------------------------------------------------------------------------- + + +def test_every_commitment_carries_the_existence_assertion(): + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + disp = record(memo, pres, Verb.ACCEPT, HUMAN) + for c in ( + commit_presentation(pres, custody=CUSTODY), + commit_disposition(disp, custody=CUSTODY), + commit_stance_application( + memo_id=memo.id, memo_version=1, binding_level="organizational", + binding_level_state="present", stance="fail_closed", + dependency="access-engine", custody=CUSTODY, + ), + ): + assert c.data["content_exists"] is True + assert c.data["custody"] == CUSTODY + + +def test_commitment_carries_no_content(): + """Commitment-only: never the brief, never the packet.""" + memo = make_memo() + data = commit_presentation(render(memo, principal_sub="bernd"), custody=CUSTODY).data + flat = str(data) + assert memo.brief not in flat + assert "Change request" not in flat + assert data["view_hash"] + + +@pytest.mark.parametrize( + "locator", + [ + "https://user:pw@store.example/obj", + "https://store.example/obj?token=abc123", + "https://store.example/o?access_token=x", + "", + ], +) +def test_secret_shaped_custody_locators_are_rejected(locator): + with pytest.raises(CustodyLocatorRejected): + assert_custody_locator_safe(locator) + + +def test_stable_identifier_custody_locator_is_accepted(): + assert_custody_locator_safe("informed-decision:memo/memo-1/presentation/pres-9") + + +# ------------------------------------------------------------------------- +# Stance application is not a decline +# ------------------------------------------------------------------------- + + +def test_fail_closed_is_recorded_as_a_stance_not_as_a_decline(): + c = commit_stance_application( + memo_id="memo-1", memo_version=1, binding_level=None, + binding_level_state="absent", stance="fail_closed", + dependency="access-engine", custody=CUSTODY, + ) + assert c.event_class is EventClass.STANCE_APPLICATION + assert "verb" not in c.data + assert c.data["stance_applied"] == "fail_closed" + + +def test_stance_application_pins_decision_attributable_false(): + """GH-DEC-2026-010 inherited: we cannot show access-engine said it.""" + c = commit_stance_application( + memo_id="m", memo_version=1, binding_level="organizational", + binding_level_state="present", stance="fail_closed", + dependency="access-engine", custody=CUSTODY, + ) + assert c.data["decision_attributable"] is False + + +# ------------------------------------------------------------------------- +# Outbox +# ------------------------------------------------------------------------- + + +def test_drain_failure_leaves_the_record_pending_never_lost(): + outbox = Outbox() + memo = make_memo() + outbox.append(commit_presentation(render(memo, principal_sub="b"), custody=CUSTODY)) + + def failing_sink(_): + raise RuntimeError("audit-core unreachable") + + with pytest.raises(RuntimeError): + outbox.drain(failing_sink) + assert len(outbox.pending) == 1 + assert outbox.drained == () + + +def test_drain_delivers_and_counts_reconcile_per_class(): + outbox = Outbox() + memo = make_memo() + pres = render(memo, principal_sub="b").with_ack("h-1") + outbox.append(commit_presentation(pres, custody=CUSTODY)) + outbox.append(commit_disposition(record(memo, pres, Verb.ACCEPT, HUMAN), custody=CUSTODY)) + sent: list[dict] = [] + assert outbox.drain(sent.append) == 2 + assert outbox.counts_by_class() == { + EventClass.PRESENTATION.value: 1, + EventClass.DISPOSITION.value: 1, + } + assert all("type" in e and "data" in e for e in sent) + + +def test_heartbeat_is_an_ordinary_event_with_the_same_envelope(): + hb = heartbeat(EventClass.DISPOSITION) + env = hb.as_envelope() + assert env["type"] == "audit-core.heartbeat" + assert env["data"] == { + "class": "informed-decision.disposition", + "assertion": "nothing-to-report", + } diff --git a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md index cebc804..73dfc5b 100644 --- a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md +++ b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md @@ -429,7 +429,7 @@ issuer, so it is not this repository's to decide alone. ```task id: INFD-WP-0001-T08 -status: todo +status: progress priority: medium state_hub_task_id: "b5c1d329-9580-5672-9640-2930cbbb729a" @@ -454,6 +454,39 @@ act is permitted. Gated externally on `approval-engine` `APPROVAL-WP-0002-T01` reaching `done` and on the service being deployed with an origin this surface can reach. +2026-09-10 — **domain core built and tested; the live proof remains gated.** +`approval-engine` `APPROVAL-WP-0002-T01` is still `progress` and the namespace +has no pods, so the end-to-end proof against a deployed engine cannot run. Built +everything that does not depend on it, with the engine behind a seam so its +arrival is a wiring change rather than a build: + +- `memo.py` — the Decision Memo, versions, and the binding document. + `Principal`, `Scope`, `Awareness` and `Hat` are dataclasses rather than dicts + because the canonicalizer requires a shape, and a missing key should fail at + construction rather than deep inside hashing. Field names follow the governed + canonicalizer (`item_id`, `severity`, `locator`) — the vectors are the + contract, so the object was aligned to them rather than the reverse. +- `presentation.py` — the **sole writer** of `view_hash`. Acknowledgment is an + explicit method call; nothing infers it. +- `disposition.py` — the verb vocabulary and guards. `accept` is **absent** from + weak steps rather than present-and-disabled, because a greyed-out accept still + teaches the wrong model. +- `provenance.py` — claim routes (A-16). `assert_human_control_dischargeable` + refuses a registration-supplied `human`, so PR-11's limitation is visible at + the point of use rather than buried in a document. +- `evidence.py` — the local outbox, commitment-only records carrying the + `GH-DEC-2026-014` §4 existence assertion, and a custody-locator guard that + rejects secret-shaped values (PR-12). +- `approval_client.py` — Protocol plus a fake with the engine's real semantics: + `409 duplicate_approver` is success, `409 conflict` terminal, `503` fail-closed, + and `approval:consume` refused before a token is ever requested. + +87 tests pass, including every negative case in the Use Case Catalog: NC-01 +through NC-08, the humanity-provenance guard, the existence assertion, and that +a fail-closed outcome is recorded as a stance application with no `verb` field — +never as a decline, because the human did not make one. + +Remaining for `done`: the live proof. Superseded context — 2026-09-09: additionally gated on `INFD-IN-0003` — `GH-DEC-2026-012` limit 3 requires the evidence copy to reach `audit-core` independently of this component, and the payload question is open. Design and decision request in