informed-decision/informed_decision/provenance.py
tegwick 0e48355b9f Add verified browser login and human approval HTTP client
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 22:05:17 +02:00

92 lines
3.6 KiB
Python

"""Claim provenance — A-16 applied to claims this surface consumes.
`key-cape` emits `tenant_source` alongside `tenant`. Its authorization-code
handler derives `principal_type=human` from the authenticated user flow; its
client-credentials handler emits `service`. These are distinct provenance
contracts, not two properties inferred from a client registration.
`GH-DEC-2026-013` §5 requires the claim to carry its provenance. 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.
The KeyCape login adapter supplies AUTHENTICATION only after verifying the
issuer's code-flow tokens. A bare or registration-supplied assertion still
cannot discharge the control.
"""
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)"
)