core/docs/decisions/ADR-0222-frame-verdict-closed-world.md
Shay ea7b2d5cbd
docs(adr): ratify ADR-0222 FrameVerdict closed-world design
Ratifies ADR-0222 as the design-only B4 artifact for FrameVerdict: a frame-general closed-world verdict type distinct from open-world Determined.

No runtime type, entry point, lane, or answer=False path is added in this PR. Implementation remains gated behind PR-1 with INV-31, the landed OWA refusal floor, and the staged plan in the ADR.

Final ratification patches included:
- positive_refutation_kind discriminator for entailed_false admissibility;
- PR-1-scoped Determined count wording;
- grounded-negative governance resolution as COMMIT-with-negative-surface at EpistemicState.INFERRED + DisclosureClaim.NONE.
2026-06-15 13:09:35 -07:00

46 KiB
Raw Blame History

ADR-0222: FrameVerdict — Frame-General Closed-World Verdict

Status: Proposed (design-only). Implementation is gated behind two preconditions: (a) the ProofWriter-OWA refusal floor has landed — met (evals/proofwriter_owa/, PR #779) — and (b) ratification of this ADR. The design PR ships only this document; no runtime type, no entry point, no lane, no answer=False path, and no change to generate/determine/determine.py lands until the implementation plan in §10 is authorized. Date: 2026-06-15 Domains: generate/frame_verdict/ (proposed, new), generate/proof_chain/, sensorium/environment/, core/response_governance/, core/epistemic_disclosure/, evals/proofwriter_cwa/ (proposed, future) Depends on: ADR-0211 (Conformal Falsification Bench — perceptual closed-frame verdict), ADR-0206 (response governance / ReachLevel / shape_surface), ADR-0201/0202 (ROBDD canonicalizer), ADR-02030205 (proof_chain entailment), ADR-0175 (calibrated learning / serving gate), ADR-0218 (proof-carrying promotion). Builds on INV-25/INV-27 (independent gold + transitive-import disjointness), INV-29 (status-transition allowlist), INV-30 (open-world determine never asserts False), and the merged Step-3 brief pack (#777, brief B4). Authors: drafted by agent for architect review (Joshua Shay ratifies). This draft incorporates an adversarial review pass (6 independent skeptics against real source); the soundness corrections it produced are folded in and flagged inline as [adv]. The architect approved the direction and resolved §14's two open questions; those resolutions and the requested precision patches (explicit positive_refutation_kind discriminator; PR-1-scoped Determined-count wording) are applied and flagged [ratify].


1. Context

CORE's open-world determination gear is sound by a hard firewall: determine() (generate/determine/determine.py) constructs only Determined(answer=True) or returns Undetermined(reason=…). Absence of a fact never refutes it. INV-30 (tests/test_architectural_invariants.py) mechanically enforces this: an AST scan over the whole source tree fails the build if any Determined(…) construction sets answer to anything but the literal True — there are exactly three sites, all in determine.py, all answer=True, and test_determine_construction_sites_are_visible pins the count. (The adversarial review ran the real _determined_constructions predicate against the proposed type and confirmed it returns [] — see §11.)

This is correct for the open world. But three real capabilities need a sound closed-world "No" — an entailed negation:

  • ProofWriter-CWA / FOLIO two-sided labels ({True, Unknown, False}) where the fact set is declared complete and False is a real answer, not a refusal.
  • Perceptual falsification (ADR-0211): an ExpectedObservationFrame is a closed, completely-enumerated expectation; an expected slot observed with a contradicting identity refutes the conformance of the observation to the expectation.
  • Future audio / vision frames, which converge on the same envelope.

INV-30's own error message names this gap verbatim: "a closed-world assert-False capability must use a distinct result type and entry point, never this one."

The hazard this ADR guards is a wrong=0-class soundness breach: a closed-world False leaking into the open-world runtime would assert not Q from what is really just absence of Q — exactly the contamination INV-30 exists to prevent. The OWA floor (#779) is the dual gate from the other side: it fails if the open-world engine ever asserts True on a gold Unknown/False. FrameVerdict must be built on top of both floors, never around them.

1.1 What already exists (the anti-reinvent inventory)

A frame-general closed-world verdict is not a new solver. Every soundness-bearing decision it needs already exists and is sound-by-construction:

Need Existing producer Verdict it emits
Propositional entailed-False generate/proof_chain/entail.py::evaluate_entailment_with_trace Entailment.REFUTED when (⋀premises) → ¬query is an ROBDD tautology, with an EntailmentTrace proof
Inconsistent premises (≠ False) same, via generate/logic_canonical.py::CanonicalProposition.is_contradiction Entailment.REFUSED(INCONSISTENT_PREMISES) — declines (ex falso quodlibet), never asserts
Out-of-regime (ungrounded ∀/predicate) same Entailment.REFUSED(OUT_OF_REGIME_OR_MALFORMED)
Perceptual frame conformance sensorium/environment/falsification.py::compare_expected_to_observation verdict = "SUPPORTED" | "FALSIFIED" — a set-comparison of one actual frame to one expected frame, with a structural FalsificationResidual{matched, missing, unexpected, changed}. [adv] This refutes a hypothesis-scoped expectation by set-inequality; it is not itself a proof of ¬query — only the changed sub-case is a positively-observed contradiction (see §5.2).
Independent text gold (3-way) evals/proofwriter_owa/oracle.py::label (aliased owa_label in score.py) "True" | "Unknown" | "False" — disjoint from the engine (INV-25/27)

The genuinely missing pieces are narrow and three: (1) a single frame-general envelope type with a lane-scoped entry point distinct from determine(); (2) the explicit closed-frame context that licenses asserting the negation (no runtime module builds one today for text); and (3) a firewall (proposed INV-31) that fails loudly if a closed-world verdict reaches the open-world runtime. The False decision itself is delegated to the producers above — it is never re-implemented here.


2. Decision

Introduce FrameVerdict: a single, frame-general, closed-world verdict type that

  1. is a distinct dataclass (never Determined, no answer: bool field), so the INV-30 call-name scan cannot fire on it and it cannot masquerade as an open-world answer;
  2. is produced only at a lane-scoped entry point that takes an explicit ClosedFrame, never a SessionContext — structurally unable to read open-world session memory;
  3. asserts entailed_false only from a positive proof of the negation (an ROBDD refutation, or a perceptual changed-slot contradiction), never from absence — neither textual non-provability nor a missing/extra observation;
  4. delegates every soundness decision to existing producers (§1.1) — it is a convergent envelope, not a solver;
  5. carries honest standing via the existing _basis(grounds) rule, never minting COHERENT/verified, never writing epistemic_status (INV-29);
  6. reaches a user surface only through the existing response-governance gate (govern_responseshape_surface, choose_served_disposition), default-dark — no parallel servability object;
  7. is firewalled from the open-world runtime by INV-31, a two-part firewall (transitive import containment and a typed data-flow guard) specified in §8 with per-scan non-vacuity anchors.

The spine stays modality-neutral (cf. project-spine-modality-neutral-convergence): text-CWA and perceptual falsification converge at the front via a frame_kind discriminator — the same idiom as RealizedRecord.structure_kind — not via a spine rewrite. The shared object is the output envelope + governance path + replay digest; the input signature differs by frame (§6).


3. Type shape

Proposed — does not yet exist. This lands in implementation PR-1 together with INV-31, never after it. It is shown here as an illustrative fenced block, not an importable module, to keep this PR design-only.

from dataclasses import dataclass
from enum import Enum, unique

@unique
class FrameKind(str, Enum):
    TEXT = "text"              # a declared-complete CWA fact set (ProofWriter-CWA, FOLIO)
    PERCEPTION = "perception"  # an ADR-0211 ExpectedObservationFrame vs an ObservationFrame
    # AUDIO = "audio"; VISION = "vision"  # reserved — they converge identically

@unique
class WorldAssumption(str, Enum):
    OPEN = "open"                      # absence ⇒ undetermined; entailed_false is ILLEGAL here
    CLOSED = "closed"                  # frame declared complete
    BOUNDED_CLOSED = "bounded_closed"  # complete only within a declared scope

@unique
class FrameVerdictKind(str, Enum):
    ENTAILED_TRUE  = "entailed_true"   # the frame proves query
    ENTAILED_FALSE = "entailed_false"  # the frame proves ¬query (positive refutation)
    UNDETERMINED   = "undetermined"    # neither query nor ¬query proven — within-frame refusal
    CONTRADICTION  = "contradiction"   # the frame's own premises are inconsistent — NOT a False
    SCOPE_BOUNDARY = "scope_boundary"  # out of decidable regime / frame not licensed complete

@dataclass(frozen=True, slots=True)
class ClosedWorldProof:
    """Modality-blind, content-addressed proof envelope. Text and perception carry
    the SAME shape; only `producer`, `outcome`, and the keys differ."""
    producer: str                # "proof_chain.entail" | "sensorium.falsification"
    outcome: str                 # the producer's own literal: REFUTED/ENTAILED/UNKNOWN | FALSIFIED/SUPPORTED
    proof_keys: tuple[str, ...]  # entail: (conjunction_key, query_key, refutation_check_key)
                                 # perception: (FalsificationRun.trace_hash,)  -- the FULL run witness
    proof_sha256: str            # sha256_json over the canonical proof payload
    positive_refutation_kind: str | None = None
    # [adv/ratify] entailed_false ONLY: "robdd_refutation" (text) | "perception_changed_slot"
    # (perception). None for EVERY non-entailed_false verdict. This is the explicit
    # discriminator the ratifier required: a GENERIC FALSIFIED (missing / unexpected /
    # whole-frame-missing — i.e. absence or over-observation) leaves this None and therefore
    # CANNOT satisfy entailed_false's admissibility invariant (FrameVerdict.__post_init__).
    # The perception adapter sets it to "perception_changed_slot" only when changed != ().

@dataclass(frozen=True, slots=True)
class FrameVerdict:
    """A closed-world verdict. DISTINCT from generate/determine/Determined:
    no `answer: bool`, a 5-way `verdict` enum instead, a distinct name, and a
    distinct entry point. INV-30's call-name scan cannot match it; it can never
    be confused with an open-world answer."""
    frame_id: str                  # content-addressed id of the explicit closed frame
    frame_kind: FrameKind
    world_assumption: WorldAssumption
    query: str                     # the proposition under test (canonical key). NOTE [adv]: for
                                   # perception this is the FRAME-CONFORMANCE proposition keyed by
                                   # expected_id ("the observation conforms to this expected frame"),
                                   # NOT an arbitrary asked proposition — see §5.2.
    verdict: FrameVerdictKind      # the two-sided result — NOT a bool, NOT named `answer`
    basis: str                     # _basis(grounds): "as_told" | "verified" (never hardcoded)
    proof: ClosedWorldProof        # the replayable refutation/entailment/falsification evidence
    evidence: tuple[str, ...]      # content-addressed refs (grounds replay_hashes / _reject_unsafe_unit-
                                   # screened merge_keys) — never raw payloads or efferent traces
    trace_hash: str                # sha256_json deterministic replay digest of the whole verdict

    def __post_init__(self) -> None:
        # [adv/ratify] Admissibility invariant — makes §12 obligation 2 non-vacuous: entailed_false
        # may exist ONLY with a positive-refutation proof, and the proof must name WHICH positive
        # refutation it is. A generic FALSIFIED (which ADR-0211 also emits for missing / unexpected /
        # whole-frame-missing — i.e. absence) cannot satisfy this; only an ROBDD refutation or a
        # perceptual changed-slot contradiction can. A mismatched (verdict, proof) pair fails loudly
        # at construction, so a mutation test can trip it.
        if self.verdict is FrameVerdictKind.ENTAILED_FALSE:
            p = self.proof
            ok = (
                (p.producer == "proof_chain.entail"
                    and p.outcome == "REFUTED"
                    and p.positive_refutation_kind == "robdd_refutation")
                or (p.producer == "sensorium.falsification"
                    and p.outcome == "FALSIFIED"
                    and p.positive_refutation_kind == "perception_changed_slot")
            )
            if not ok or not p.proof_sha256:
                raise ValueError(
                    "entailed_false requires a positive-refutation proof: an ROBDD refutation "
                    "(robdd_refutation) or a perceptual changed-slot contradiction "
                    "(perception_changed_slot) — never a generic FALSIFIED"
                )

Notes binding the shape to existing contracts:

  • No answer field, distinct name. INV-30's predicate keys only on the literal call name Determined and the answer argument. FrameVerdict matches neither; a two-sided enum cannot be smuggled as answer=True.
  • basis ∈ {as_told, verified}, computed by the existing _basis(grounds) rule over the grounds' epistemic_status. Today every realized record is SPECULATIVE → as_told. A proofless verdict can never claim verified. The proof-backing lives in proof, orthogonally — so a sound refutation over as_told grounds is honestly "I was told these facts; from them, ¬query is proven." [adv] _basis currently lives inside determine.py; PR-1 lifts it to a shared generate/epistemic_basis.py so neither package depends on the other's internals (§8).
  • No epistemic_status field. Standing is read-only via basis + the grounds' own stored status; FrameVerdict never writes an epistemic_status key, keeping INV-29's {vault/store.py} allowlist untouched.
  • proof and trace_hash are content-addressed and replay-stable (sha256_json, order-invariant). [adv] For perception, proof_keys carries the full FalsificationRun.trace_hash (which binds expected_sha256 + actual_trace_hash + verdict), not residual_sha256 alone — the residual hash does not bind the expected/actual frame identities and two different (expected, actual) pairs can share a residual delta. No clock, no RNG, no LLM, no probabilistic confidence, no tolerance.
  • evidence carries only content-addressed references (grounds' replay_hash, perception units' merge_key — all already _reject_unsafe_unit-screened), never raw modality payloads or efferent traces, matching ADR-0211's discipline.

3.1 The closed-frame context

@dataclass(frozen=True, slots=True)
class ClosedFrame:
    """The EXPLICIT closed-world context that licenses asserting a negation. The
    entry point takes THIS, never a SessionContext — so it structurally cannot read
    open-world session memory where absence ≠ false."""
    frame_id: str
    frame_kind: FrameKind
    world_assumption: WorldAssumption     # must be CLOSED or BOUNDED_CLOSED — OPEN ⇒ refuse
    propositions: tuple[str, ...]         # text: the COMPLETE enumerated fact set incl. declared
                                          #       negations/disjointness (content-addressed)
    expected_frame_ref: str | None        # perception: id of the ExpectedObservationFrame (ADR-0211)
    frame_sha256: str

A ClosedFrame is constructed explicitly by the lane / scenario, never defaulted from SessionContext or VaultStore. There is no "the session is closed" mode. If a caller supplies world_assumption == OPEN, or omits the closure declaration, the entry point returns SCOPE_BOUNDARY (refusal), never entailed_false. This is the dual of INV-30: the open-world path lacks the completeness license and so may only assert True or refuse; the closed-world path may assert False only because it was handed an explicit, declared-complete frame and a positive proof of the negation.


4. Entry points

OPEN  WORLD (unchanged):  determine(question, ctx: SessionContext) -> Determined | Undetermined
                          # asserts answer=True or refuses. NEVER FrameVerdict. NEVER answer=False.

CLOSED WORLD (new):       evaluate_frame_verdict(frame: ClosedFrame, query: str) -> FrameVerdict
                          # lives in NEW top-level-isolated package generate/frame_verdict/.
                          # Takes a ClosedFrame, never a SessionContext. Returns FrameVerdict,
                          # never Determined. For perception, `query` is synthesized as the
                          # frame-conformance proposition keyed by expected_id (§5.2).

Hard rules (enforced by INV-31, §8):

  • For FrameVerdict PR-1, determine() and generate/determine/determine.py are untouched: the return union stays Determined | Undetermined and its current Determined(answer=True) construction count is unchanged. [ratify] This is a PR-1 scope rule, not a permanent ban on the number: future open-world True-only capabilities may add legitimate Determined(answer=True) sites only if INV-30 is updated with a visible count change (test_determine_construction_sites_are_visible) and every new site is a literal answer=True. What is permanent is the firewall, not the count: no answer=False branch is ever added anywhere, and FrameVerdict adds no Determined site of its own.
  • evaluate_frame_verdict is the only constructor of FrameVerdict, and lives in a package the open-world runtime spine does not transitively import.
  • The two paths share the front-end reader (comprehendrealize, the same RealizedRecord grounds shape) but diverge at context establishment and entry point. Reusing the reader is sanctioned; reusing the entry point is forbidden. [adv] Because the reader is shared, INV-31 must firewall the data flow, not only the module imports: a ClosedFrame-derived grounds must be unable to enter determine(), and the disclosure gate must refuse to render a FrameVerdict through an open-world disposition (§8 Part B).

5. Semantics — the verdict table

The single load-bearing rule: a closed frame licenses False, and entailed_false is produced only from a positive proof of the negation. "The world is closed" is realized by the frame declaring its closure facts (disjointness, expected-set completeness), which the existing sound producers then consume — not by an unsound "not-provable ⇒ false" (text) or "not-observed ⇒ false" (perception) leap.

5.1 Text frame (frame_kind = text)

evaluate_frame_verdict lowers the ClosedFrame.propositions + query to the propositional regime and calls the existing evaluate_entailment_with_trace(premises, query):

entail.py outcome FrameVerdict.verdict Rationale
ENTAILED ((⋀prem) → query taut.) entailed_true the frame proves query
REFUTED ((⋀prem) → ¬query taut.) entailed_false the frame proves ¬query — sound closed-world No
UNKNOWN undetermined neither proven — within-frame refusal (NOT False)
REFUSED(INCONSISTENT_PREMISES) contradiction the frame's own premises clash — decline, ex falso
REFUSED(OUT_OF_REGIME_OR_MALFORMED) scope_boundary ungrounded ∀/predicate — not decidable here

The EntailmentTrace (with refutation_check_key — the ROBDD key of (⋀prem)→¬query) becomes the ClosedWorldProof; producer="proof_chain.entail", outcome="REFUTED", positive_refutation_kind="robdd_refutation". (Adversarial review confirmed REFUTED is a genuine (⋀prem)→¬query tautology — a real positive proof — and that UNKNOWN/INCONSISTENT_PREMISES stay strictly out of entailed_false.)

5.2 Perception frame (frame_kind = perception) [adv — fully revised]

ADR-0211 has no query and no ¬query proof: a FalsificationRun is a set comparison of one actual frame to one hypothesis-scoped ExpectedObservationFrame. So the perception convergence must (a) name the proposition it decides, and (b) refuse to launder absence/over-observation into entailed_false.

The perception query is frame-conformance, keyed by the expected frame: query = "observation conforms to expected frame " + expected_id. Under that specific proposition, the residual decomposes (it is not a bare SUPPORTED/FALSIFIED map):

Residual condition (from FalsificationResidual) FrameVerdict.verdict Why
SUPPORTED (matched == expected, none missing/changed/unexpected) entailed_true the conformance proposition is positively observed
changed ≠ () (a declared-expected slot observed with a contradicting merge_key) entailed_false a positively observed contradiction of a declared expectation — sound No
missing ≠ () only (an expected slot received no observation) undetermined absence, not contradiction — the perception analogue of NAF (deferred, see §5.3)
unexpected ≠ () only (an extra slot appeared) undetermined over-observation ("saw more than expected") — not a refutation of any expected slot
whole actual frame absent (_missing_actual_run, actual_frame_id="__missing_observation_frame__") scope_boundary observed nothing — a coverage gap, never a proof of ¬query
_canonical_refs conflicting-slot raise (ExpectedObservationFrame ill-formed) scope_boundary the frame is not a licensed complete expectation

ClosedWorldProof for an entailed_false: producer="sensorium.falsification", outcome="FALSIFIED", proof_keys=(FalsificationRun.trace_hash,), positive_refutation_kind="perception_changed_slot" — which the adapter sets only when changed ≠ () (the __post_init__ invariant of §3 is the backstop: a generic FALSIFIED leaves positive_refutation_kind=None and cannot become entailed_false). Perception is binary-native (ADR-0211 v1 emits only SUPPORTED/FALSIFIED), so in v1 it inhabits the subset {entailed_true, entailed_false, undetermined, scope_boundary} of the verdict alphabet; a query the expected frame under-determines is routed to scope_boundary, never forced.

5.3 The rules that keep entailed_false sound (both frames)

  1. Positive proof only. entailed_false requires either an ROBDD REFUTED proof (text) or a changed-slot contradiction (perception). There is no entailed_false from textual non-provability, from a missing observation, or from an extra observation.
  2. contradiction is not False. Inconsistent frame premises yield contradiction (decline), never entailed_false. The design must not collapse REFUTED and INCONSISTENT_PREMISES — different facts about the world (and, per §7, they must stay distinguishable at the disclosure layer, not only upstream).
  3. undetermined stays undetermined outside a complete frame. Under bounded_closed, anything outside the declared bound is undetermined/scope_boundary, never False. This is exactly the OWA discipline (label returns Unknown from absence, False only from declared disjointness).

Deferred (separately ratifiable): negation-as-failure in both frames — text "declared-complete ⇒ unprovable is false" and perception "observation-complete ⇒ a missing expected slot is false." This is the single riskiest soundness hinge — "the frame/observation is truly complete" is a strong, easy-to-get-wrong claim — so v1 omits it on both sides. When pursued, each must carry an explicit, machine-checkable completeness witness and its own wrong=0 proof; the text side must still route through entail (the completeness facts become premises), and the perception side must carry an observation-completeness license, never a bare absence leap.


6. Modality convergence

flowchart TD
    subgraph FRONT[shared front-end reader]
      R[comprehend → realize\nRealizedRecord grounds]
    end
    R --> K{frame_kind\n+ explicit ClosedFrame}
    K -->|text: propositions + query| T[evaluate_entailment_with_trace\nENTAILED / REFUTED / UNKNOWN / REFUSED]
    K -->|perception: expected vs actual| P[compare_expected_to_observation\nresidual decompose §5.2]
    K -.->|audio / vision\n(reserved)| F[same envelope,\ndifferent frame evidence]
    T --> V[FrameVerdict\nverdict + ClosedWorldProof + trace_hash]
    P --> V
    V --> G[core/epistemic_disclosure\nchoose_served_disposition → govern_response → shape_surface]
    G --> U[user surface\ndefault-dark]

The discriminator frame_kind partitions substrates at the front, exactly as RealizedRecord.structure_kind already partitions meaning-graph vs binding-graph substrates. [adv] What is shared across frames is the output envelope, the governance/disclosure path, and the replay digest. The input signature differs by frame: text takes (propositions, query); perception takes (expected_frame, actual_frame) and synthesizes the frame-conformance query (§5.2). A new modality adds a FrameKind member and a producer adapter — never a new verdict alphabet, never a spine rewrite. The render integration lives entirely in core/epistemic_disclosure (§8), so chat/runtime.py never imports the closed-world package.


7. Standing & servability (no parallel object, default-dark) [adv — revised]

FrameVerdict produces a typed verdict and commits/serves nothing itself — the discipline of ConverseEstimate, which returns a candidate and lets the gate + disclosure layer decide. A wrong served closed-world verdict must always be a disclosed wrong.

Two distinct axes carry standing and must not be conflated:

  • basis (teaching.epistemic.EpistemicStatusas_told/verified), computed by _basis(grounds). This is the grounds' standing.
  • EpistemicState (core.epistemic_state, the 15-member governance taxonomy). For the limitation verdicts, a FrameVerdict is lowered to a LimitationAssessment whose state is derived by the existing limitation.py::_KIND_TO_STATE. For the committed verdicts (entailed_true/entailed_false), [ratify] the default produced state is EpistemicState.INFERRED with DisclosureClaim.NONE — a frame-derived conclusion, not EVIDENCED (RECONCILE drift) and not VERIFIED (which has its own protected producer). _basis does not produce an EpistemicState; the two enums are orthogonal.

Mapping onto the existing closed sets (no fourth taxonomy):

FrameVerdict.verdict Is it a limitation? LimitationKind / ResolutionAction ServedDisposition Notes
entailed_true no — a committed answer ("Yes") COMMIT [ratify] EpistemicState.INFERRED + DisclosureClaim.NONE by default; surface carries the as_told/verified basis honestly. Do not stamp EVIDENCED (RECONCILE drift) or VERIFIED (no producer)
entailed_false no — a committed answer ("No"), not a block COMMIT [ratify] COMMIT-with-negative-surface: EpistemicState.INFERRED + DisclosureClaim.NONE; PR-4 adds a grounded-negative renderer (render_determination only renders affirmations). It is not a contradiction and must not add or reuse a LimitationKind
contradiction (inconsistent frame) yes contradiction / report_contradictionCONTRADICTED REPORT the only verdict that is a real contradiction; carries blocking_reason="frame_inconsistent"
undetermined yes hard_boundary / refuse_known_boundaryUNDETERMINED REFUSE within-frame refusal
scope_boundary yes scope_boundary / refuse_known_boundarySCOPE_BOUNDARY EXPLAIN out-of-regime / unlicensed frame

This resolves the two governance findings from review: entailed_false (a proven "No") no longer collapses onto the contradiction triple (they were colliding on every typed field of LimitationAssessment), and a proven negative is correctly typed as a committed answer rather than a CONTRADICTED block. The closed LimitationKind set has no "soundly-refuted" member because entailed_false is not a limitation. [ratify] RESOLVED: the grounded-negative answer is served as COMMIT-with-negative-surface — a PR-4 renderer, no new LimitationKind/EpistemicState member. ServedDisposition.COMMIT already means "serve a fully-grounded answer as-is"; REPORT stays reserved for the inconsistent-frame contradiction.

Binding rules:

  • Stays STRICT. A closed-world verdict is absolute (proof-backed), not a statistical estimate — it never rides the APPROXIMATE rung. govern_response is called with no license_decision, returns STRICT_POLICY, and shape_surface is the byte-identical identity transform. Serving SHAs and wrong=0 are unchanged.
  • Cannot self-certify VERIFIED. DisclosureClaim.VERIFIED degrades to COMMIT unless EpistemicState.VERIFIED (no producer; RESERVED in policy.py).
  • Default-dark. [adv] choose_served_disposition has no caller on the default live serving path — its one caller (core/epistemic_disclosure/ask_serving.py via chat/ask_runtime.py::maybe_apply_served_ask) is itself unwired into chat/runtime.py. The FrameVerdict disclosure path lands in the same posture: classify + decide a disposition, but do not flip the live shape_surface STRICT identity until a ratified producer + lane exist. Byte-identical serving is preserved.

Standing/persistence (if a lane ever persists a refuted hypothesis): through VaultStore.store only, SPECULATIVE by default (INV-21/22/23) — a perceptual falsification refutes a SPECULATIVE HypothesisClaim (ADR-0211), it never mints COHERENT. The only path to verified standing remains the certificate-gated apply_certified_promotion (INV-29). FrameVerdict writes no status itself.


8. INV-31 — the firewall (closed-world False cannot reach the open-world runtime) [adv — fully revised]

The review showed a single-level, 3-file import scan is insufficient: "the open-world path" is not a scannable unit (chat/runtime.py is one file holding the open-world, realization, and governance-render code together); a single-level scan is defeated by one transitive hop (the spine already imports into generate.proof_chain, the FrameVerdict producer package); and the actual leak surface is the shared reader + shared disclosure gate, a data-flow path no import scan touches. INV-31 is therefore a two-part firewall, and it lands in PR-1 together with the type — never after.

Guarantee. A closed-world verdict (FrameVerdict, especially entailed_false / contradiction) can never be produced by, imported by, or rendered as an open-world fact by the open-world runtime spine.

Part A — transitive import containment

  • A1. generate/determine/determine.py neither imports nor constructs FrameVerdict; determine()'s return union stays Determined | Undetermined.
  • A2. FrameVerdict(…) is constructed only inside an exact allowlist (no glob): ALLOWED_FRAME_VERDICT_SITES = frozenset({"generate/frame_verdict/evaluate.py", …exact files…, "evals/proofwriter_cwa/adapter.py", …}). The matcher must catch every constructor, not just the literal FrameVerdict(…) call: classmethod factories, dataclasses.replace(v, verdict=…), and any helper returning FrameVerdict — mirroring INV-21's receiver-binding matcher, which exists precisely to defeat wrapper evasion. A drift-guard (à la test_allowlist_is_actually_used) flags a listed-but-unused site. The construction scan's file-walk must include evals/ (unlike INV-30's _enumerate_project_py_files, which excludes it) or the eval-adapter entries are unenforced.
  • A3 (transitive). generate/frame_verdict is unreachable from the transitive first-party import closure of every open-world spine entry module — computed with the existing _transitive_first_party_imports walker (INV-27's), not a single-level scan. The spine set is the whole files chat/runtime.py, session/context.py, vault/store.py (each barred entirely — there is no "open-world path" sub-unit). The package is placed so no spine closure touches it, and it is forbidden to be re-exported through generate/proof_chain (which the spine already imports). frame_verdict may import from generate.proof_chain.entail and the lifted generate/epistemic_basis.py; those one-way edges are sanctioned and the scan is directional (spine ↛ frame_verdict, not the reverse).

Part B — typed data-flow firewall (the shared-reader / shared-gate leak)

Imports alone don't reach the leak surface the shared front-end creates, so:

  • B1. determine()'s grounds input is type-tagged such that a ClosedFrame-derived grounds cannot be fed to the open-world determine() (a typed guard + a test that a closed-frame provenance into determine() raises).
  • B2. choose_served_disposition / govern_response / shape_surface refuse to render a FrameVerdict via an open-world disposition unless an explicit closed-world disposition tag is present. The render integration lives inside core/epistemic_disclosure, never in chat/runtime.py; the spine ↛ frame_verdict containment (A3) plus this typed render gate means a closed-world entailed_false cannot reach the user surface as an open-world assertion.

Non-vacuity — a meaningful-failure anchor for each scan

Mirroring INV-30's trio (30b non-vacuity, 30b no-false-positive, 30c sites-visible) and INV-29's parallel trio, every scan gets an anchor so a blind/mis-rooted scan fails loudly:

Test Fails when
test_inv31_determine_is_visible_and_clean (anchors A1) the scan does not see determine.py's real Determined sites (the count INV-30c pins, whatever it is at the time), or sees a FrameVerdict ref there
test_inv31_construction_allowlist (anchors A2) any FrameVerdict/factory/replace construction occurs outside the exact allowlist
test_inv31_construction_detector_is_non_vacuous a fixture constructing FrameVerdict (and each alternate-constructor shape) in a forbidden location is not flagged
test_inv31_spine_imports_resolve_and_exclude (anchors A3) a spine module's transitive import set is empty (mis-rooted/typo'd scan), or it does include generate.frame_verdict
test_inv31_determine_rejects_closed_frame_grounds (anchors B1) a ClosedFrame-derived grounds fed to determine() does not raise
test_inv31_open_disposition_rejects_frameverdict (anchors B2) the open-world disposition renders a FrameVerdict without the closed-world tag

The non-vacuity claim is scoped per scan — "INV-31 is non-vacuous" holds only because each of A1/A2/A3/B1/B2 has its own meaningful-failure anchor, not because one fixture test covers the construction scan alone.


9. Measurement

  • wrong_total == 0, closed-world edition. For a future ProofWriter-CWA lane, wrong is "the closed-world gear asserted entailed_false where the independent CWA oracle does not entail ¬query (or entailed_true where it does not entail query)." The analogue of the OWA breach; wrong must stay 0, non-vacuous because the oracle is independent (INV-25/27).
  • Independent gold, disjoint solver. A CWA oracle is the closed-world analogue of evals/proofwriter_owa/oracle.py::label: same disjoint-oracle discipline (imports no determine/comprehend/frame_verdict), but it may soundly return False more often under the declared CWA. The oracle stays the scorer; it is never promoted into serving.
  • Capability-index registration. Unlike the OWA refusal floor (deliberately not a capability domain — low coverage drags coverage_geomean), a two-sided CWA lane commits entailed_true/entailed_false and is a real capability domain. It registers through the existing hook: a proofwriter_cwa_result() -> DomainResult adapter appended to evals/capability_index/adapters.py::ADAPTERS; a lane that fails to run is recorded in Collection.not_covered, never faked.
  • Replay digest stable. Every FrameVerdict.trace_hash is deterministic (sha256_json, order-invariant). The lane's report digest must be byte-stable — no clock, no LLM, no sampling.
  • The OWA floor (#779) stays green. A CWA lane is a separate measure-only lane. B4 adds no False path to determine(), so the OWA wrong=0 / coverage_gaps gate is unaffected.

10. Implementation plan (gated; this PR is design-only)

PR Scope Gate
PR-0 (this) This ADR only. No code. ratification of this document
PR-1 Lift _basisgenerate/epistemic_basis.py; add generate/frame_verdict/ (the FrameVerdict/ClosedFrame/ClosedWorldProof types incl. the __post_init__ admissibility invariant + evaluate_frame_verdict for the text frame, delegating to entail) + serialization + INV-31 Part A & B with all six non-vacuity anchors. No serving wire. PR-0 ratified
PR-2 evals/proofwriter_cwa/ — independent CWA oracle + hand-authored CWA fixtures + scorer (correct_false / wrong_false columns) + capability_index adapter. Measure-only. PR-1 merged
PR-3 sensorium/environment/FrameVerdict adapter (the conservative §5.2 residual decomposition: only changedentailed_false; missing/unexpected/whole-missing→undetermined/scope_boundary; frame-conformance query; full-run trace_hash proof). Perception-NAF stays deferred. PR-1 merged
PR-4 core/epistemic_disclosure integration — map FrameVerdictServedDisposition per §7 (committed verdicts → COMMIT at EpistemicState.INFERRED + DisclosureClaim.NONE), incl. a grounded-negative renderer for entailed_false, default-dark (no live shape_surface flip). PR-2/3 merged + a ratified producer

Sequencing rule: INV-31 ships with the type (PR-1), never after. The firewall must exist the moment a FrameVerdict can be constructed.


11. Non-negotiables honored

Non-negotiable How this design satisfies it
Do not modify determine() to emit answer=False determine.py untouched; INV-31 A1 fails if it imports/constructs FrameVerdict; the new path is a distinct package/function
Do not relax INV-30 INV-30 unchanged and green; FrameVerdict has no answer field and a distinct name (review ran the real predicate → []), so the scan cannot match it
Do not treat absence as false text entailed_false only from an ROBDD refutation; perception entailed_false only from a changed-slot contradiction — missing/unexpected/whole-missing → undetermined/scope_boundary; NAF deferred both frames
No closed-world verdict masquerades as open-world Determined distinct type, distinct entry point, 5-way verdict enum (not a bool), and the typed data-flow firewall (INV-31 Part B) blocks the shared-reader / shared-gate leak
Closed-world False requires an explicit closed/bounded frame + proof basis entry point takes a ClosedFrame (never SessionContext); OPEN/forged/implicit frames → SCOPE_BOUNDARY; every entailed_false carries a positive-refutation ClosedWorldProof (enforced by __post_init__)
FrameVerdict is a separate type/surface from Determined yes — separate package, type, entry point, and (PR-4) grounded-negative renderer
response_governance stays the final servability gate verdict commits nothing; surfaces only via choose_served_dispositiongovern_responseshape_surface, STRICT, default-dark

12. Proof obligations (per Schema-Defined Proof Obligations)

Each claim is real only if an executing test can meaningfully fail under the violation it names. Obligations PR-1+ must discharge:

  1. INV-31 per-scan non-vacuity — the six anchors in §8 (each of A1/A2/A3/B1/B2 has a meaningful-failure test; the construction detector is exercised against every alternate constructor shape).
  2. entailed_false is proof-backed (now non-vacuous) — the §3 __post_init__ raises on verdict==ENTAILED_FALSE unless the proof is a named positive refutation (robdd_refutation from proof_chain.entail/REFUTED, or perception_changed_slot from sensorium.falsification/FALSIFIED); a test constructs each mismatch — including a generic FALSIFIED with positive_refutation_kind=None — and asserts the raise.
  3. contradiction ≠ entailed_false — inconsistent premises → contradiction, never entailed_false; and (§7) the two carry distinct LimitationKind so they stay distinguishable at the disclosure layer, not only upstream.
  4. Absence never yields entailed_false — text: an UNKNOWN query → undetermined; perception: a missing-only residual and a whole-frame-missing run → undetermined / scope_boundary, asserted explicitly (the blocker the review caught).
  5. Replay determinism — identical trace_hash across two runs and under input reordering; perception proof binds the full FalsificationRun.trace_hash.
  6. CWA wrong=0 is non-vacuous — the lane's scorer, against the independent oracle, fails if any entailed_false is asserted where the oracle does not entail ¬query.

13. Consequences

Positive. A single sound, replayable, frame-general closed-world verdict; text-CWA and perception converge on one envelope before either is built, so neither invents an incompatible "False" shape; the open-world runtime is firewalled by both import containment and a typed data-flow guard; no new solver, no relaxed invariant, no serving regression.

Costs / risks.

  • NAF deferral (both frames) means v1 text-CWA asserts False only where the frame declares the refuting facts, and v1 perception asserts False only on a changed-slot contradiction. ProofWriter-CWA fixtures must include their closure facts — which matches how ProofWriter-CWA is presented. Acceptable; NAF is the riskier hinge and is correctly deferred.
  • Perception is binary-native and conservative. It inhabits a subset of the verdict alphabet in v1; under-determined queries → scope_boundary. This is the honest cost of not laundering set-inequality into entailment.
  • Grounded-negative renderer is new (PR-4). entailed_false is a committed "No" with no existing renderer; [ratify] resolved as COMMIT-with-negative-surface (no new closed-set member), served at EpistemicState.INFERRED + DisclosureClaim.NONE.
  • _basis lift (PR-1) is a small, mechanical refactor that removes the open/closed package coupling the review flagged; INV-31's directional scan depends on it.
  • INV-31 import scans must use the project-wide source walk with the maintained .claude/worktree exclusions (CLAUDE.md Architectural Scan Exclusions), and the construction scan must include evals/.

Alternatives considered & rejected.

  • Widen determine()/Determined with answer=False — rejected: mechanically breaks INV-30 and contaminates the open-world session-memory path.
  • Map bare FalsificationRun.verdict (SUPPORTED/FALSIFIED) onto entailed_true/entailed_false — rejected (review blocker): FALSIFIED fires on absence (missing, whole-frame-missing) and over-observation (unexpected); SUPPORTED is mere set-match, not a proof of any query. Only changed is a positively-observed contradiction.
  • Route entailed_false through LimitationKind=contradiction — rejected (review): collapses a proven "No" with an inconsistent frame and mis-types a grounded answer as a block.
  • A single-level 3-file import scan as the firewall — rejected (review): defeated by one transitive hop and blind to the shared-reader/disclosure leak.
  • Negation-as-failure in v1 (either frame) — deferred (§5.3), not adopted, for soundness.

14. Ratification checklist (to be completed by architect)

  • The FrameVerdict axes (§3) are the right closed set — frame_kind, world_assumption, the 5-way verdict (undetermined, aligning with the merged brief and EpistemicState.UNDETERMINED), basis/proof/evidence, trace_hash.
  • No answer field / distinct name is accepted as the INV-30-safe shape, and the __post_init__ admissibility invariant is accepted.
  • The §5.1 text table and the revised §5.2 perception decomposition (only changedentailed_false; missing/unexpected/whole-missing → undetermined/scope_boundary; frame-conformance query) are correct.
  • NAF deferral on both frames (§5.3) is accepted for v1.
  • INV-31's two-part shape (§8 — transitive import containment + typed data-flow firewall) with all six non-vacuity anchors is accepted, shipping in PR-1.
  • The _basis lift to generate/epistemic_basis.py (PR-1) is authorized.
  • RESOLVED (ratify): the §7 governance mapping — entailed_false as a committed "No" (not a contradiction block), STRICT, default-dark, no VERIFIED self-cert. Grounded-negative answer = COMMIT-with-negative-surface (a PR-4 renderer), no new LimitationKind/EpistemicState member.
  • RESOLVED (ratify): entailed_true/entailed_false produced EpistemicState = INFERRED with DisclosureClaim.NONE by default; never EVIDENCED, never VERIFIED unless a later canonical verified producer earns it.
  • APPLIED (ratify): the explicit positive_refutation_kind discriminator (§3) so a generic FALSIFIED cannot satisfy entailed_false; and the PR-1-scoped wording for the Determined-count rule (§4, no permanent ban on future visible-count additions).
  • The PR sequence (§10) and the "INV-31 ships with the type" rule are authorized.
  • The new invariant number INV-31 is free / correctly assigned (review confirmed no existing INV-31 reference).