/**
* The THREE KERNELS + the asymmetric Evidence-Ledger TOPOLOGY — the
* architectural capstone of the claims runtime (SDD §F; v1.1 §6; §R topology;
* Inv 13). This module is the one place the whole §F table is expressed as
* TYPES, and the one place the §F one-directional flow
*
* Read + Action → Evidence Ledger → Claims → Renderer
*
* is wired as a composition. It owns NO new policy: every repo-specific decision
* (does this actor own the resource? is this field PII? did the action's outcome
* confirm?) is INJECTED (like Q3's `SoundnessDeps`), so the kernel stays pure
* and the two-axis Read layers version independently (SDD §F: "authorization ≠
* trust"). Each conjunct/verdict it reuses lives in its own Q-module:
*
* - Read kernel = THIS module's `ReadKernel` (Access ⊕ Provenance — §F, Inv 13).
* - Action kernel = the EXISTING `Decision` (REUSED, not forked — §F, §R).
* - Claims kernel = Q3 `claimAllowed` (P1) ∘ Q4 `checkConsistency` (P2) — §F.
* - Evidence Ledger= Q2 `EvidenceLedger` (the per-turn snapshot Read+Action feed).
*
* §F TOPOLOGY (asymmetric, one-directional): Read + Action FEED the Evidence
* Ledger; the Claims Kernel sits DOWNSTREAM as the final output authority. The
* arrow never points backward — Claims CONSUMES the Ledger; the Ledger never
* consumes Claims, and nothing flows Claims → Ledger → Read (SDD §R topology
* condition 1: the model must not collapse). Q5 stops at producing the
* renderable VALIDATED+consistent set + the turn terminal; the
* renderer-from-claims itself is a DOWNSTREAM (ibatexas) deliverable (SDD §Q.7).
*
* §R topology hard-errors this module structurally prevents (each is a test):
* 1. the four-stage model does not collapse (the flow is one-directional);
* 2. NO claim carries free-text reasoning to validate — validation goes through
* Q3's typed `EvidenceRequirement` predicate, never a prose field;
* 3. the three-valued `VALIDATED | UNKNOWN | REFUSED` model is intact (Q1);
* 4. the Read kernel's two layers (access ≠ provenance) are independent.
*
* PURE & self-contained — no clock/RNG/IO; no kernel-downstream import (SDD §R
* kernel purity: `adjudicate → claustrum → ibatexas`, never backward). The
* Action kernel is REUSED INTRA-PACKAGE from `../decision.js` (allowed — same
* package), never redefined (a divergent second Action verdict = drift, §F/§R).
*/
// ── Action kernel: REUSE the EXISTING Decision (NOT a fork) ────────────────
// Intra-package import (same `@adjudicate/core`) — allowed by §R kernel purity
// (the backward-arrow ban is about DOWNSTREAM packages, claustrum/ibatexas).
// The §F Action verdict IS this `Decision` / `DecisionKind`; redefining the
// six values here would be the drift §F/§R forbids ("a second/divergent Action
// verdict = drift").
import type { Decision, DecisionKind } from "../decision.js";
import type { EvidenceLedger } from "./evidence-ledger.js";
import type { LedgerTaint } from "./evidence-ledger.js";
import { claimAllowed } from "./soundness.js";
import type {
MinimalClaim,
SoundnessDeps,
ValueBinding,
} from "./soundness.js";
import { checkConsistency } from "./consistency.js";
import type {
ConsistencyClaim,
ConsistencyOptions,
ConsistencyResult,
} from "./consistency.js";
import type { ClaimVerdict, TurnTerminal } from "./verdict.js";
// inv.17 — the kernel-minted, runtime-non-forgeable renderer-input carrier. The
// mint is PACKAGE-INTERNAL (not re-exported from the barrel); `runClaimsKernel` is
// its SOLE caller, on the VALIDATED ∧ P2-consistent `renderable` set.
import { mintCanonicalClaim, type CanonicalClaim } from "./canonical-claim.js";
// ═══════════════════════════════════════════════════════════════════════════
// KERNEL 1 — READ = Access ⊕ Provenance (SDD §F; v1.1 §6; Inv 13)
// ═══════════════════════════════════════════════════════════════════════════
//
// The Read kernel answers "may I read it? + what's its trust?" with TWO
// INDEPENDENT layers (SDD §F: "Read's two layers (authorization ≠ trust)
// version independently"). They are modeled as SEPARATE types so they can vary
// — and version — independently; the kernel result is their direct sum (⊕):
//
// access : ALLOW_READ · REDACT · ESCALATE · REFUSE (authorization)
// provenance : TRUSTED · UNTRUSTED_DATA (trust)
//
// Authorization is NOT trust: an ALLOW_READ on an UNTRUSTED_DATA row is a valid,
// expected combination (you may read a value whose origin you do not trust); so
// is a REDACT on a TRUSTED row (PII-minimization fires on first-party data too).
// Because all four × two combinations are well-formed, the two axes are
// genuinely orthogonal — neither field constrains the other.
/**
* The Read kernel's ACCESS-layer verdict (SDD §F; v1.1 §6; Inv 13) — the
* authorization axis, EXACTLY these four members:
*
* - `ALLOW_READ` — the actor may read the value as requested.
* - `REDACT` — read is permitted but PII/cross-tenant fields are removed.
* REDACT is the FIELD-LEVEL mechanism of PII-minimization +
* tenant-isolation (Inv 13).
* - `ESCALATE` — the access decision needs a human/supervisor.
* - `REFUSE` — the actor may NOT read it (e.g. cross-customer scope, Inv 2).
*
* This is the P1-ownership + PII-minimization + tenant-isolation verdict (Inv
* 13). It is DISTINCT from `provenance` (the trust axis) — the two version
* independently (SDD §F).
*/
export type ReadAccess = "ALLOW_READ" | "REDACT" | "ESCALATE" | "REFUSE";
/**
* The closed membership tuple for `ReadAccess`, in spec order (SDD §F). Single
* source of truth for the four members; `isReadAccess` narrows against it.
*/
export const READ_ACCESS_VERDICTS: readonly ReadAccess[] = [
"ALLOW_READ",
"REDACT",
"ESCALATE",
"REFUSE",
] as const;
/** Type guard: is `value` one of the exactly-four Read access verdicts? Pure. */
export function isReadAccess(value: unknown): value is ReadAccess {
return (
typeof value === "string" &&
(READ_ACCESS_VERDICTS as readonly string[]).includes(value)
);
}
/**
* The Read kernel's PROVENANCE-layer verdict (SDD §F; v1.1 §6) — the trust axis,
* EXACTLY the two `LedgerTaint` members `TRUSTED | UNTRUSTED_DATA` (reused from
* Q2's ledger vocabulary — §G; these two values land on a ledger entry's read-
* layer `taint`). A distinct ALIAS name documents that this is the Read kernel's
* SECOND, orthogonal layer; the underlying union is shared with the ledger so a
* Read provenance verdict drops into the ledger entry's `taint` as-is. NOTE this
* is the read-layer trust axis, NOT the 3-value `OriginProvenance` ORIGIN axis
* (§G / §J.3) on `originProvenance` — that is labeled at mint time, separately.
*
* Authorization ≠ trust (SDD §F): this is NOT `ReadAccess`. An `UNTRUSTED_DATA`
* value may still be `ALLOW_READ` (you read it) — but it may never be the
* VALIDATING value of a claim (Inv 3; enforced downstream by Q3, not here).
*/
export type ReadProvenance = LedgerTaint;
/**
* The Read kernel RESULT — the DIRECT SUM (⊕) of the two independent layers
* (SDD §F; v1.1 §6; Inv 13). The two are SEPARATE fields, each its own closed
* union, so they version independently (the §R/§F "two layers version
* independently" requirement is structural here — neither field's type mentions
* the other; all `ReadAccess × ReadProvenance` combinations are well-formed):
*
* - `access` — the authorization verdict (P1 ownership + PII-min +
* tenant-isolation, Inv 13).
* - `provenance` — the trust verdict (TRUSTED | UNTRUSTED_DATA).
*
* This is what the Read kernel FEEDS the Evidence Ledger: an allowed/redacted
* read writes an entry whose read-layer `taint` carries `provenance` (the entry's
* 3-value `originProvenance` ORIGIN axis is labeled separately at mint time, §G /
* §J.3). The Read kernel does NOT itself validate claims — it produces evidence +
* its trust; the Claims kernel (downstream) decides what may be SAID (SDD §F).
*/
export interface ReadKernelResult {
readonly access: ReadAccess;
readonly provenance: ReadProvenance;
}
/**
* The Read kernel's ACCESS layer (SDD §F; Inv 13) — the authorization predicate,
* INJECTED so the kernel hardcodes no repo ownership/PII model (mirrors Q3's
* injected `owns`). Given an opaque, kernel-abstract `query`, it returns a
* `ReadAccess`. The repo implements P1 ownership (owner-scoped read),
* PII-minimization, and tenant-isolation here; `REDACT` is the field-level
* mechanism (Inv 13). Must be PURE so the Read kernel stays pure.
*/
export interface ReadAccessLayer {
readonly decideAccess: (query: unknown) => ReadAccess;
}
/**
* The Read kernel's PROVENANCE layer (SDD §F) — the trust predicate, INJECTED
* and SEPARATE from the access layer so the two version independently (SDD §F:
* "authorization ≠ trust"). Given the same opaque `query`, it returns the
* `ReadProvenance` (TRUSTED | UNTRUSTED_DATA) the read's origin warrants. Must
* be PURE.
*
* The two layers are DELIBERATELY two distinct injected objects, not one — a
* downstream repo can re-version (swap) the access policy without touching the
* provenance policy, and vice versa. That is the §F independence made concrete.
*/
export interface ReadProvenanceLayer {
readonly decideProvenance: (query: unknown) => ReadProvenance;
}
/**
* The Read kernel = Access ⊕ Provenance (SDD §F; v1.1 §6; Inv 13). It runs the
* two INDEPENDENT layers over a read `query` and returns their direct sum. A
* PURE function: same `query` + same layers ⟹ same result. The two layers are
* never composed INTO one another — `access` is computed solely by the access
* layer, `provenance` solely by the provenance layer — so they remain
* orthogonal and version independently (the §F/§R requirement).
*/
export function runReadKernel(
query: unknown,
layers: ReadKernel,
): ReadKernelResult {
return {
// authorization axis (P1 ownership + PII-min + tenant-isolation, Inv 13)
access: layers.access.decideAccess(query),
// trust axis (TRUSTED | UNTRUSTED_DATA) — computed INDEPENDENTLY
provenance: layers.provenance.decideProvenance(query),
};
}
/**
* The Read kernel as a COMPOSITION of its two independent layers (SDD §F). The
* two are separate fields so a repo can re-version one without the other; that
* separation IS the "two layers version independently" contract.
*/
export interface ReadKernel {
readonly access: ReadAccessLayer;
readonly provenance: ReadProvenanceLayer;
}
// ═══════════════════════════════════════════════════════════════════════════
// KERNEL 2 — ACTION = adjudicate (SDD §F; v1.1 §6) — REUSE, DO NOT FORK
// ═══════════════════════════════════════════════════════════════════════════
//
// The Action kernel answers "can this happen?" Its verdict IS the EXISTING
// six-valued `Decision` (EXECUTE · REFUSE · ESCALATE · REQUEST_CONFIRMATION ·
// DEFER · REWRITE) produced by the EXISTING `adjudicate(envelope, state,
// policy)` — NOT a new union. We re-export the existing types under §F-named
// aliases so the topology can NAME the Action verdict without DEFINING a second
// one. Redefining the six values here would be the §F/§R drift ("a second/
// divergent Action verdict = drift"); a type alias guarantees identity — these
// names ARE `Decision` / `DecisionKind`, byte-for-byte, by construction.
/**
* The Action kernel's VERDICT (SDD §F; v1.1 §6) — an ALIAS of the EXISTING
* `Decision` (from `../decision.js`), NOT a redefinition. The §F Action verdict
* is the six-valued adjudicate `Decision`; this alias lets the topology refer to
* it by a §F-evocative name while the type IDENTITY stays the existing
* `Decision`. (`ActionKernelVerdict` ≡ `Decision`, structurally.)
*/
export type ActionKernelVerdict = Decision;
/**
* The Action kernel verdict KIND (SDD §F; v1.1 §6) — an ALIAS of the EXISTING
* `DecisionKind` (`EXECUTE | REFUSE | ESCALATE | REQUEST_CONFIRMATION | DEFER |
* REWRITE`). Reused, not forked: `ActionKernelVerdictKind` ≡ `DecisionKind`.
*/
export type ActionKernelVerdictKind = DecisionKind;
// ── BUILD-TIME reuse-not-fork guard (SDD §F Action-reuse; §R no-fork) ──────────
// The §F Action verdict MUST stay byte-identical to the existing `Decision`/
// `DecisionKind` — forking the alias (redefining the six values, or drifting its
// shape) is the §F/§R drift ("a second/divergent Action verdict = drift"). The
// assertion lives HERE, in `src`, BECAUSE `src` is always typechecked by the
// package build (`tsc`); a guard placed in the test file would be inert (that
// file is excluded from the typecheck). These are PURE compile-time TYPES — zero
// runtime cost — so a fork becomes a BUILD-TIME compile error, not a silent pass.
//
// `_Equal` is `true` IFF A and B are mutually assignable AND identical
// (the classic invariant-position trick: two identity functions are assignable
// to each other only when their type parameters' constraints coincide exactly).
type _Equal =
(() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2)
? true
: false;
// `_AssertEqual` resolves to `true` when A ≡ B and to `never` otherwise.
// Annotating a `true` literal with it is the build-time check: assigning `true`
// to a `true`-typed binding is fine, but assigning `true` to `never` (the forked
// case) is a TYPE ERROR. The two constants below force `tsc` to evaluate the
// equality — an unreferenced `type` alias alone is never checked.
type _AssertEqual = _Equal extends true ? true : never;
// If either alias ever FORKS away from its source type, its `_AssertEqual<…>`
// collapses to `never`, `true` is no longer assignable to it, and the package
// build (`tsc`) FAILS on the offending line — the §F/§R drift surfaces at build
// time, exactly where the source is always typechecked. Pure compile-time: these
// two `const`s carry no logic and are tree-shaken away.
const _actionVerdictReused: _AssertEqual = true;
const _actionVerdictKindReused: _AssertEqual<
ActionKernelVerdictKind,
DecisionKind
> = true;
// Reference them so an enabled `noUnusedLocals` stays satisfied; `void` = no use.
void _actionVerdictReused;
void _actionVerdictKindReused;
// ═══════════════════════════════════════════════════════════════════════════
// KERNEL 3 — CLAIMS = P1 (per claim) ⊕ P2 (set) (SDD §F; v1.1 §6) — THE OUTPUT AUTHORITY
// ═══════════════════════════════════════════════════════════════════════════
//
// The Claims kernel answers "may the system SAY it?" It is the FINAL output
// authority of the topology (SDD §F). It runs the lifecycle (SDD §D):
//
// Candidate Claims →(P1 soundness, Q3 claimAllowed)→ Validated Claim Set
// →(P2 consistency, Q4 checkConsistency)→ Renderable Set + Terminal
//
// It COMPOSES the two existing gates — it does not re-implement either. P1 runs
// FIRST and per-claim (an UNTRUSTED/UNKNOWN member must never enter or suppress
// the P2 set — SDD §D); only the VALIDATED survivors reach P2. Validation goes
// THROUGH Q3's typed `EvidenceRequirement` predicate over the Evidence Ledger —
// NEVER a free-text reasoning field (SDD §R topology condition 2).
/**
* One CANDIDATE claim presented to the Claims kernel (SDD §D; §F). It carries
* BOTH faces the two gates need, kept as the EXISTING Q-module shapes so no
* third claim type is invented:
*
* - `soundness` — the Q3 `MinimalClaim` the §5 predicate quantifies over
* (`requiredEvidence` + `minSourceIntegrity` + `kind` +
* `actor` + `resources`). Validation reads ONLY this typed
* structure (no free-text reason — §R condition 2).
* - `subject` — the Q4 same-subject partition key (consistency is a
* same-subject property — SDD §D).
* - `type` — the Q4 registry type name (the consistency table is keyed
* by the unordered `{type, type}` pair within a subject).
* - `value` — the domain proposition the renderer would fill from this
* claim. Carried for a RENDERED claim; it is the §O#5 / Inv 6
* forbidden payload — the gate's OWN suppression output never
* echoes it (Q4 enforces that).
*
* Deliberately NO `reason` / `rationale` / free-text field: a claim validates
* via the typed soundness predicate, never prose (SDD §R topology condition 2).
*/
export interface CandidateClaim {
readonly soundness: MinimalClaim;
readonly subject: string;
readonly type: string;
readonly value: unknown;
}
/**
* The Claims kernel's per-claim VERDICT record (SDD §F; §I). The §5 soundness
* verdict (Q3) for one candidate, paired with the candidate's identity so the
* caller can trace WHICH candidate got WHICH three-valued verdict. The
* `verdict` is EXACTLY the three-valued `ClaimVerdict` (Q1) — never a fourth
* value, never the turn terminal (SDD §R topology condition 3 / §P misreading).
*/
export interface ClaimSoundnessVerdict {
readonly subject: string;
readonly type: string;
/** The Q3 §5 verdict — the three-valued `VALIDATED | UNKNOWN | REFUSED`. */
readonly verdict: ClaimVerdict;
}
/**
* The Claims kernel RESULT (SDD §F; §D; §I) — the FINAL output authority's
* output. Q5 stops HERE (the renderer-from-claims is downstream, SDD §Q.7):
*
* - `perClaim` — the three-valued §5 verdict (Q1 `ClaimVerdict`) for EVERY
* candidate, in input order (P4 completeness: no candidate
* silently disappears — each gets an explicit verdict).
* - `renderable` — the consistent VALIDATED subset that may reach the
* renderer (Q4's `renderable`). A suppressed or non-validated
* claim is NOT here.
* - `terminal` — the TURN terminal (Q1 `TurnTerminal`): `RENDER` iff there
* is a non-empty consistent VALIDATED set; otherwise the
* safe terminal Q4 forced (`ESCALATE`) or `UNKNOWN` when
* nothing validated at all (honest ignorance, SDD §I/§K).
* - `consistency` — the full Q4 `ConsistencyResult` (renderable + terminal +
* the §O#5 proposition-free suppression records), surfaced so
* a downstream renderer has the structural suppression
* reasons WITHOUT this kernel re-deriving them.
*
* The `terminal` is a `TurnTerminal`, NOT a `ClaimVerdict` (SDD §I/§P): the turn
* space includes `ESCALATE`/`CLARIFY`, which the three-valued verdict does not.
*/
export interface ClaimsKernelResult {
readonly perClaim: readonly ClaimSoundnessVerdict[];
readonly renderable: readonly ConsistencyClaim[];
readonly terminal: TurnTerminal;
readonly consistency: ConsistencyResult;
/**
* inv.17 — the `renderable` set, RE-MINTED as kernel-stamped `CanonicalClaim`s.
* This is the renderer's REQUIRED input: it is the ONLY place a CanonicalClaim
* is minted, and it is reachable ONLY here, after the claim fully VALIDATED (the
* §5 predicate, incl. C6 value-binding) and survived P2 consistency. It is
* populated ONLY when `terminal === 'RENDER'`; it is EMPTY on every non-RENDER
* terminal (ESCALATE/UNKNOWN/CLARIFY, incl. STAGE-FAIL-CLOSED) — INCLUDING the
* F1 ESCALATE lone-survivor case, where `renderable` is NON-empty while
* `renderableCanonical` is []. `renderableCanonical` and `renderable` therefore
* DIVERGE on non-RENDER terminals and carry NO length/index parity: consumers
* must NOT zip or index-align the two arrays across terminals — read
* `renderableCanonical` only under RENDER. Additive: existing consumers may keep
* reading `renderable`; the renderer migrates to this field.
*/
readonly renderableCanonical: readonly CanonicalClaim[];
}
/**
* The Claims kernel = P1 ∘ P2 (SDD §F; §D; v1.1 §6) — the FINAL output authority.
* A PURE function: same ledger + candidates + deps ⟹ same result.
*
* Topology + lifecycle (SDD §F / §D, in order — ONE-DIRECTIONAL):
*
* 1. **P1 soundness (Q3), per candidate, FIRST.** Run `claimAllowed` for each
* candidate against the Evidence Ledger (the snapshot Read + Action fed —
* the kernel CONSUMES it; it never writes to it). Each candidate gets a
* three-valued `ClaimVerdict`. Validation is THROUGH the typed §5 predicate
* over `requiredEvidence` — never a free-text reason (§R condition 2).
* 2. **Form the VALIDATED set (§D).** Only `VALIDATED` candidates carry into
* P2; an UNKNOWN/REFUSED member must never enter or suppress the P2 set.
* 3. **P2 consistency (Q4) over the SET.** Run `checkConsistency` over the
* VALIDATED members → the renderable subset + the turn terminal + the
* proposition-free suppression records.
* 4. **Turn terminal (§I).** `RENDER` when a non-empty consistent VALIDATED
* set survives; `ESCALATE` when consistency suppressed something; `UNKNOWN`
* when nothing validated at all (honest ignorance — the turn surfaces it
* rather than rendering an empty set as if it had content, SDD §I/§K).
*
* The flow is ASYMMETRIC: the Ledger is read-only INPUT here; this function
* never mutates it and nothing flows Claims → Ledger → Read (SDD §F/§R topology
* condition 1 — the model does not collapse).
*/
export function runClaimsKernel(
ledger: EvidenceLedger,
candidates: readonly CandidateClaim[],
deps: ClaimsKernelDeps,
): ClaimsKernelResult {
// ── (1) P1 soundness (Q3), per candidate, against the read-only Ledger. The
// ledger is the snapshot Read + Action already fed; the Claims kernel only
// RESOLVES keys out of it (one-directional — never records into it here).
let perClaim: ClaimSoundnessVerdict[];
try {
perClaim = candidates.map((candidate) => ({
subject: candidate.subject,
type: candidate.type,
// Validation goes THROUGH the typed §5 predicate over `requiredEvidence` —
// there is no free-text reason path (§R topology condition 2).
//
// C6 value-binding (§5 C6; Theorem S (a-value)): the RENDERED value the model
// authored is `candidate.value` (the field copied UNTOUCHED into the renderable
// ConsistencyClaim below). We thread it into the soundness input so that, when
// the candidate's `soundness.valueBinding` is declared (W5), C6 binds THAT
// rendered value to its licensing evidence — closing the surplus channel where
// a claim validated on present∧fresh∧owned∧… while its value was a model
// confabulation. Additive + fail-safe: with no `valueBinding`, `value` is never
// read and the verdict is byte-identical to before.
verdict: claimAllowed(
{ ...candidate.soundness, value: candidate.value },
ledger,
deps.soundness,
),
}));
} catch {
// ── STAGE-FAIL-CLOSED for the P1 stage (W6; F3). `claimAllowed` THROWS on a
// malformed-registry PROGRAMMER error: a `falsifierComplete: true` type that
// enumerates no falsifiers (`assertFalsifierDeclaration`), or a C6
// `valueBinding.key` that is not a member of `requiredEvidence` (the binding-key
// guard). A throw in this deterministic stage must NOT propagate uncaught out of
// `runClaimsKernel` and crash every turn touching that type — it FAILS CLOSED
// exactly like the P2 `checkConsistency` try/catch below: ESCALATE with an EMPTY
// renderable, never a partial render. No trustworthy per-claim verdicts survive a
// P1 throw, so the stage-fail-closed audit set is empty.
return stageFailClosed([]);
}
// ── (2) Form the VALIDATED set (§D): only VALIDATED candidates carry into P2.
// Each carries its (subject, type, value) so the P2 gate can partition by
// subject and key the constraint table by type. A non-VALIDATED candidate is
// dropped here — it may neither enter the renderable set nor suppress a valid
// same-subject claim (SDD §D).
const consistencyInput: ConsistencyClaim[] = candidates
.map((candidate, i): ConsistencyClaim => {
const verdict = perClaim[i]?.verdict ?? "UNKNOWN";
return {
subject: candidate.subject,
type: candidate.type,
verdict,
value: candidate.value,
};
})
// DEFENSE-IN-DEPTH §D filter (NOT the single enforcement point). The P2 gate
// ALSO drops non-VALIDATED members internally (consistency.ts step 1), so §D
// is enforced in BOTH places. This pre-filter is a redundant belt-and-braces:
// it keeps `consistencyInput` honest (only VALIDATED reaches the gate) even if
// the gate's own filter were ever weakened. The two use the IDENTICAL
// predicate (`verdict === "VALIDATED"`), so they cannot diverge.
.filter((c) => c.verdict === "VALIDATED");
// ── STAGE-FAIL-CLOSED (W6 / P4 completeness). The P1 stage MUST emit exactly
// one verdict per candidate; if it ever produced a partial set (a candidate
// silently disappeared), we must NOT render the partial result → ESCALATE the
// whole turn (never a partial render). Defensive belt-and-braces over the map.
if (perClaim.length !== candidates.length) {
return stageFailClosed(perClaim);
}
// ── (3) P2 consistency (Q4) over the VALIDATED set. The consistency stage is a
// deterministic stage that CAN fail to complete (e.g. a malformed constraint
// table). If it throws, we FAIL CLOSED: ESCALATE the turn with an EMPTY
// renderable — never render the partial set we had before the stage ran (W6).
let consistency: ConsistencyResult;
try {
consistency = checkConsistency(consistencyInput, deps.consistency);
} catch {
return stageFailClosed(perClaim);
}
// ── (4) Turn terminal (§I). Q4 returns RENDER iff nothing was suppressed and
// ESCALATE otherwise. But a Q4 RENDER over an EMPTY validated set is not a
// render of anything — there is no content to show. When nothing validated,
// the turn surfaces honest ignorance: UNKNOWN (SDD §I/§K — "missing/not-found
// → honest ignorance + offer; not a failure"), never a vacuous RENDER.
const terminal: TurnTerminal =
consistency.terminal === "RENDER" && consistency.renderable.length === 0
? "UNKNOWN"
: consistency.terminal;
// ── (5) MINT (inv.17). The renderable set is the VALIDATED ∧ P2-consistent
// survivors; each carries its C6-bound (for any render-proposition type, see
// canonical-claim.ts) ledger-derived value. This is the SOLE CanonicalClaim mint
// site, structurally reachable only after the §5 predicate passed (incl. C6) and
// consistency held. The renderer takes these — never a raw ConsistencyClaim — so
// an un-validated proposition cannot reach prose.
//
// F1 — gate the mint on the TURN terminal. `consistency.renderable` can be
// NON-EMPTY on a non-RENDER terminal: e.g. subjectA has two same-type VALIDATED
// claims with conflicting values (SAME_TYPE_VALUE_CONFLICT → both suppressed →
// consistency terminal ESCALATE) while subjectB's lone VALIDATED claim has no peer
// and survives into `renderable`. Minting unconditionally would violate this field's
// documented invariant ("an empty array on every non-RENDER terminal") and hand the
// renderer a canonical claim on an ESCALATE turn. Mint ONLY when the turn actually
// RENDERs; on every other terminal the renderer-input is empty (the stageFailClosed
// path already returns []).
//
// F2 — NARROW the minted value to its C6-proven slice. A renderable claim whose
// TYPE declares a `valueBinding` had C6 compare ONLY `projectValue(value, path)`
// against the ledger (soundness.ts); any SIBLING field of a value OBJECT rode
// through UNVALIDATED (model content). We reconstruct a minimal value carrying ONLY
// the bound path, so `projectValue(minted, path)` yields the SAME C6-verified scalar
// but siblings (e.g. a model-authored `message`) are GONE — an unbound sibling is
// now UNREACHABLE via `unwrapCanonical`. The per-type binding is recovered from the
// candidates (`valueBinding` is a per-registry-type property, so all candidates of a
// type share it) keyed by the renderable claim's `${subject}|${type}` identity. A
// type with NO `valueBinding` never ran C6 and (INV-1) exposes no proposition slot
// that reads its value → carry the value UNCHANGED (do not regress that path).
const bindingBySubjectType = new Map();
for (const candidate of candidates) {
bindingBySubjectType.set(
`${candidate.subject}|${candidate.type}`,
candidate.soundness.valueBinding,
);
}
const renderableCanonical =
terminal === "RENDER"
? consistency.renderable.map((c) => {
const binding = bindingBySubjectType.get(`${c.subject}|${c.type}`);
const mintedValue =
binding === undefined ? c.value : pickPath(c.value, binding.path);
return mintCanonicalClaim(c.subject, c.type, mintedValue);
})
: [];
return {
perClaim,
renderable: consistency.renderable,
terminal,
consistency,
renderableCanonical,
};
}
/**
* The TURN terminal a STAGE-FAIL-CLOSED forces (W6): a deterministic stage that
* cannot complete must ESCALATE the whole turn, never render a partial set. First
* class (§I). Exported so a caller/test can name the fail-closed posture.
*/
export const STAGE_FAIL_CLOSED_TERMINAL: TurnTerminal = "ESCALATE";
/**
* Build the STAGE-FAIL-CLOSED kernel result (W6): the per-claim verdicts computed
* so far (audit), an EMPTY renderable (never a partial render), and the ESCALATE
* terminal + a proposition-free ESCALATE consistency result. Pure.
*/
function stageFailClosed(
perClaim: readonly ClaimSoundnessVerdict[],
): ClaimsKernelResult {
return {
perClaim,
renderable: [],
renderableCanonical: [],
terminal: STAGE_FAIL_CLOSED_TERMINAL,
consistency: { renderable: [], terminal: "ESCALATE", suppressions: [] },
};
}
/**
* F2 — reconstruct a MINIMAL value carrying ONLY the C6-bound `path` of `value`,
* dropping every unbound sibling. For a type that declares a `valueBinding`, C6
* (soundness.ts) proved ONLY `projectValue(value, path)` against the ledger and the
* renderer (INV-1) reads solely that projection; a sibling field of a value OBJECT is
* unvalidated model content. This narrows the minted `CanonicalClaim.value` to the
* proven slice: `projectValue(pickPath(v, path), path)` equals `projectValue(v, path)`,
* but no sibling survives. An absent/empty path binds the WHOLE value (§5 compares it
* whole) → return it unchanged. PURE; own-property reads only (a prototype/non-own key
* never resolves), mirroring `projectValue` in soundness.ts.
*/
function pickPath(
value: unknown,
path: readonly (string | number)[] | undefined,
): unknown {
if (path === undefined || path.length === 0) return value;
// Project to the bound leaf (own-property only). For a renderable claim this
// ALWAYS resolves to an in-grammar scalar — C6 PASSED, which requires it — but a
// missing segment fails safe to `undefined` rather than reading a prototype key.
let leaf: unknown = value;
for (const segment of path) {
if (leaf === null || typeof leaf !== "object") {
leaf = undefined;
break;
}
const obj = leaf as Record;
leaf = Object.prototype.hasOwnProperty.call(obj, segment) ? obj[segment] : undefined;
}
// Rebuild the minimal nested object holding ONLY `path` → leaf; siblings dropped.
let rebuilt: unknown = leaf;
for (let i = path.length - 1; i >= 0; i--) {
rebuilt = { [path[i]!]: rebuilt };
}
return rebuilt;
}
/**
* The injected capabilities the Claims kernel composes (SDD §F). It holds NO
* policy of its own — it threads the EXISTING Q3 `SoundnessDeps` (ownership +
* action-outcome + `now`) into P1, and the EXISTING Q4 `ConsistencyOptions`
* (the optional declared constraint table) into P2. Keeping them as the
* existing shapes means the topology adds no new injection surface — it only
* WIRES the two gates that already exist.
*/
export interface ClaimsKernelDeps {
/** Q3 §5 capabilities (P1): `owns`, `outcomeConfirmed`, `now`. */
readonly soundness: SoundnessDeps;
/** Q4 P2 options (the optional declared same-subject constraint table). */
readonly consistency?: ConsistencyOptions;
}
// ═══════════════════════════════════════════════════════════════════════════
// THE ASYMMETRIC TOPOLOGY (SDD §F; v1.1 §6; §R) — Read+Action → Ledger → Claims → Renderer
// ═══════════════════════════════════════════════════════════════════════════
//
// The §F topology is ASYMMETRIC and ONE-DIRECTIONAL. Read + Action FEED the
// Evidence Ledger (they WRITE evidence); the Claims kernel sits DOWNSTREAM and
// READS the Ledger as the final output authority. The arrow never reverses —
// Claims consumes the Ledger; the Ledger never consumes Claims, and nothing
// flows Claims → Ledger → Read. We model this as a typed STAGE enum + a
// directed adjacency so the asymmetry is inspectable AND testable (a §R
// topology-collapse becomes a failing assertion against this graph).
/**
* The FOUR distinct stages of the §F topology (SDD §F; §R topology condition 1).
* They are DISTINCT — collapsing any two is the §R "kernel model collapses"
* failure. Order is the spec's left-to-right flow.
*/
export type TopologyStage = "READ_ACTION" | "EVIDENCE_LEDGER" | "CLAIMS" | "RENDERER";
/**
* The closed membership tuple for `TopologyStage`, in flow order (SDD §F).
*/
export const TOPOLOGY_STAGES: readonly TopologyStage[] = [
"READ_ACTION",
"EVIDENCE_LEDGER",
"CLAIMS",
"RENDERER",
] as const;
/**
* One DIRECTED edge of the §F topology — `from` FEEDS `to` (SDD §F). The
* direction is load-bearing: it encodes that Read+Action feed the Ledger and the
* Ledger feeds Claims, never the reverse.
*/
export interface TopologyEdge {
readonly from: TopologyStage;
readonly to: TopologyStage;
}
/**
* The §F topology as a DIRECTED, ACYCLIC, ONE-DIRECTIONAL adjacency (SDD §F;
* v1.1 §6; §R topology condition 1):
*
* READ_ACTION → EVIDENCE_LEDGER → CLAIMS → RENDERER
*
* EXACTLY these three forward edges; NO backward edge (no `CLAIMS → ...`,
* `EVIDENCE_LEDGER → READ_ACTION`, etc.). The Claims kernel CONSUMES the Ledger
* (`EVIDENCE_LEDGER → CLAIMS`); the Ledger does NOT consume Claims. This is the
* data-flow asymmetry §F demands; `topologyHasBackwardEdge` proves it holds.
*/
export const ASYMMETRIC_TOPOLOGY: readonly TopologyEdge[] = [
{ from: "READ_ACTION", to: "EVIDENCE_LEDGER" },
{ from: "EVIDENCE_LEDGER", to: "CLAIMS" },
{ from: "CLAIMS", to: "RENDERER" },
] as const;
/**
* The forward RANK of each stage on the §F flow (SDD §F). A topology edge is
* FORWARD iff `rank(from) < rank(to)`. Used to detect a backward (or self) edge
* — the §R topology-collapse signal.
*/
function stageRank(stage: TopologyStage): number {
return TOPOLOGY_STAGES.indexOf(stage);
}
/**
* Does the topology contain ANY backward or self edge (SDD §F; §R topology
* condition 1)? `true` iff some edge has `rank(from) >= rank(to)` — i.e. the
* flow is NOT strictly one-directional (a Claims → Ledger → Read reversal, or a
* stage feeding itself, would be a collapse). For `ASYMMETRIC_TOPOLOGY` this is
* `false` by construction; the test asserts that, and asserts that INJECTING a
* backward edge flips it to `true` (non-vacuity). Pure.
*/
export function topologyHasBackwardEdge(
edges: readonly TopologyEdge[],
): boolean {
return edges.some((edge) => stageRank(edge.from) >= stageRank(edge.to));
}
/**
* Does the Evidence Ledger CONSUME Claims (SDD §F; §R topology condition 1)? The
* §F asymmetry says NO — Claims consumes the Ledger, never the reverse. `true`
* iff any edge points FROM `CLAIMS` (or `RENDERER`) back INTO `EVIDENCE_LEDGER`
* or `READ_ACTION`. For `ASYMMETRIC_TOPOLOGY` this is `false`; a `CLAIMS →
* EVIDENCE_LEDGER` (or `→ READ_ACTION`) edge would make it `true` (the collapse
* the §R test forbids). Pure.
*/
export function ledgerConsumesClaims(
edges: readonly TopologyEdge[],
): boolean {
const upstream: readonly TopologyStage[] = ["READ_ACTION", "EVIDENCE_LEDGER"];
const downstream: readonly TopologyStage[] = ["CLAIMS", "RENDERER"];
return edges.some(
(edge) =>
downstream.includes(edge.from) && upstream.includes(edge.to),
);
}