diff --git a/GOAL.md b/GOAL.md index 35612ad..e3cbe40 100644 --- a/GOAL.md +++ b/GOAL.md @@ -85,6 +85,11 @@ These hold for Stage 1 and for every stage after it. - **`approved` is never rendered as permission to act.** It is a state of an object. Presenting it as "you may now do the thing" is a policy decision point in the browser. +- **The decision path is not validated, and must not be described as such.** + `GH-DEC-2026-010` requires a decision be attributable to `access-engine`, and + no consumer can satisfy that today — the envelope is unsigned + (`FLEX-WP-0024`). This surface records that a decision was obtained and what + it said, and records `decision_attributable: false`. - **Entitlement to view is `access-engine`'s.** A `200` from `approval-engine` is not permission to see the approval; that engine never answers "may this actor do X". This surface obtains a decision before rendering and never @@ -108,7 +113,9 @@ These hold for Stage 1 and for every stage after it. > **Who owns the approver UI?** -The answer this repository proposes, to be ratified rather than assumed: +**Answered 2026-09-09 by `GH-DEC-2026-012`.** The proposal below was confirmed +in full. Recorded as proposed-then-ratified rather than rewritten as though it +had always been settled: - **informed-decision owns it** — the surface, the presentation record, the browser client, and the evidence of informedness. @@ -117,13 +124,20 @@ The answer this repository proposes, to be ratified rather than assumed: and statute §6.4. - It supplies exactly one PIP-like fact — *what was presented* — as a claim carrying `view_hash`, issuer and freshness, and it never evaluates that fact. -- The catalog row does not exist yet. `INFD-WP-0001-T02` asks `gate-house` and - writes `layer.yaml` from the ruling. If `gate-house` places this component - elsewhere, `INTENT.md` and this file change to match — the ruling wins. +- **Ruled:** PEP-shaped, confirmed as proposed. No second catalog row for the + presentation claim — PEP and PIP are shapes a repository has. The claim is + permitted under three limits (presentation-only; never an input to the + decision it presents for; evidence reaches `audit-core` independently), and + `view_hash` versus the binding digest is settled as distinct attestations with + an authority rule, linked by co-reference and never by nesting. + `layer.yaml` and `pep-stance.yaml` declare it; `INTENT.md` carries the + reasoning. ## Definition of done -1. `gate-house` has ruled on the layer placement and `layer.yaml` reflects it. +1. ~~`gate-house` has ruled on the layer placement and `layer.yaml` reflects + it.~~ **Done 2026-09-09** — `GH-DEC-2026-012`; `layer.yaml`, + `pep-stance.yaml` and `tests/test_layer_conformance.py`. 2. `key-cape` has the `client_id` and callback URI, and `KEY-WP-0013-T02` is unblocked with the answer traceable to this repository. 3. The four specs exist, are reviewed against the current `approval-engine`, diff --git a/INTENT.md b/INTENT.md index 6cf03c1..be508a3 100644 --- a/INTENT.md +++ b/INTENT.md @@ -7,7 +7,7 @@ date: "2026-09-09" domain: infotech topic_slug: netkingdom layer: surface -role: pep-shaped # PROVISIONAL — see "Layer Placement", INFD-WP-0001-T02 +role: pep-shaped # confirmed by GH-DEC-2026-012; see layer.yaml standard: net-kingdom/canon/standards/security-layer-model_v0.7.md companion: net-kingdom/SECURITY-COMPANION.md --- @@ -184,21 +184,69 @@ not failure** — a memo sent back for improvement is the surface working. presented view plus explicit highlight acknowledgment — never keystroke analytics, dwell timers, or attention theater. -## Layer Placement — Provisional +## Layer Placement — Ruled -Under security layer model v0.7 this repository is **not** an Engine. It is -browser-facing and it causes a protected side effect on the far side of a -decision, which is the shape statute §6.4 and companion §5 call **PEP-shaped**. -`approval-engine`'s own INTENT names this expectation: *"Callers that do are -PEP-shaped and owe companion §5 / statute §6.4."* +**PEP-shaped**, confirmed by `GH-DEC-2026-012` (answering `INFD-IN-0001`). +Not an Engine: this repository holds no state another layer reads at runtime for +a verdict, and renders no decision. Companion §5 is owed and statute §6.4 +applies in full. The declaration is `layer.yaml`, in this repository's own voice +— a layer someone else states about you is not a declaration. -This is declared **provisional** because the catalog row does not exist yet and -inventing one is the drift §17 exists to prevent. `INFD-WP-0001-T02` takes the -question to `gate-house` and writes `layer.yaml` from the answer, not from this -paragraph. The framing this repository will argue: informed-decision is a -**presentation and binding surface** — a PEP for the acts it fronts, and a PIP -for exactly one fact, *what was presented*, which it supplies as a claim and -never evaluates. +The ruling also confirmed that emitting a **presentation claim** does not +require a second catalog row: PEP and PIP are shapes a repository has, and §4 +records the layers it occupies. That permission carries three limits, and they +are the substance of it rather than caveats on it: + +1. **The claim carries presentation and nothing else.** It must never carry, + restate, summarise or imply the decision, the verdict, or whether the act was + permitted. A consumer learns from it only what was *shown*, never what was + *decided*. +2. **The claim must not be an input to the decision it presents for.** A policy + reading `view_hash` to decide whether an act is permitted would let the + presenting surface contribute to its own authorization. +3. **The evidence copy reaches `audit-core` independently of this repository.** + The claim endpoint and the evidence path are different things and neither + substitutes for the other. + +Limit 2 is load-bearing. `GH-DEC-2026-012` accepted this repository's argument +that a renderer attesting its own rendering is not the self-dealing that kept +the approval object out of `access-engine` — *but only because that limit +holds*. Without it, the two collapse into the same failure. + +Limit 3 is the one that most shapes the architecture: audit evidence is +protected from the actor being audited, and here the actor and the source are +the same component. + +### `view_hash` and the binding digest + +Ruled as option (b): they are **distinct attestations with an authority rule**. + +- `approval-engine`'s binding digest is authoritative for **what the request + is**. +- `view_hash` is authoritative for **what was shown**, and nothing else. +- Neither may be substituted for the other. +- **A disagreement between them is a finding against the presenting surface, + never a fact about the request.** + +They link by **co-reference**, not nesting: the presentation record carries the +approval or binding identifier explicitly, and both attestations are read +against that one reference. This repository must never recompute or restate +`approval-engine`'s binding digest in its own vocabulary — it references the +digest that layer computed and recorded. + +Nesting was refused for a reason worth carrying: it reproduces the hash cycle +that made `GH-DEC-2026-008` unimplementable, where a claim had to name the +digest of a request that would come to contain it, and a fail-closed consumer +obeying it would deny permanently. + +### An inherited gap, stated rather than glossed + +`GH-DEC-2026-010` requires a decision be **attributable** to `access-engine`. +No consumer can satisfy that today: the decision envelope is unsigned, a +declared §13 gap tracked as `FLEX-WP-0024`. This repository's record can show +that a decision was obtained and what it said; it cannot yet show that +`access-engine` said it. The decision path must not be described as validated +while that is open. ## What Would Make This Repository Wrong diff --git a/informed_decision/__pycache__/stance.cpython-312.pyc b/informed_decision/__pycache__/stance.cpython-312.pyc new file mode 100644 index 0000000..2256151 Binary files /dev/null and b/informed_decision/__pycache__/stance.cpython-312.pyc differ diff --git a/informed_decision/stance.py b/informed_decision/stance.py new file mode 100644 index 0000000..5e36a15 --- /dev/null +++ b/informed_decision/stance.py @@ -0,0 +1,56 @@ +"""The shipped unreachable-engine stance map. + +This is the map the surface actually applies. ``pep-stance.yaml`` publishes it, +and ``tests/test_layer_conformance.py`` asserts the two are equal — a published +map that may drift from the code invites reliance it cannot support. + +Built to v0.8 obligation 3 per GH-DEC-2026-012: the axis is enumerated rather +than defaulted, ``unknown`` resolves to ``fail_closed``, and an absent scope is +distinguishable in the record from an unknown one. +""" + +from __future__ import annotations + +from enum import Enum + +AXIS = "binding_level" + +#: Total by enumeration, not by catch-all. See ``pep-stance.yaml`` for why every +#: stance is ``fail_closed`` here where ops-warden can justify ``fail_open``. +STANCE: dict[str, str] = { + "acknowledgment": "fail_closed", + "organizational": "fail_closed", + "aes": "fail_closed", + "qes": "fail_closed", + "unknown": "fail_closed", + "absent": "fail_closed", +} + +#: Values of the axis proper — the two non-value outcomes are not axis values. +AXIS_VALUES: tuple[str, ...] = ("acknowledgment", "organizational", "aes", "qes") + + +class BindingLevelState(str, Enum): + """How the axis value was obtained. Recorded; never collapsed. + + ``ABSENT`` and ``UNKNOWN`` resolve to the same stance but must never be + recorded as the same fact: collapsing them hides a schema-drift incident + inside a malformed-input statistic. + """ + + PRESENT = "present" + ABSENT = "absent" + UNKNOWN = "unknown" + + +def resolve(binding_level: str | None) -> tuple[str, BindingLevelState]: + """Return ``(stance, state)`` for a memo's ``binding_level``. + + There is no per-call discretion and no implicit default: an unlisted value + is resolved explicitly to the ``unknown`` stance, never permissively. + """ + if binding_level is None or binding_level == "": + return STANCE["absent"], BindingLevelState.ABSENT + if binding_level not in AXIS_VALUES: + return STANCE["unknown"], BindingLevelState.UNKNOWN + return STANCE[binding_level], BindingLevelState.PRESENT diff --git a/layer.yaml b/layer.yaml new file mode 100644 index 0000000..7af1ea4 --- /dev/null +++ b/layer.yaml @@ -0,0 +1,184 @@ +# informed-decision — NetKingdom security layer declaration +# +# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md +# Companion: net-kingdom/SECURITY-COMPANION.md v0.2 +# Voice: INTENT.md (this repository's own, per §11 "who must declare") +# Ruling: GH-DEC-2026-012 (gate-house@0a1d1d9) answered INFD-IN-0001 +# +# Reference form: ops-warden's, adopted by audit-core and kings-guard, with +# kings-guard's adaptation for a repository with no Tooling contacts. +# +# GH-DEC-2026-012 R1 confirmed the SHAPE. The layer is declared here, in this +# repository's own voice, because a layer someone else states about you is not +# a declaration. + +schema_version: "0.1" +framework: netkingdom-security-layer-model +standard_version: "0.7" +companion_version: "0.2" +repository: informed-decision +layer: surface +role: pep-shaped +declared_by: INTENT.md +declared_at: "2026-09-09" +ruling: GH-DEC-2026-012 + +# §6.4 — informed-decision is PEP-shaped: it causes a protected side effect on +# the far side of a decision (recording an approver entry against an approval +# object). Companion §5 is owed and §6.4 applies in full. +# +# Built to v0.8 obligation 3, not v0.7, per GH-DEC-2026-011 — see pep-stance.yaml. +pep_stance: pep-stance.yaml + +protected_action: "Approver entry recorded against an approval object (POST /v1/approvals/{id}/entries)" +decision_engine: access-engine + +# §6 — no repository other than access-engine exposes an authorization decision. +# This surface renders a question and records a human's answer. A disposition is +# evidence of an act, never a verdict. +decision_surfaces_exposed: none + +# §3.3 / GH-DEC-2026-012 R2 — YES to a presentation claim, and NO second catalog +# row: PEP and PIP are shapes a repository has; §4 records the layers it +# occupies. The permission carries three limits, and they are the substance of +# it rather than caveats on it. +presentation_claim: + emitted: true + carries: presentation-only + limits: + - id: L1-presentation-only + rule: >- + The claim carries presentation and nothing else. It MUST NOT carry, + restate, summarise or imply the decision, the verdict, or whether the + act was permitted. A consumer learns from it only what was SHOWN, never + what was DECIDED. + - id: L2-not-an-input + rule: >- + The claim MUST NOT be an input to the decision it presents for. A policy + reading view_hash to decide whether an act is permitted would let the + presenting surface contribute to its own authorization. + note: >- + Load-bearing, not a formality. GH-DEC-2026-012 accepted this + repository's argument that a renderer attesting its own rendering is not + the self-dealing that kept the approval object out of access-engine — + but only because this limit holds. Without it the two collapse into the + same failure. + - id: L3-independent-evidence-path + rule: >- + The evidence copy reaches audit-core INDEPENDENTLY of this repository. + The claim endpoint and the evidence path are different things and + neither substitutes for the other. The copy that is evidence MUST NOT be + reachable only through the party it is evidence about. + note: >- + The limit that matters most here: audit evidence is protected from the + actor being audited, and in this component the actor and the source are + the same. Architecture consequence, tracked in + docs/specs/ArchitectureBlueprint.md. + +# §17 — the shared request-claim schema is still unowned. This repository +# publishes at its own boundary and yields to that schema when it exists. +# Position accepted by GH-DEC-2026-012 and matching approval-engine's in +# APPROVAL-IN-0001. +request_claim_schema: + status: unowned-upstream + local_shape: published-at-own-boundary + yields_to: taxonomy-request-claim-schema + +# GH-DEC-2026-012 R3 — (b), with the authority rule written down. +binding_digest_relationship: + ruling: GH-DEC-2026-012 + view_hash_authoritative_for: what-was-shown + binding_digest_authoritative_for: what-the-request-is + binding_digest_owner: approval-engine + substitutable: false + disagreement_is: >- + A finding against the presenting surface, never a fact about the request. + linkage: co-reference + 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. + 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. + +# §5 applies to Staff. This is a browser-facing surface with no Tooling contact. +tooling_contacts: [] + +# §11 — record non-Tooling clients so the check is total. +non_tooling_clients: [] + +intended_non_tooling_clients: + - target: approval-engine + layer: engine + rationale: >- + GET /v1/approvals/{id} and /claim (approval:read) to render; POST + /v1/approvals/{id}/entries (approval:approve) to record a binding. Never + /consume. Requirements: approval-engine/docs/approver-surface-requirements.md. + - target: access-engine + layer: engine + rationale: >- + Decision consumed before rendering an approval to a person. A 200 from + approval-engine is not entitlement. This surface consumes a decision and + never renders one. + - target: key-cape + layer: engine + rationale: >- + Identity. Authorization-code + PKCE browser client. Identity is imported, + never invented here. + - target: audit-core + layer: engine + rationale: >- + Evidence destination for presentation records and dispositions. Must be an + independent path per limit L3-independent-evidence-path. + - target: state-hub + layer: not-catalogued + rationale: >- + Progress events. Outside §5 by the v0.5 scope rule. Recorded, not policed. + +# §9.6 — presentation evidence is load-bearing: it is the only record of what a +# human was shown before binding. Atomicity and attestation cover accident and +# later tampering, never a compromised source. +evidence: + kind: load-bearing + residual: compromised-surface-presents-x-attests-y + residual_closed: false + custody: same-bound-as-every-other-source # §16 decided: no stronger archive + note: >- + GH-DEC-2026-012 states the residual is not closed in those words, and this + repository is not credited with closing it. Same disposition as + approval-engine's equivalent residual for adversarial omission at a + compromised source. + +# INHERITED DECLARED GAP — GH-DEC-2026-010. +# +# Obligation 1 now requires a decision be ATTRIBUTABLE to access-engine. No +# consumer can satisfy that today: flex-auth's decision envelope is unsigned. +# This is a declared §13 gap tracked as FLEX-WP-0024, not a clean path this +# repository can walk. +# +# Stated here, and in SCOPE.md and ArchitectureBlueprint.md, because +# GH-DEC-2026-012 requires it be said in this repository's own documents rather +# than describing validation as complete. +inherited_gaps: + - id: GH-DEC-2026-010-attributability + obligation: 1 + gap: >- + A decision consumed from access-engine cannot today be proven to have come + from access-engine — the envelope is unsigned. + tracked_by: FLEX-WP-0024 + consequence_here: >- + This surface's record can show that a decision was obtained and what it + said. It cannot yet show it was access-engine that said it. Validation of + the decision path MUST NOT be described as complete while this is open. + status: open + +declared_shapes: + "5.1": [] + "5.2": [] + "5.3": [] diff --git a/pep-stance.yaml b/pep-stance.yaml new file mode 100644 index 0000000..b078870 --- /dev/null +++ b/pep-stance.yaml @@ -0,0 +1,110 @@ +# informed-decision — PEP unreachable-engine stance map +# +# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md §6.4, §9.3, §9.7 +# Built to: v0.8 obligation 3 (GH-DEC-2026-011), not v0.7 — per GH-DEC-2026-012, +# so this is built to rather than migrated to. +# Ruling: GH-DEC-2026-012 +# Validate: pytest tests/test_layer_conformance.py +# +# §6.4 obligation 3 requires a declared unreachable-engine stance that is total, +# scoped per zone, carries no implicit default and no per-call discretion, and is +# published rather than held in code comments. A map that lives only in a module +# default is not published — it is merely written down. +# +# The property that makes this worth reading: it is asserted equal to the shipped +# map in informed_decision/stance.py by tests/test_layer_conformance.py. A +# published map that may drift from the code is worse than none, because it +# invites reliance it cannot support. + +schema_version: "0.1" +framework: netkingdom-security-layer-model +standard_version: "0.7" +built_to_obligation_3_of: "0.8" +repository: informed-decision +pep_shape: true +declared_by: layer.yaml +ruling: GH-DEC-2026-012 + +protected_action: "Approver entry recorded against an approval object" +decision_engine: access-engine + +# v0.8 obligation 3: the map MUST enumerate its axis rather than lean on a +# catch-all. The axis here is the memo's binding_level — this repository's own +# graded property, and the one that determines how costly a wrong bind is. +axis: binding_level +axis_source: decision-memo.schema.json#/properties/binding_level + +# Total by enumeration, not by catch-all. Every value of the axis appears +# explicitly, plus the two non-value outcomes required to be distinguishable. +# +# Every stance is fail_closed, and that is a conclusion rather than a shortcut. +# ops-warden can justify fail_open on some zones because refusing removes +# production host access to close a documentation gap — a continuity argument. +# No such argument exists here. Refusing to render or to bind delays a decision; +# it does not remove a capability anyone depends on to keep a system running. +# Binding a human's identity to an act without an authorization decision is the +# precise failure this repository exists to prevent, so there is no level at +# which proceeding-without-a-decision is the safer error. +stance: + acknowledgment: fail_closed + organizational: fail_closed + aes: fail_closed + qes: fail_closed + unknown: fail_closed # v0.8 obligation 3: unknown MUST resolve to fail_closed + absent: fail_closed # distinct from unknown in the record — see below + +# v0.8 obligation 3 requires an absent scope to be DISTINGUISHABLE in the record +# from an unknown one. They resolve to the same stance here but must never be +# recorded as the same fact: +# absent — the memo carried no binding_level at all (a defect in the memo) +# unknown — a binding_level was present but is not a value this map enumerates +# (a version skew between memo and this map) +# Collapsing them would hide a schema-drift incident inside a malformed-input +# statistic. +scope_absence: + distinguishable: true + recorded_as: + absent: "binding_level.absent" + unknown: "binding_level.unknown" + +# §6.4 obligation 1 requires a decision record for a protected side effect. +# Where the engine is unreachable there is no decision to hold, so this surface +# records the APPLICATION OF THE STANCE instead. +on_apply: + recorded_fields: + - memo_id + - memo_version + - binding_level + - binding_level_state # present | absent | unknown + - stance_applied + - decision_id # only where a decision was actually rendered + - decision_attributable # see inherited gap below + - presentation_id + user_visible: >- + The approver is told the act could not be authorized and that nothing was + bound. The surface never presents a fail_closed outcome as a decline by the + approver: no disposition is recorded, because the human did not make one. + +# GH-DEC-2026-010, inherited. Obligation 1 requires a decision be ATTRIBUTABLE to +# access-engine, and no consumer can satisfy that today — flex-auth's envelope is +# unsigned (declared §13 gap, FLEX-WP-0024). `decision_attributable` is therefore +# recorded as false and MUST NOT be reported as satisfied while that is open. +inherited_gap: + id: GH-DEC-2026-010-attributability + decision_attributable_today: false + tracked_by: FLEX-WP-0024 + +# §13.1 / GH-DEC-2026-011 — a dated classification-coverage figure beside the +# stance. Honest figure for a repository at spec stage: the axis is fully +# enumerated against the schema, and no protected action has shipped yet. +classification_coverage: + as_of: "2026-09-09" + axis_values_enumerated: 4 + axis_values_in_schema: 4 + coverage: "4/4 (100%) of binding_level values enumerated" + protected_actions_shipped: 0 + note: >- + Coverage is complete against the schema, not against production behaviour: + nothing is deployed. Re-date this figure when the walking skeleton + (INFD-WP-0001-T08) ships, and treat a coverage figure that predates a schema + change as stale. diff --git a/tests/__pycache__/test_layer_conformance.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_layer_conformance.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..c52c2ba Binary files /dev/null and b/tests/__pycache__/test_layer_conformance.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py new file mode 100644 index 0000000..312c24d --- /dev/null +++ b/tests/test_layer_conformance.py @@ -0,0 +1,182 @@ +"""Layer and stance conformance. + +GH-DEC-2026-012 confirmed this repository is PEP-shaped and told it to build to +v0.8 obligation 3 rather than migrate to it later. These tests pin the parts of +that obligation a test can actually hold: + +- the published map equals the shipped map (obligation 3, a MUST); +- the axis is enumerated, not defaulted; +- ``unknown`` resolves to ``fail_closed``; +- an absent scope is distinguishable in the record from an unknown one; +- the inherited GH-DEC-2026-010 attributability gap is declared open rather + than described as satisfied. +""" + +from __future__ import annotations + +import pathlib + +import pytest + +yaml = pytest.importorskip("yaml") + +from informed_decision.stance import AXIS, AXIS_VALUES, STANCE, BindingLevelState, resolve + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def load(name: str) -> dict: + return yaml.safe_load((ROOT / name).read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def stance_doc() -> dict: + return load("pep-stance.yaml") + + +@pytest.fixture(scope="module") +def layer_doc() -> dict: + return load("layer.yaml") + + +# -------------------------------------------------------------------------- +# Obligation 3 — published equals shipped. This is the MUST. +# -------------------------------------------------------------------------- + + +def test_published_stance_equals_shipped_stance(stance_doc): + assert stance_doc["stance"] == STANCE, ( + "pep-stance.yaml has drifted from informed_decision/stance.py. " + "A published map that may differ from the code is worse than none." + ) + + +def test_published_axis_equals_shipped_axis(stance_doc): + assert stance_doc["axis"] == AXIS + + +# -------------------------------------------------------------------------- +# Obligation 3 — totality by enumeration, no catch-all, no implicit default. +# -------------------------------------------------------------------------- + + +def test_every_axis_value_has_an_explicit_stance(): + for value in AXIS_VALUES: + assert value in STANCE, f"{value} has no declared stance" + + +def test_stance_map_has_no_entries_beyond_the_axis_and_the_two_outcomes(): + allowed = set(AXIS_VALUES) | {"unknown", "absent"} + assert set(STANCE) == allowed + + +def test_unknown_resolves_to_fail_closed(stance_doc): + """v0.8 obligation 3 makes this a MUST; v0.7 permitted fail_open.""" + assert STANCE["unknown"] == "fail_closed" + assert stance_doc["stance"]["unknown"] == "fail_closed" + + +@pytest.mark.parametrize("value", sorted(STANCE)) +def test_no_stance_is_permissive(value): + """Not required by the standard — required by this repository. + + Binding an identity without an authorization decision is the failure this + surface exists to prevent, so there is no level at which proceeding is the + safer error. If this test is ever relaxed, the reasoning in pep-stance.yaml + must be rewritten first. + """ + assert STANCE[value] == "fail_closed" + + +# -------------------------------------------------------------------------- +# Obligation 3 — absent must be distinguishable from unknown IN THE RECORD, +# even though both resolve to the same stance. +# -------------------------------------------------------------------------- + + +def test_absent_and_unknown_resolve_the_same_but_record_differently(): + absent_stance, absent_state = resolve(None) + unknown_stance, unknown_state = resolve("notalevel") + assert absent_stance == unknown_stance == "fail_closed" + assert absent_state is BindingLevelState.ABSENT + assert unknown_state is BindingLevelState.UNKNOWN + assert absent_state != unknown_state + + +def test_empty_string_is_absent_not_unknown(): + assert resolve("")[1] is BindingLevelState.ABSENT + + +@pytest.mark.parametrize("value", AXIS_VALUES) +def test_known_axis_values_record_as_present(value): + stance, state = resolve(value) + assert state is BindingLevelState.PRESENT + assert stance == "fail_closed" + + +def test_published_map_declares_the_two_states_distinguishable(stance_doc): + absence = stance_doc["scope_absence"] + assert absence["distinguishable"] is True + assert absence["recorded_as"]["absent"] != absence["recorded_as"]["unknown"] + + +# -------------------------------------------------------------------------- +# Inherited gap — GH-DEC-2026-010. Must be declared open, not glossed. +# -------------------------------------------------------------------------- + + +def test_attributability_gap_is_declared_open_in_the_stance(stance_doc): + gap = stance_doc["inherited_gap"] + assert gap["decision_attributable_today"] is False + assert gap["tracked_by"] == "FLEX-WP-0024" + + +def test_attributability_gap_is_declared_open_in_the_layer(layer_doc): + gaps = {g["id"]: g for g in layer_doc["inherited_gaps"]} + gap = gaps["GH-DEC-2026-010-attributability"] + assert gap["status"] == "open" + + +def test_stance_records_whether_the_decision_was_attributable(stance_doc): + assert "decision_attributable" in stance_doc["on_apply"]["recorded_fields"] + + +# -------------------------------------------------------------------------- +# R1 / R2 / R3 — the ruling's limits are declared, not merely remembered. +# -------------------------------------------------------------------------- + + +def test_layer_declares_pep_shaped_and_no_decision_surface(layer_doc): + assert layer_doc["role"] == "pep-shaped" + assert layer_doc["decision_surfaces_exposed"] == "none" + + +def test_presentation_claim_carries_all_three_limits(layer_doc): + limits = {limit["id"] for limit in layer_doc["presentation_claim"]["limits"]} + assert limits == { + "L1-presentation-only", + "L2-not-an-input", + "L3-independent-evidence-path", + } + + +def test_binding_digest_relationship_is_co_reference_not_nesting(layer_doc): + rel = layer_doc["binding_digest_relationship"] + assert rel["linkage"] == "co-reference" + assert rel["substitutable"] is False + assert "nesting_forbidden" in rel + + +def test_residual_is_declared_not_closed(layer_doc): + assert layer_doc["evidence"]["residual_closed"] is False + + +# -------------------------------------------------------------------------- +# §13.1 / GH-DEC-2026-011 — a dated coverage figure beside the stance. +# -------------------------------------------------------------------------- + + +def test_classification_coverage_is_dated_and_complete_against_the_axis(stance_doc): + cov = stance_doc["classification_coverage"] + assert cov["as_of"] + assert cov["axis_values_enumerated"] == cov["axis_values_in_schema"] == len(AXIS_VALUES) 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 0b26cc3..760da69 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 @@ -94,7 +94,7 @@ Two corrections made during the same task, recorded rather than silently fixed: ```task id: INFD-WP-0001-T02 -status: progress +status: done priority: high state_hub_task_id: "4f94134b-2260-5404-84e0-12f2b08ef565" ```