diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9e27b45..ad6b803 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,7 +8,33 @@ permissions:
contents: read
jobs:
+ checks:
+ name: Release documentation and test gates
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
+ with:
+ python-version: '3.12'
+ - name: Install source test dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install setuptools==75.8.0 wheel==0.45.1
+ python -m pip install -e .
+ - name: Documentation and API drift gate
+ run: python docs/check_docs.py
+ - name: Compile source gate
+ run: python -m compileall -q fable_mode fable_engine fable_v2
+ - name: Canonical V1 suite gate
+ run: python -m unittest discover -s tests -p 'test_server*.py' -v
+ - name: Full unittest suite gate
+ run: python -m unittest discover -s tests -p 'test_*.py' -v
+ - name: Shell wrapper syntax gate
+ shell: bash
+ run: sh -n install.sh
+
build:
+ needs: checks
name: "artifact-${{ matrix.artifact }}"
runs-on: "${{ matrix.os }}"
strategy:
@@ -17,12 +43,10 @@ jobs:
include:
- os: ubuntu-latest
artifact: linux-x86_64
- # macOS 13 is retired; macos-15-intel preserves the x86_64
- # build while using the supported Intel runner image.
+ # macOS 13 is retired; the Intel image preserves the x86_64 artifact.
- os: macos-15-intel
artifact: macos-x86_64
- # macOS 14 is being retired; use the supported macOS 15 arm64
- # image for the Apple Silicon build.
+ # macOS 15 on the standard hosted image is Apple Silicon.
- os: macos-15
artifact: macos-arm64
- os: windows-latest
@@ -198,6 +222,8 @@ jobs:
permissions:
contents: write
steps:
+ # Generated release notes need the tag history, not a shallow default
+ # branch checkout. Keep this before the release-creation step.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.ref }}
@@ -212,33 +238,29 @@ jobs:
tag="${GITHUB_REF_NAME:?GITHUB_REF_NAME must be set}"
mkdir -p upload
- # Each build artifact must contribute exactly one expected,
- # versioned archive. Keep those names unchanged, then copy the
- # same bytes to a stable alias for the /releases/latest/download
- # links documented in README.md.
+ # build_release.py emits the bare package version. The public
+ # release keeps the tag spelling and also provides stable
+ # /releases/latest/download aliases for README links.
for descriptor in \
- "windows-x86_64|fable-mode-${tag}-windows-x86_64.zip|fable-mode-windows-x86_64.zip" \
- "macos-x86_64|fable-mode-${tag}-macos-x86_64.zip|fable-mode-macos-x86_64.zip" \
- "macos-arm64|fable-mode-${tag}-macos-arm64.zip|fable-mode-macos-arm64.zip" \
- "linux-x86_64|fable-mode-${tag}-linux-x86_64.tar.gz|fable-mode-linux-x86_64.tar.gz"; do
- IFS='|' read -r artifact archive alias <<< "$descriptor"
+ "windows-x86_64|fable-mode-${tag#v}-windows-x86_64.zip|fable-mode-${tag}-windows-x86_64.zip|fable-mode-windows-x86_64.zip" \
+ "macos-x86_64|fable-mode-${tag#v}-macos-x86_64.zip|fable-mode-${tag}-macos-x86_64.zip|fable-mode-macos-x86_64.zip" \
+ "macos-arm64|fable-mode-${tag#v}-macos-arm64.zip|fable-mode-${tag}-macos-arm64.zip|fable-mode-macos-arm64.zip" \
+ "linux-x86_64|fable-mode-${tag#v}-linux-x86_64.tar.gz|fable-mode-${tag}-linux-x86_64.tar.gz|fable-mode-linux-x86_64.tar.gz"; do
+ IFS='|' read -r artifact source_archive archive alias <<< "$descriptor"
source_dir="release/fable-mode-${artifact}"
- source_file="${source_dir}/${archive}"
+ source_file="${source_dir}/${source_archive}"
test -d "$source_dir"
test -f "$source_file"
- archive_count=$(find "$source_dir" -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | wc -l)
+ archive_count=$(find "$source_dir" -maxdepth 1 -type f \(-name '*.zip' -o -name '*.tar.gz' \) | wc -l)
test "$archive_count" -eq 1
- test "$archive" != "$alias"
test ! -e "upload/$archive"
test ! -e "upload/$alias"
cp -- "$source_file" "upload/$archive"
cp -- "$source_file" "upload/$alias"
done
- # There must be four versioned archives plus four aliases. The
- # generated checksum file below therefore covers every uploaded
- # ZIP/TAR asset, including each stable alias.
- test "$(find upload -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | wc -l)" -eq 8
+ # Four versioned archives and four aliases are checksummed together.
+ test "$(find upload -maxdepth 1 -type f \(-name '*.zip' -o -name '*.tar.gz' \) | wc -l)" -eq 8
test ! -e upload/SHA256SUMS
(
cd upload
@@ -252,4 +274,3 @@ jobs:
set -eu
test -n "${GITHUB_REF_NAME}"
gh release create "${GITHUB_REF_NAME}" upload/* --title "Fable Mode ${GITHUB_REF_NAME}" --generate-notes
-
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2980904..6ac4954 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -31,7 +31,7 @@ jobs:
python -m compileall -q fable_mode fable_engine fable_v2
python -c "import fable_mode, fable_v2; from fable_engine import server; print(fable_mode.__version__)"
- name: Canonical V1 suite
- run: python fable_engine/test_server.py
+ run: python -m unittest discover -s tests -p 'test_server*.py' -v
- name: Canonical V2 and packaging/security suites
run: python -m unittest discover -s tests -p 'test_*.py' -v
- name: Validate packaging definition
diff --git a/README.md b/README.md
index c48b54a..3c6460c 100644
--- a/README.md
+++ b/README.md
@@ -1,344 +1,223 @@
-
+# Fable Mode
+
+Fable Mode is a small, independent Python project for giving an AI host a more
+structured way to plan, inspect, test, and review work. It records a session,
+separates observations from hypotheses, and can require evidence and checks
+before a session is marked ready for execution.
+
+It is not a model, a proof system, or a security sandbox. A successful tool
+call does not make a model's conclusion correct. The V1 time lock is a workflow
+control implemented by the server process; it is not an operating-system
+permission boundary. V2 is an experimental runtime and broker, not a finished
+replacement for V1. Measure outcomes in the host and workload where you intend
+to use it.
+
+**Package version:** 1.2.3
+
+
+Fable Mode is maintained independently by REX-codebase under the MIT license.
+It is not affiliated with any model vendor or host named in this repository.
+
+## Choose your path
+
+| You want to... | Start here | Status | Important boundary |
+|---|---|---|---|
+| Add the existing MCP session tool to a compatible host | [V1 quickstart](#v1-quickstart) and [`docs/mcp-reference.md`](docs/mcp-reference.md) | Documented; source CI-tested on the listed runners | V1 is the legacy `fable_engine.server` stdio server |
+| Understand what is installed by the source helpers | [`install.sh`](install.sh) or [`install.ps1`](install.ps1) | Documented; wrapper syntax is CI-tested on its respective OS | They register V1; the installed runtime also contains the experimental V2 package and broker |
+| Try the portable evidence-gated runtime | [`docs/fable-v2-architecture.md`](docs/fable-v2-architecture.md) | Experimental | V2 has a Python runtime and a separate broker, but no drop-in V2 MCP server |
+| Move from V1 to V2 | [`docs/fable-v1-v2-migration.md`](docs/fable-v1-v2-migration.md) | Experimental migration | Keep V1 until a host adapter and broker deployment are conformance-tested |
+| Check host and platform support | [`docs/host-compatibility.md`](docs/host-compatibility.md) | Documented status matrix | A profile is an expectation until a live probe attests it |
+| Inspect security, files, or releases | [`docs/security-and-trust.md`](docs/security-and-trust.md), [`docs/data-and-artifacts.md`](docs/data-and-artifacts.md), and [`docs/release-policy.md`](docs/release-policy.md) | Documented | Release archives are currently unsigned; verify checksums and provenance |
+
+## V1 and V2 are different things
+
+### V1: `fable-engine` session MCP server
+
+V1 is the current, backward-compatible product surface. It exposes one MCP
+tool, `fable_session`, over JSON-RPC 2.0 on stdin/stdout. The tool manages a
+named session, a wall-clock authority budget, six phases, an epistemic ledger,
+invariants, refinement entries, checkpoints, and optional CAS payload helpers.
+CAS references provide content addressing and integrity verification for normal
+reads; they are not encryption, access control, authenticity, or a guaranteed
+compression/token ratio. Any token counts, ratios, and the optional `<= 0.003
+tokens/character` check shown by the payload helper are rough character-based
+estimates for diagnostics, not measured model-token usage or guarantees.
+Existing installation helpers deliberately register this server.
+
+V1's `unlock_execution` action checks that the authority deadline has elapsed,
+that two `PROVEN` entries have evidence, that one invariant has a proof or
+rationale, and that the session has reached Phase 3 or later. The check helps a
+host coordinate work; it does not prove the work is correct and does not grant
+filesystem permissions. An internal `set_timer` pacing timer cannot unlock the
+session. Any silent-deliberation or “zero-chat” convention during the time lock
+is advisory host behavior, not a server-enforced guarantee; the MCP transport
+still returns protocol responses. An emergency token, when configured by an
+administrator out of band, is not part of the model-facing MCP schema. See the
+[V1 theory notes](docs/fable-v1-theory.md) for the design rationale and
+limitations.
-
-
-# ?? Fable-Mode: Deterministic Deliberative Cognitive Architecture & Fleet Orchestrator
-
-[](https://www.python.org/)
-[](./LICENSE)
-[](https://modelcontextprotocol.io/)
-[-brightgreen?style=for-the-badge)](#-zero-dependencies-architecture)
-[](#-domain-cognitive-gears)
-[](#-3-cinematic-design-engine)
-
-**A deterministic, evidence-gated cognitive architecture and Model Context Protocol (MCP) orchestrator that transforms shallow, rushed AI code generation into verified, deep-deliberation engineering workflows.**
-
-[What Fable-Mode Is](#-what-fable-mode-actually-is-and-what-it-is-not) ? [Core Architecture](#-core-architecture--components) ? [Domain Cognitive Gears](#-domain-cognitive-gears) ? [Lifecycle & State Machine](#-how-it-works-in-practice-the-6-phase-lifecycle) ? [MCP Tool Reference](#-mcp-tool-reference--api) ? [Installation & Downloads](#-installation--downloads) ? [Benchmarks & Tests](#-benchmarks--test-verification)
-
-
-
----
-
-> **Independence Notice:** Fable-Mode is independently developed and maintained by **REX-codebase**. It is not affiliated with, endorsed by, or sponsored by Google, Anthropic, OpenAI, or any model vendor. Host names and paths described below represent protocol compatibility integrations only.
+```mermaid
+flowchart LR
+ H[Host MCP client] -- JSON-RPC 2.0 over stdio --> S[fable_engine.server]
+ S --> T[fable_session tool]
+ T --> L[Ledger, phases, invariants, refinements]
+ T --> D[Private data directory / sessions]
+ H -. host-owned execution after workflow gate .-> W[Host tools]
+```
-> **Ownership & License:** All source code, design blueprints, test suites, and documentation in this repository are published under the open-source [MIT License](./LICENSE).
+V1 does **not** automatically control the host's tools, shell, files, model,
+subagents, or operating-system permissions. The diagram's dashed relationship
+is intentional.
----
+### V2: portable runtime and execution broker
-## ? What Fable-Mode Actually Is (and What It Is Not)
+V2 is an experimental, model-agnostic foundation in `fable_v2/`. It defines
+typed task contracts, candidate artifacts, host-produced tool receipts,
+integrity-bound evidence, verifier policies, and a run state machine. The
+`fable-v2-broker` command is a separate JSON-lines process for bounded command
+execution, inspection, and workspace writes. It is not a V1 MCP server and is
+not an alias for `fable-engine`.
-### The Structural Failure of Rushed AI Reasoning
+```mermaid
+flowchart LR
+ U[Host adapter] --> Q[TaskSpec and budget]
+ Q --> R[FableRun]
+ R --> C[Candidates]
+ R --> E[Receipts and evidence]
+ R --> V[Registered verifiers]
+ V --> G[Finalization gate]
+ U --> B[fable-v2-broker process]
+ B --> X[Allowlisted commands]
+ B --> I[Workspace inspection]
+ B --> Y[Workspace writes, locked by default]
+ A[Administrator control handle] -. separate channel .-> B
+```
-Standard autonomous coding agents exhibit a systemic vulnerability: **premature execution rushing accompanied by ungrounded reasoning**. When presented with complex, non-trivial engineering tasks, language models frequently rush into modifying codebase files within 15?45 seconds. In doing so, they fail to model hardware memory hierarchies, concurrency race conditions, distributed partition modes, or mathematical state invariants.
+V2 currently requires an adapter to connect a host to these contracts. Its
+in-process verifier foundation is not a security boundary; hostile workloads
+need OS-level isolation. The broker is a policy boundary, not a complete
+sandbox. Read the [V2 architecture](docs/fable-v2-architecture.md) before
+using it.
-Attempts to address this with probabilistic Monte Carlo Tree Search (MCTS) struggle in real-world software engineering contexts:
-1. **Stochastic $Q$-Score Drift**: LLMs evaluate simulated rollouts with soft confidence scores. Hallucinated premises compound across search nodes, causing the tree to assign high values to flawed architectural proposals.
-2. **Brittle Heuristic Rollouts**: Software systems are deterministic, stateful, and non-linear. Speculative token rollouts cannot substitute for live compiler diagnostics, AST validation, and formal state proofs.
-3. **Premature Termination**: Given a 30-minute thinking budget, unconstrained stochastic searches often exhaust their token sampling heuristic within 2?3 minutes, abandoning the requested deliberation window.
+## V1 quickstart
-### The Fable-Mode Solution
+The source checkout requires Python 3.10 or newer and has no declared runtime
+dependencies. The commands below run the source implementation directly. The
+server speaks MCP over stdio, so normally a host launches it rather than a
+human waiting for terminal output.
-`fable-mode` enforces **Deterministic Deliberative System 2 & System 3 Cognition** through enforceable mechanical constraints and evidence admission gates:
+### macOS, Linux, and WSL2
-| Dimensional Vector | Stochastic Prompting / MCTS | Fable-Mode Architecture |
-| :--- | :--- | :--- |
-| **Epistemic Grounding** | ?? Soft heuristic confidence scores; unverified assumptions treated as facts. | ?? **Strict Epistemic Ledger** (`[PROVEN]`, `[HYPOTHESIS]`, `[UNKNOWN]`) verified via live tools. |
-| **Verification Rigor** | ?? Probabilistic token generation and speculative self-evaluations. | ?? **Formal Binary Invariant Proofs** (Curry-Howard proofs, Kripke bisimulations, state bounds). |
-| **Execution Safety** | ?? Permissive; unverified code edits occur immediately in the workspace. | ?? **Mechanical Time-Lock**: Workspace code modifications are locked at the engine level. |
-| **Time-Budget Pacing** | ?? Erratic; models stop or argue against long-running tasks within minutes. | ?? **Immutable Authority Monotonic Timer**: Model remains in active cognitive refinement. |
-| **Cognitive Architecture**| ?? Monolithic agent interleaves high-level planning and syntax typing in one context. | ?? **Strict Role Separation**: Master Architect (System 2/3) conducts Subagent Fleet (Coders). |
+```sh
+python3 --version # must be Python 3.10+
+python3 -m venv .venv
+. .venv/bin/activate
+python -m pip install -e .
+fable-mode --version # should print 1.2.3
-$$\boxed{\text{System 1 Proposal} \xrightarrow{\quad\text{Epistemic Grounding}\quad} \text{Invariant Proofs} \xrightarrow{\quad\text{Adversarial Red-Teaming}\quad} \text{Mechanical Unlock} \implies \text{Evidence-Backed Execution}}$$
+# Run the V1 MCP server directly (keep stdin/stdout connected to the host):
+python -m fable_mode serve
+```
----
+WSL2 uses the Linux commands when the checkout and Python environment are
+inside WSL. A Windows-hosted editor may need a WSL-aware MCP configuration and
+an absolute path visible inside WSL; that integration is documented but is not
+covered by the Windows CI job or a dedicated WSL runner.
-## ??? Core Architecture & Components
+Optional source helper (installs a private copy and can register discovered
+host CLIs):
-```mermaid
-flowchart TB
- subgraph Host["MCP Client Host (Claude Code / Codex / Antigravity)"]
- direction TB
- HostReq["User Request + Time Budget (e.g. 45m)"]
- end
-
- subgraph FableMCP["fable-engine MCP Server (Pure Python Stdlib)"]
- direction TB
- SessionMgr["Session Manager & State Engine"]
- WAL["Write-Ahead Log (WAL) Disk Store"]
- TimeLock["Immutable Monotonic Authority Time-Lock"]
- Gatekeeper["Admission Gatekeeper (Epistemic + Invariant Rules)"]
-
- SessionMgr <--> WAL
- SessionMgr <--> TimeLock
- TimeLock --> Gatekeeper
- end
-
- subgraph Deliberation["Phase 1-3: System 2 / System 3 Cognitive Engine"]
- direction TB
- Architect["Master Architect Conductor"]
- Probes["Live System Probes: run_command (Diagnostics / Benchmarks)"]
- Artifacts["Design Artifacts: /brain/ Blueprints & 10D Matrices"]
- RefineLoop["Continuous Rethink-Refine Loop (log_refinement_cycle)"]
-
- Architect --> Probes
- Architect --> Artifacts
- Architect --> RefineLoop
- end
-
- subgraph Execution["Phase 4-6: Subagent Implementation Fleet (Post-Unlock)"]
- direction TB
- FleetRouter["Subagent Dispatcher (type: self, type: research)"]
- Coder1["Coder Subagent A: Core Engine Implementation"]
- Coder2["Coder Subagent B: Unit & Concurrency Test Suites"]
- Verifier["Quality Gatekeeper: Strict Lint + Metamorphic Checks"]
-
- FleetRouter --> Coder1
- FleetRouter --> Coder2
- Coder1 --> Verifier
- Coder2 --> Verifier
- end
-
- HostReq --> SessionMgr
- SessionMgr --> Architect
- Architect --> Gatekeeper
- Gatekeeper -- "Monotonic Clock Passed + Proofs Valid" --> FleetRouter
+```sh
+./install.sh --dry-run --yes
+./install.sh --yes # install V1 without host registration
+./install.sh --yes --register-hosts --workspace "$PWD"
```
-### 1. Zero-Dependency Stdio MCP Server (`fable-engine`)
-The core server (`fable_engine.server`) is built entirely upon Python's standard library with zero third-party runtime dependencies. It communicates over standard I/O streams using JSON-RPC 2.0, exposing the unified `fable_session` tool.
-
-### 2. ACID-Compliant Session State & Write-Ahead Log (WAL)
-Every session transition, epistemic observation, invariant proof, and refinement record is appended to an on-disk Write-Ahead Log (WAL) with atomic rename operations (`os.replace`). Session files reside in platform-native user data directories:
-- **Windows**: `%LOCALAPPDATA%\FableMode\data\sessions\.json`
-- **POSIX (macOS / Linux)**: `~/.local/share/fable-mode/data/sessions/.json`
+`--register-hosts` only changes hosts that the helper can safely discover.
+For Antigravity, registration updates its global
+`~/.gemini/config/mcp_config.json`; when `--workspace` is supplied it also
+creates or updates `/.agents/mcp_config.json`. Unrelated keys are
+preserved, but these are real configuration side effects (and are normalized
+to owner-only permissions where supported). Review both resulting files
+before use. The helper records ownership so uninstall can restore the prior
+entries.
-### 3. Immutable Authority Time-Lock Mechanics
-When an engineering time budget (e.g. `30 mins`, `45 mins`, `4 hours`) is established via `create_session`, the engine initializes an immutable monotonic deadline:
+To verify a source-helper install, invoke the copy that was installed rather
+than assuming a `fable-mode` command is on PATH:
-$$t_{\text{deadline}} = t_{\text{start}} + T_{\text{budget}}$$
-
-- **Unbypassable Enforcement**: The authority deadline is fixed at creation. Agent pacing timers (`set_timer`) can subdivide internal tasks, but cannot shorten the authority deadline.
-- **Fail-Closed Execution Gate**: If an agent attempts to invoke `unlock_execution` before $t \ge t_{\text{deadline}}$, the tool immediately raises a `PermissionError` lockout rejection.
-- **Admission Criteria**: Unlocking requires reaching Phase $\ge 3$, possessing $\ge 2$ `[PROVEN]` epistemic items with empirical evidence, and registering $\ge 1$ formal invariant with proof.
+```sh
+python3 /absolute/path/to/install/runtime/fable_mode_entry.py verify \
+ --install-dir /absolute/path/to/install
+```
-### 4. Strict Cognitive Role Separation
-- **Main Agent (Master Architect & Deliberation Conductor)**: Handles all heavy System 2 & System 3 reasoning, epistemic calibration, architectural blueprinting, TRIZ contradiction resolutions, invariant proofs, and post-implementation verification. **The Main Agent is structurally prohibited from writing or modifying codebase files directly.**
-- **Subagent Fleet (Coder & Implementer Fleet)**: 100% of codebase source modifications (`write_to_file`, `replace_file_content`), build script repairs, and test suites are executed **exclusively by subagents** (`type: self` or `type: research`) dispatched only after execution is formally unlocked.
+### Windows PowerShell
----
+```powershell
+py -3 --version # must be Python 3.10+
+py -3 -m venv .venv
+.\.venv\Scripts\Activate.ps1
+python -m pip install -e .
+fable-mode --version # should print 1.2.3
-## ?? Domain Cognitive Gears
+# Run the V1 MCP server directly:
+python -m fable_mode serve
+```
-Fable-Mode equips language models with specialized cognitive gears tailored to distinct engineering challenges:
+The source helper is:
-```
- ???????????????????????????????????????????????????????????
- ? FABLE-MODE COGNITIVE GEARS ?
- ???????????????????????????????????????????????????????????
- ?
- ?????????????????????????????????????????????????????????????????????????????
- ? ? ? ?
-??????????????????? ????????????????????? ??????????????????? ?????????????????????
-? Architecture & ? ? System 3 Meta- ? ? Cinematic Design? ? Token Compression ?
-? Systems Engine ? ? Cognitive Engine ? ? Engine ? ? (FableCompress)?
-??????????????????? ????????????????????? ??????????????????? ?????????????????????
- ? ? ? ?
- ?? Invariant Proofs ?? Pearl Causal DAGs (do-calculus) ?? 7-Layer Depth ?? Content-Addressed
- ?? Hardware Topology?? Kripke Model Checker (CTL*) ?? 6 Haute Archetypes Storage (CAS)
- ?? Cache Line Align ?? Dialectical TRIZ Synthesis ?? Golden Ratio Typo?? Grammar333
- ?? 10D Trade-Offs ?? Friston Active Inference ?? Newtonian Motion ?? Windowed Slices
- ?? G?delian Proof Oracle ?? Anti-Slop Protocol
+```powershell
+.\install.ps1 -DryRun -Yes
+.\install.ps1 -Yes # install V1 without registration
+.\install.ps1 -Yes -RegisterHosts -Workspace (Get-Location)
```
-### 1. Architecture & Systems Engineering
-- **Formal Invariant Verification**: Rigorous state-space bounds, lock-free progress guarantees, and acquire-release memory ordering specifications.
-- **Hardware Topology Profiling**: Cache line padding (64-byte L1 isolation), false-sharing elimination, and NUMA node locality modeling.
-- **10-Dimensional Vector Scoring**: Systematic evaluation across Latency ($L$), Throughput ($T$), Memory Footprint ($M$), Fault Tolerance ($F$), Concurrency Safety ($S$), Complexity ($C$), Observability ($O$), Determinism ($D$), Testability ($E$), and Modularity ($V$).
-
-### 2. System 3 Meta-Cognition (Frontier Reasoning)
-Located in `fable_v2/system3/`, this subsystem implements advanced epistemological and mathematical deliberation:
-
-- **Causal DAG Simulation (`causal.py`)**: Models system architectures as directed acyclic causal graphs using Judea Pearl's *do-calculus*. Performs counterfactual intervention analysis ($P(Y \mid \text{do}(X=x))$) to calculate systemic brittleness and identify single-point cascade failures.
-- **Kripke Modal Model Checking (`kripke.py`)**: Formally verifies safety and liveness properties using Kripke structures ($M = \langle S, R, V \rangle$) across branching state spaces. Evaluates temporal modal operators ($\Box \phi$, $\Diamond \phi$, $\bigcirc \phi$, $\phi \ \mathcal{U} \ \psi$).
-- **TRIZ Contradiction Matrix & Dialectical Synthesis (`dialectical.py`)**: Resolves engineering trade-offs (e.g. throughput vs lock contention, memory footprint vs speed) using the 40 classical TRIZ inventive principles rather than accepting weak compromises.
-- **Friston Active Inference (`free_energy.py`)**: Optimizes deliberation policies by minimizing expected variational free energy ($G(\pi)$), balancing epistemic information foraging (reducing model ambiguity) with pragmatic goal satisfaction.
-- **G?delian Proof Oracle (`oracle.py`)**: Employs the Curry-Howard correspondence to type-check lambda terms against constructive proposition types (e.g. conjunctions, disjunctions, implicational logic), providing formal proofs for recorded invariants.
-- **Hyperbolic Poincar? Embeddings (`hyperbolic.py`)**: Maps hierarchical codebase and dependency graphs into 2D Poincar? disk hyperbolic space, preserving tree topologies without distortion.
-- **Genetic Evolutionary Paradigm Search (`evolution.py`)**: Searches architectural design genomes across mutation and crossover operators, selecting Pareto-optimal candidates along the 10D trade-off frontier.
-
-### 3. Cinematic Design Engine
-Dedicated to frontier UI/UX and product design engineering:
-
-- **7-Layer Optical Depth Staging**:
- - `L0: Atmospheric Canvas` (Background foundations, ambient environment)
- - `L1: Dynamic Ambient Scrim` (Depth-enhancing radial gradients, noise overlays)
- - `L2: Surface Container` (Bento grids, modular structural boundaries)
- - `L3: Elevated Interaction` (Interactive controls, responsive hover states)
- - `L4: Accent Emissive` (Brand focus accents, subtle lighting cues)
- - `L5: Floating Overlay` (Context menus, modal dialogs, tooltips)
- - `L6: Viewport Grain` (Subtle analog texture and optical polish)
-- **6 Haute Aesthetic Archetypes**:
- - *Ethereal Editorial*: Minimalist luxury typography with expansive whitespace and subtle line borders.
- - *Industrial Precision*: High-density data readouts, monospaced metrics, and muted technical grays.
- - *Swiss Brutalist*: High-contrast typography, sharp grids, architectural discipline, and zero rounded fluff.
- - *Cybernetic High-Tech*: Deep dark tones with calibrated luminescent accents and real-time telemetry panels.
- - *Organic Warmth*: Earth-toned OKLCH palettes, natural textures, and rounded tactile geometry.
- - *Velvet Luxury*: Deep midnight tones, gilded accents, serif headlines, and cinematic backdrop filters.
-- **Fluid Golden-Ratio Typography**: Modular scaling ($\phi = 1.618$) using CSS `clamp(min, preferred, max)` math, automatic optical sizing, and calibrated negative letter-spacing for large display headings.
-- **Newtonian Spring Motion Physics**: Realistic animation dynamics governed by second-order differential mechanics ($F = -kx - cv$) with critical damping ($\zeta \approx 1.0$), combined with strict `@media (prefers-reduced-motion: reduce)` accessibility compliance.
-- **Anti-Slop Elimination Protocol**: Explicit prohibition of generic AI design anti-patterns (uncalibrated purple gradients, non-functional floating cards, unstyled default states, and decorative placeholder clutter).
-
-### 4. Token Compression Subsystem (FableCompress)
-Implemented in `fable_compressor.py`:
-
-- **Content-Addressed Storage (CAS)**: SHA-256 deduplicated, cryptographically verified object storage with atomic disk writes. Reduces redundant context transmission across large tool outputs.
-- **Grammar333 High-Entropy Micro-Bytecode**: Compact structural serialization for tool actions, payloads, and JSON-RPC packets.
-- **Adaptive Chunk Accumulator**: Coalesces micro-payloads into composite frames, maintaining high throughput and minimizing token fragmentation.
-- **Zero-Copy Windowed Line Slice Viewing**: Efficient line-indexed range extraction for examining large files and session transcripts.
-
----
-
-## ?? How It Works in Practice: The 6-Phase Lifecycle
+PowerShell execution policy may require the user or administrator to permit a
+local script. The helper uses `py -3` when available and otherwise accepts a
+`python` command only after it reports Python 3. With `-RegisterHosts
+-Workspace`, Antigravity also writes `/.agents/mcp_config.json` in
+addition to its global `.gemini\config\mcp_config.json`; inspect both files
+because registration is a real configuration side effect.
-```mermaid
-stateDiagram-v2
- [*] --> Phase1: create_session & set_timer
-
- state "Phase 1: Reconnaissance & Epistemic Grounding" as Phase1 {
- [*] --> EpistemicDiscovery
- EpistemicDiscovery --> LogProven: log_epistemic_item [PROVEN]
- EpistemicDiscovery --> LogUnknown: log_epistemic_item [UNKNOWN]
- }
-
- Phase1 --> Phase2: advance_phase
-
- state "Phase 2: Axiomatic Bounds & Multi-Archetype Synthesis" as Phase2 {
- [*] --> HardwareProfiling: run_command (Probe Hardware & Dependencies)
- HardwareProfiling --> MultiArchetype: 10D Trade-off Matrix
- MultiArchetype --> TRIZSynthesis: System 3 Dialectical Resolution
- }
-
- Phase2 --> Phase3: advance_phase
-
- state "Phase 3: System 2 Deliberation & Invariant Proofs" as Phase3 {
- [*] --> FormalProof: record_invariant & system3_proof_oracle
- FormalProof --> ModalChecking: system3_kripke_verify
- ModalChecking --> RefineLoop: Continuous Rethink-Refine
-
- state "Continuous Rethink-Refine (log_refinement_cycle)" as RefineLoop {
- [*] --> MutateArchetype
- MutateArchetype --> TerminalBenchmark: run_command (Scratch Probes)
- TerminalBenchmark --> TightenProof
- TightenProof --> MutateArchetype: Authority Clock Running
- }
- }
-
- Phase3 --> LockoutGate: unlock_execution
-
- state LockoutGate <>
- LockoutGate --> Phase3: [now < deadline] ?? Hard Rejection Exception
- LockoutGate --> Phase4: [now >= deadline & DoD Valid] ?? Execution Unlocked
-
- state "Phase 4: Subagent Fleet Delegation & Implementation" as Phase4 {
- [*] --> DispatchSubagents: invoke_subagent (type: self)
- DispatchSubagents --> CoderExecution: write_to_file & replace_file_content
- CoderExecution --> LocalUnitTests: Subagents run tests
- LocalUnitTests --> SubagentReport: send_message (Diffs & Test Logs)
- }
-
- Phase4 --> Phase5: Subagents Complete Work
-
- state "Phase 5: Multi-Tier Quality Gatekeeping" as Phase5 {
- [*] --> Tier1_Lint: Strict Linters & Diagnostics
- Tier1_Lint --> Tier2_Unit: 100% Green Unit Tests
- Tier2_Unit --> Tier3_Concurrency: Race & Sanitizer Verification
- Tier3_Concurrency --> Tier4_Metamorphic: Metamorphic Invariant Checks
- }
-
- Phase5 --> Phase6: Quality Gates Passed
-
- state "Phase 6: Session Checkpoint & Final Walkthrough" as Phase6 {
- [*] --> CheckpointWAL: checkpoint_session
- CheckpointWAL --> WriteWalkthrough: walkthrough.md Artifact
- }
-
- Phase6 --> [*]: Task Complete
-```
+Verify a source-helper install with its installed runtime copy:
----
-
-## ??? MCP Tool Reference & API
-
-The `fable-engine` exposes the unified `fable_session` tool supporting the following actions:
-
-| Action | Purpose | Key Parameters |
-| :--- | :--- | :--- |
-| `create_session` | Initializes a new Fable session, arms the monotonic time-lock, and writes the initial WAL state. | `session_name` (str), `objective` (str), `time_budget_minutes` (float) |
-| `set_timer` | Sets an internal agent pacing sub-timer (cannot modify the immutable authority deadline). | `session_name` (str), `time_budget_minutes` (float) |
-| `get_status` | Returns session state, elapsed time, pacing ratio, active phase, and gate readiness. | `session_name` (str) |
-| `advance_phase` | Transitions the session state machine to a subsequent phase (Phase 1 through Phase 6). | `session_name` (str), `target_phase` (str), `rationale` (str) |
-| `log_epistemic_item` | Records an epistemic fact (`[PROVEN]` requires evidence), hypothesis (`[HYPOTHESIS]`), or ambiguity (`[UNKNOWN]`). | `session_name` (str), `tag` (str), `claim` (str), `evidence` (required for PROVEN) |
-| `record_invariant` | Registers a formal invariant specification and proof (architecture, logic, or design). | `session_name` (str), `invariant_name` (str), `formal_statement` (str), `proof_or_rationale` (str), `domain` (opt) |
-| `log_refinement_cycle` | Logs a rethink-refine cycle (archetype mutation, benchmark result, invariant stress test). | `session_name` (str), `refinement_type` (str), `focus_area` (str), `critique_or_bottleneck` (str), `architectural_refinement` (str) |
-| `unlock_execution` | Anti-rush gatekeeper: verifies the monotonic authority deadline, epistemic evidence, and formal invariants. | `session_name` (str), `rationale` (str) |
-| `checkpoint_session` | Performs an atomic Write-Ahead Log (WAL) snapshot and persists session state to disk. | `session_name` (str) |
-| `system3_causal_simulate` | Runs Pearl causal DAG simulation and counterfactual intervention analysis. | `session_name` (str), `nodes` (list), `edges` (list), `interventions` (dict) |
-| `system3_kripke_verify` | Performs formal temporal logic model checking across Kripke branching worlds. | `session_name` (str), `states` (list), `transitions` (list), `formulas` (list) |
-| `system3_dialectical_synthesis` | Applies TRIZ contradiction resolution matrix to opposing engineering parameters. | `session_name` (str), `improving_param` (str), `worsening_param` (str) |
-| `system3_proof_oracle` | Verifies constructive type-theoretic proofs using the Curry-Howard isomorphism. | `session_name` (str), `term` (dict), `expected_type` (dict) |
-
-### JSON Request Examples
-
-#### Creating a Session
-```json
-{
- "action": "create_session",
- "session_name": "distributed_raft_v2",
- "objective": "Design and verify high-throughput Raft consensus with batch pipelining",
- "time_budget_minutes": 45.0
-}
+```powershell
+py -3 C:\path\to\install\runtime\fable_mode_entry.py verify `
+ --install-dir C:\path\to\install
```
-#### Logging an Epistemic Item
-```json
-{
- "action": "log_epistemic_item",
- "session_name": "distributed_raft_v2",
- "tag": "PROVEN",
- "claim": "Target architecture is x86_64 with 64-byte L1 cache line boundaries.",
- "evidence": "Observed via run_command: lscpu | grep 'L1d cache' returning 32K on 64-byte lines."
-}
-```
+### Installed commands
-#### Logging a Continuous Refinement Cycle
-```json
-{
- "action": "log_refinement_cycle",
- "session_name": "distributed_raft_v2",
- "refinement_type": "cache_line_alignment",
- "focus_area": "Atomic Log Tail Contention",
- "critique_or_bottleneck": "High false-sharing invalidations on shared tail cursor during multi-threaded append bursts.",
- "architectural_refinement": "Injected 56-byte alignas(64) padding around append cursor to isolate L1 cache lines.",
- "terminal_probe_results": "Micro-benchmark (scratch/test_ring.exe): 10M iterations latency dropped from 62.4ns to 7.8ns."
-}
-```
+The package metadata provides these entry points:
----
+| Command | Meaning |
+|---|---|
+| `fable-mode` | Package-aware installer, verifier, uninstaller, or V1 `serve` command |
+| `fable-engine` | Direct V1 MCP server |
+| `fable-v1` | Explicit alias for the direct V1 server |
+| `fable-v2-broker` | Experimental V2 broker; requires `--workspace` |
-## ?? Installation & downloads
+V2 availability is intentionally explicit across distribution paths:
-Fable-Mode is distributed as a self-contained console runtime. It installs Fable first, then can register the installed executable with detected host CLIs. The runtime needs no Python, Git, pip, Node, network service, or GUI.
+| Distribution path | V2 library and broker |
+|---|---|
+| `pip install` / editable source checkout | Available as `fable_v2` and `fable-v2-broker` |
+| `install.sh` / `install.ps1` source helper | Copied into `runtime/fable_v2` (including `system3`); invoke `PYTHONPATH=/path/to/install/runtime python3 -m fable_v2.execution_broker --workspace /path/to/workspace` (use `py -3` on Windows) |
+| Frozen release archive | V2 modules are bundled for embedding, but the frozen `fable-mode` executable exposes the V1 launcher only; use a pip/source install for the standalone V2 broker |
-### Quick downloads
+## Release downloads (v1.2.3)
-The downloader scripts are available from the `main` branch now. The release links become available only after a tagged release has completed successfully; until then, GitHub will return no release asset for those URLs. All links below are explicit GitHub-hosted download URLs and download files only.
+The latest existing release is **v1.2.3**. The release workflow is the source
+of truth: download links are available only after a tagged release has completed successfully;
+use the assets only after a tagged release has completed successfully. The Windows and macOS scripts are available from the `main` branch now; each
+script selects the exact architecture-specific archive after
+checking the GitHub Release metadata.
-**Downloaders (available from the `main` branch now):**
+**Downloaders (available from `main`):**
- [Windows PowerShell downloader](https://raw.githubusercontent.com/REX-codebase/fable-mode/main/download-windows.ps1)
- [macOS shell downloader](https://raw.githubusercontent.com/REX-codebase/fable-mode/main/download-macos.sh)
-**Latest release artifacts (only after a tagged release has completed successfully):**
+**Latest release assets:**
- [Windows x86_64 ZIP (contains `fable-mode.exe`)](https://github.com/REX-codebase/fable-mode/releases/latest/download/fable-mode-windows-x86_64.zip)
- [macOS x86_64 ZIP](https://github.com/REX-codebase/fable-mode/releases/latest/download/fable-mode-macos-x86_64.zip)
@@ -346,165 +225,82 @@ The downloader scripts are available from the `main` branch now. The release lin
- [Linux x86_64 tar.gz](https://github.com/REX-codebase/fable-mode/releases/latest/download/fable-mode-linux-x86_64.tar.gz)
- [SHA256SUMS](https://github.com/REX-codebase/fable-mode/releases/latest/download/SHA256SUMS)
-### Release artifacts and trust status
-
-Artifacts are **not available until a version tag build succeeds**. When a tagged workflow completes successfully, it publishes these architecture-specific names (the exact version is inserted in the GitHub Release):
-
-- `fable-mode-vX.Y.Z-windows-x86_64.zip` (contains `fable-mode.exe`)
-- `fable-mode-vX.Y.Z-macos-x86_64.zip` (contains `fable-mode`)
-- `fable-mode-vX.Y.Z-macos-arm64.zip` (contains `fable-mode`)
-- `fable-mode-vX.Y.Z-linux-x86_64.tar.gz` (contains `fable-mode`)
-- `SHA256SUMS` (one SHA-256 line for each archive)
-
-Supported release targets:
-- `windows-x86_64`
-- `macos-x86_64`
-- `macos-arm64`
-- `linux-x86_64`
-
-These downloaders fetch **unsigned binaries**. The workflow publishes SHA-256 checksums for transport and integrity verification, but cryptographic publisher signing, certificate validation, and macOS notarization are not included. Review the release and its checksums before use.
-
-### Option A: Pre-Built Binary Installation
-
-#### Windows (PowerShell)
-```powershell
-.\download-windows.ps1
-# Install and verify:
-& "$HOMEable-modeable-mode.exe" install --yes
-& "$HOMEable-modeable-mode.exe" verify
-```
-
-#### macOS / Linux
-```sh
-chmod +x ./download-macos.sh
-./download-macos.sh
-# Install and verify:
-"$HOME/.local/bin/fable-mode" install --yes
-"$HOME/.local/bin/fable-mode" verify
-```
-
-### Option B: Source Installation (Python 3.10+)
-
-From a local checkout of the repository:
+A completed tagged release publishes four versioned archives:
+`fable-mode-vX.Y.Z-windows-x86_64.zip`,
+`fable-mode-vX.Y.Z-macos-x86_64.zip`,
+`fable-mode-vX.Y.Z-macos-arm64.zip`, and
+`fable-mode-vX.Y.Z-linux-x86_64.tar.gz`, plus `SHA256SUMS`. It also publishes
+the stable filenames used by the latest-release links above. For the current
+release, the versioned names are `fable-mode-v1.2.3-windows-x86_64.zip`,
+`fable-mode-v1.2.3-macos-x86_64.zip`, `fable-mode-v1.2.3-macos-arm64.zip`, and
+`fable-mode-v1.2.3-linux-x86_64.tar.gz`. The macOS Intel and Apple Silicon
+archives are separate binaries, not a universal archive.
+
+These are **unsigned binaries**. `SHA256SUMS` provides SHA-256 integrity
+checks, not publisher authentication or a signature. The macOS artifacts are
+not signed and have no macOS notarization. Review release provenance and verify the
+matching checksum before extraction or execution; the downloaders verify the
+checksum before extracting and never execute a downloaded binary.
+
+For a packaged release, use the archive for the matching OS and architecture;
+see [release policy](docs/release-policy.md). Do not place a downloaded binary
+on a PATH and trust it merely because its filename is familiar.
+
+## MCP quick reference
+
+V1 uses newline-delimited JSON-RPC 2.0. The complete action and parameter
+schema is generated in `fable_engine/fable_session.json` and mirrored by the
+server's `TOOL_SCHEMA`. Examples for `initialize`, `tools/list`, `tools/call`,
+and session creation are in [`docs/mcp-reference.md`](docs/mcp-reference.md).
+The V2 broker uses a different JSON-lines request/response envelope; it is not
+MCP. Its examples are in the same reference document.
+
+## Host and platform compatibility
+
+The repository's source CI matrix runs Ubuntu, macOS, and Windows runners for
+Python 3.10, 3.11, and 3.12. That is test coverage for the source suite, not a
+claim that every host integration works there. Release CI builds separate
+Linux x86_64, macOS x86_64, macOS arm64, and Windows x86_64 archives. See the full matrix
+in [`docs/host-compatibility.md`](docs/host-compatibility.md), which separates
+**tested**, **documented**, **experimental**, and **not supported**.
+
+## Verification and development checks
+
+Runtime tests (from the repository root):
```sh
-# Install in editable mode
-python -m pip install -e .
-
-# Run server verification test suite
-python fable_engine/test_server.py
-```
-
-### Host MCP Integration Guides
-
-Configure your MCP host using the standard local stdio configuration:
-
-#### 1. Claude Code
-```powershell
-# Windows PowerShell
-claude mcp add --transport stdio fable-engine -- py -3 "C:\path oable-modeable_engine\server.py"
-
-# macOS / Linux
-claude mcp add --transport stdio fable-engine -- python3 "/path/to/fable-mode/fable_engine/server.py"
+python -m unittest discover -s tests -p 'test_server*.py' -v
+python -m unittest discover -s tests -p 'test_*.py' -v
```
-#### 2. OpenAI Codex CLI
-```powershell
-# Windows PowerShell
-codex mcp add fable-engine -- py -3 "C:\path oable-modeable_engine\server.py"
-
-# macOS / Linux
-codex mcp add fable-engine -- python3 "/path/to/fable-mode/fable_engine/server.py"
-```
-
-#### 3. Antigravity
-Add the server entry to your global configuration (`~/.gemini/config/mcp_config.json`) or workspace configuration (`.agents/mcp_config.json`):
-
-```json
-{
- "mcpServers": {
- "fable-engine": {
- "command": "python3",
- "args": [
- "/absolute/path/to/fable-mode/fable_engine/server.py"
- ],
- "cwd": "/absolute/path/to/fable-mode"
- }
- }
-}
-```
-*(On Windows, use `"command": "py"`, `"args": ["-3", "C:/path/to/fable-mode/fable_engine/server.py"]`, and Windows-style paths for `cwd`.)*
-
----
-
-## ?? Benchmarks & Test Verification
-
-The repository maintains an automated regression and verification suite covering the core MCP server, session lifecycle, token compression, and System 3 meta-cognitive engines.
-
-### Running the Test Suites
+Documentation and drift checks:
```sh
-# 1. Run canonical V1 MCP server tests
-python fable_engine/test_server.py
-
-# 2. Run full regression and System 3 verification suite
-python -m unittest discover -s tests -p "test_*.py" -v
+python docs/check_docs.py
+python -m unittest discover -s tests -p 'test_documentation.py' -v
```
----
-
-## ?? Repository Directory Layout
-
-```
-fable-mode/
-??? build_scripts/
-? ??? build_release.py # PyInstaller release packaging script
-??? fable_mode/
-? ??? launcher.py # CLI entry point and verification harness
-? ??? installer.py # Transactional host installer
-? ??? adapters.py # Host adapter definitions & discovery
-? ??? resources.json # Package resource manifest
-??? fable_engine/
-? ??? fable_session.json # MCP JSON-RPC 2.0 tool declaration schema
-? ??? server.py # Pure Python stdlib MCP server implementation
-? ??? test_server.py # Canonical V1 unit and integration suite
-??? fable_v2/
-? ??? execution_broker.py # Workspace sandboxing and execution boundary
-? ??? runtime.py # Portable candidate search & verification runtime
-? ??? protocol.py # V2 event sourcing protocol
-? ??? system3/ # System 3 Meta-Cognitive Subsystem
-? ??? causal.py # Pearl Causal DAGs & do-calculus simulation
-? ??? kripke.py # Kripke modal model checker (CTL*)
-? ??? dialectical.py # TRIZ contradiction resolution matrix
-? ??? free_energy.py # Friston Active Inference & variational free energy
-? ??? oracle.py # G?delian proof oracle (Curry-Howard isomorphism)
-? ??? hyperbolic.py # Poincar? disk hyperbolic tree embeddings
-? ??? evolution.py # Genetic evolutionary paradigm search
-??? fable_compressor.py # Token Compression (CAS store, Grammar333 bytecode)
-??? skills/
-? ??? fable-mode/ # Fable-Mode Cognitive Skill Definitions & References
-? ??? SKILL.md # Core cognitive protocol instructions
-? ??? references/
-? ??? cinematic-design-engine.md
-? ??? design-tokens-and-typographies.md
-? ??? system3-meta-cognition.md
-? ??? weak-model-frontier-uplift.md
-??? tests/ # Full regression and verification test suite
-??? docs/ # Architectural specifications and migration guides
-??? LICENSE # MIT License
-??? pyproject.toml, setup.py # Package configuration metadata
-??? README.md # Project documentation
+The documentation checker validates local Markdown links and fragments,
+control/replacement characters, meaningful V1 schema semantics and output
+fields against the runtime, V2 broker actions and probe output, the complete
+source manifest, package metadata/entry points, release naming/runner details,
+and package-version/README consistency. It does not fetch external URLs and
+cannot prove that an external host's API remains unchanged.
+
+## Repository map
+
+```text
+fable_mode/ installer, launcher, adapters, safety
+fable_engine/ V1 MCP server and canonical schema
+fable_v2/ experimental V2 protocol, runtime, broker, adapters, System 3
+build_scripts/ release build and version validation
+packaging/ PyInstaller specification
+docs/ product, architecture, API, security, and checks
+tests/ runtime, packaging, security, and documentation tests
+rules/ host instruction resources
+install.sh / install.ps1 source-mode V1 installation helpers
```
----
-
-## ?? License
-
-Fable-Mode is open-source software licensed under the [MIT License](./LICENSE).
-
-
-
-**Built independently by REX-codebase.**
-*Grounded Epistemic Deliberation ? Deterministic Systems Engineering*
+## License
-
+Fable Mode is released under the [MIT License](LICENSE).
diff --git a/build_scripts/build_release.py b/build_scripts/build_release.py
index 49b3e82..e0c474e 100644
--- a/build_scripts/build_release.py
+++ b/build_scripts/build_release.py
@@ -20,7 +20,6 @@
sys.path.insert(0, str(ROOT))
_VERSION_RE = re.compile(r"^version\s*=\s*[\"']([^\"']+)[\"']", re.MULTILINE)
_SETUP_VERSION_RE = re.compile(r"version\s*=\s*[\"']([^\"']+)[\"']")
-_SERVER_VERSION_RE = re.compile(r"\"version\"\s*:\s*\"([^\"]+)\"")
def _machine() -> str:
@@ -31,24 +30,18 @@ def _machine() -> str:
def _package_version() -> str:
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
setup = (ROOT / "setup.py").read_text(encoding="utf-8")
- server = (ROOT / "fable_engine" / "server.py").read_text(encoding="utf-8")
match = _VERSION_RE.search(pyproject)
setup_match = _SETUP_VERSION_RE.search(setup)
- server_versions = _SERVER_VERSION_RE.findall(server)
- if not match or not setup_match or not server_versions:
- raise ValueError("could not determine package/runtime version from project metadata")
+ if not match or not setup_match:
+ raise ValueError("could not determine package version from pyproject.toml/setup.py")
pyproject_version = match.group(1)
setup_version = setup_match.group(1)
try:
from fable_mode import __version__
except ImportError as exc:
raise ValueError("could not determine fable_mode.__version__") from exc
- versions = {pyproject_version, setup_version, __version__, *server_versions}
- if len(versions) != 1:
- raise ValueError(
- "package/runtime version mismatch between pyproject.toml, setup.py, "
- "fable_mode.__version__, and fable_engine.server"
- )
+ if len({pyproject_version, setup_version, __version__}) != 1:
+ raise ValueError("package version mismatch between pyproject.toml, setup.py, and fable_mode.__version__")
return pyproject_version
@@ -73,7 +66,9 @@ def validate_tag_version(tag: str | None = None) -> str:
def archive_name() -> str:
supplied = os.environ.get("FABLE_VERSION", os.environ.get("GITHUB_REF_NAME"))
- version = supplied.replace("/", "-") if supplied else "dev"
+ # Archive names use the package semver without the tag's conventional
+ # leading ``v``. This keeps names stable across local and tagged builds.
+ version = _tag_version(supplied) if supplied else "dev"
system = platform.system().lower()
machine = _machine()
if system == "windows":
diff --git a/docs/benchmark-methodology.md b/docs/benchmark-methodology.md
new file mode 100644
index 0000000..d78a8af
--- /dev/null
+++ b/docs/benchmark-methodology.md
@@ -0,0 +1,84 @@
+# Benchmark methodology template
+
+Use this template before comparing model-alone, V1-assisted, or V2-assisted
+runs. A benchmark result is evidence for the stated task set and environment,
+not a universal product guarantee.
+
+## Study definition
+
+- **Study ID and date:**
+- **Repository revision / package version:**
+- **Host and adapter revision:**
+- **OS, architecture, Python, and model versions:**
+- **Task domain and intended users:**
+- **Primary question:**
+- **Pre-registered hypotheses:**
+- **Exclusions and safety limits:**
+
+## Task set
+
+- **Source:**
+- **Number of tasks:**
+- **Difficulty strata:**
+- **Train/tuning tasks:**
+- **Held-out evaluation tasks:**
+- **Leakage controls:**
+- **Definition of success for each task:**
+- **Human review rubric, if used:**
+
+Do not tune prompts, verifier thresholds, or task selection on the held-out
+set. If tasks are generated, publish the generator, seed policy, and filtering
+rules.
+
+## Conditions
+
+Run at least these conditions where applicable:
+
+1. model/host without Fable;
+2. V1 with the same model and host tools;
+3. V2 with the same model, adapter, budget, and broker policy.
+
+Randomize task and condition order when practical. Record retries, timeouts,
+failed tool calls, verifier decisions, and any manual intervention. Report
+both per-task outcomes and aggregate outcomes.
+
+## Metrics to record
+
+- success and failure counts with the success definition;
+- confidence intervals or uncertainty estimates appropriate to the sample;
+- cost or token use;
+- wall-clock latency and active compute time separately;
+- tool-call and verifier counts;
+- repair attempts and failure categories;
+- verifier false-positive and false-negative checks against an independent
+ adjudication sample;
+- resource use and crash/recovery behavior;
+- unsupported or skipped tasks by reason.
+
+Do not report an effectiveness percentage without its denominator, task set,
+condition, uncertainty, and success definition. Do not convert a workflow
+gate, a receipt count, or a synthetic score into an accuracy claim.
+
+## Results record
+
+- **Baseline result:**
+- **Fable result:**
+- **Absolute difference:**
+- **Uncertainty / interval:**
+- **Cost and latency difference:**
+- **Verifier quality:**
+- **Failure analysis:**
+- **Per-host differences:**
+- **Threats to validity:**
+- **Raw logs and hashes:**
+- **Reproduction command:**
+
+## Interpretation checklist
+
+- Is the baseline non-zero and meaningfully specified?
+- Were all systems given equivalent task information and tool access?
+- Were failed and abandoned tasks counted consistently?
+- Could a verifier, prompt, or selection rule have leaked the answer?
+- Does the result replicate on another host, model, or task slice?
+- Which claims are demonstrated, which are hypotheses, and which remain
+ unknown?
diff --git a/docs/check_docs.py b/docs/check_docs.py
new file mode 100644
index 0000000..a1c30a3
--- /dev/null
+++ b/docs/check_docs.py
@@ -0,0 +1,419 @@
+#!/usr/bin/env python3
+"""Offline documentation, API-drift, and version consistency checks.
+
+This checker intentionally does not fetch URLs or execute host integrations.
+It is suitable for local runs and CI from the repository root.
+"""
+from __future__ import annotations
+
+import ast
+import json
+import os
+from pathlib import Path
+import re
+import sys
+import tempfile
+from urllib.parse import unquote
+
+ROOT = Path(__file__).resolve().parents[1]
+# Running ``python docs/check_docs.py`` puts docs/, not the checkout root, on
+# sys.path. Add the repository explicitly for the source API import below.
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+MARKDOWN = [ROOT / "README.md", *sorted((ROOT / "docs").rglob("*.md"))]
+V1_SCHEMA = ROOT / "fable_engine" / "fable_session.json"
+CANONICAL_V1_COMMAND = "python -m unittest discover -s tests -p 'test_server*.py' -v"
+SYSTEM3_FILES = {
+ "fable_v2/system3/__init__.py", "fable_v2/system3/causal.py",
+ "fable_v2/system3/dialectical.py", "fable_v2/system3/evolution.py",
+ "fable_v2/system3/executive.py", "fable_v2/system3/free_energy.py",
+ "fable_v2/system3/hyperbolic.py", "fable_v2/system3/induction.py",
+ "fable_v2/system3/kripke.py", "fable_v2/system3/oracle.py",
+}
+
+
+def fail(errors: list[str], message: str) -> None:
+ errors.append(message)
+
+
+def local_links(path: Path):
+ # Covers normal Markdown links and images. Titles are deliberately not
+ # interpreted; all links used by this repository have simple destinations.
+ pattern = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+['\"][^)]*['\"])?\)")
+ for match in pattern.finditer(path.read_text(encoding="utf-8")):
+ yield match.group(1), path
+
+
+def github_slug(value: str) -> str:
+ value = value.strip().lower()
+ value = re.sub(r"[`*_~]", "", value)
+ value = re.sub(r"[^\w\s-]", "", value, flags=re.UNICODE)
+ return re.sub(r"\s+", "-", value)
+
+
+def headings(path: Path) -> set[str]:
+ found: set[str] = set()
+ fenced = False
+ for line in path.read_text(encoding="utf-8").splitlines():
+ if line.strip().startswith("```"):
+ fenced = not fenced
+ continue
+ if not fenced:
+ match = re.match(r"^#{1,6}\s+(.+?)\s*#*\s*$", line)
+ if match:
+ found.add(github_slug(match.group(1)))
+ return found
+
+
+def check_links(errors: list[str]) -> None:
+ for source in MARKDOWN:
+ for destination, _ in local_links(source):
+ if destination.startswith(("http://", "https://", "mailto:")):
+ continue
+ decoded = unquote(destination)
+ target_text, _, fragment = decoded.partition("#")
+ target = source.parent / target_text if target_text else source
+ target = target.resolve()
+ try:
+ target.relative_to(ROOT.resolve())
+ except ValueError:
+ fail(errors, f"{source.relative_to(ROOT)}: link escapes repository: {destination}")
+ continue
+ if not target.is_file():
+ fail(errors, f"{source.relative_to(ROOT)}: missing link target: {destination}")
+ continue
+ if fragment and target.suffix.lower() in {".md", ".markdown"}:
+ if github_slug(fragment) not in headings(target):
+ fail(errors, f"{source.relative_to(ROOT)}: missing fragment #{fragment} in {target.relative_to(ROOT)}")
+
+
+def package_versions(errors: list[str]) -> set[str]:
+ pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ setup = (ROOT / "setup.py").read_text(encoding="utf-8")
+ init = (ROOT / "fable_mode" / "__init__.py").read_text(encoding="utf-8")
+ py_match = re.search(r"^version\s*=\s*['\"]([^'\"]+)['\"]", pyproject, re.MULTILINE)
+ setup_match = re.search(r"\bversion\s*=\s*['\"]([^'\"]+)['\"]", setup)
+ init_match = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", init)
+ if not (py_match and setup_match and init_match):
+ fail(errors, "could not read package versions from pyproject.toml/setup.py/fable_mode/__init__.py")
+ return set()
+ values = {py_match.group(1), setup_match.group(1), init_match.group(1)}
+ if len(values) != 1:
+ fail(errors, "package version mismatch between pyproject.toml, setup.py, and fable_mode.__version__")
+ return values
+
+
+def check_version_consistency(errors: list[str]) -> None:
+ versions = package_versions(errors)
+ if not versions:
+ return
+ version = next(iter(versions))
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ marker = re.search(r"", readme)
+ if not marker:
+ fail(errors, "README.md is missing marker")
+ elif marker.group(1) != version:
+ fail(errors, f"README package-version marker is {marker.group(1)}, package is {version}")
+
+ server = (ROOT / "fable_engine" / "server.py").read_text(encoding="utf-8")
+ server_versions = set(re.findall(r'"version"\s*:\s*"([^"]+)"', server))
+ if version not in server_versions:
+ fail(errors, f"fable_engine/server.py initialize/session version does not contain package version {version}")
+ if any(item != version for item in server_versions):
+ fail(errors, f"fable_engine/server.py contains versions other than package version: {sorted(server_versions)}")
+
+
+def check_manifest_and_metadata(errors: list[str]) -> None:
+ """Keep source, resource, wheel, and frozen package claims aligned."""
+ try:
+ from fable_mode.manifest import ALLOWED_FILES
+ canonical = set(ALLOWED_FILES)
+ except Exception as exc:
+ fail(errors, f"cannot import canonical package manifest: {exc}")
+ return
+ try:
+ resource = json.loads((ROOT / "fable_mode" / "resources.json").read_text(encoding="utf-8"))
+ resource_files = resource.get("files") if isinstance(resource, dict) else None
+ if set(resource_files or ()) != canonical or len(resource_files or ()) != len(canonical):
+ fail(errors, "fable_mode/resources.json differs from manifest.ALLOWED_FILES")
+ except (OSError, ValueError, TypeError) as exc:
+ fail(errors, f"cannot load fable_mode/resources.json: {exc}")
+ missing = sorted(rel for rel in canonical if not (ROOT / rel).is_file())
+ if missing:
+ fail(errors, f"canonical package manifest lists missing files: {missing}")
+ system3 = {p.relative_to(ROOT).as_posix() for p in (ROOT / "fable_v2" / "system3").glob("*.py")}
+ if system3 != SYSTEM3_FILES:
+ fail(errors, "manifest/system3 package does not include every System 3 module")
+ if not SYSTEM3_FILES <= canonical:
+ fail(errors, "canonical package manifest omits part of fable_v2/system3")
+
+ pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ setup = (ROOT / "setup.py").read_text(encoding="utf-8")
+ py_description = re.search(r"^description\s*=\s*['\"]([^'\"]+)['\"]", pyproject, re.MULTILINE)
+ setup_description = re.search(r"\bdescription\s*=\s*['\"]([^'\"]+)['\"]", setup)
+ if not py_description or not setup_description or py_description.group(1) != setup_description.group(1):
+ fail(errors, "package descriptions differ between pyproject.toml and setup.py")
+ if "fable-v2-broker" not in pyproject or "fable-v2-broker" not in setup:
+ fail(errors, "V2 broker entry point is missing from package metadata")
+ project_section = pyproject.split("[project.scripts]", 1)[-1].split("\n[", 1)[0]
+ project_scripts = set(re.findall(r"^\s{0,4}([a-z0-9-]+)\s*=\s*['\"]", project_section, re.MULTILINE))
+ setup_section = setup.split('"console_scripts":', 1)[-1].split("],", 1)[0]
+ setup_scripts = set(re.findall(r"^\s*['\"]([a-z0-9-]+)=", setup_section, re.MULTILINE))
+ expected_scripts = {"fable-mode", "fable-engine", "fable-v1", "fable-v2-broker"}
+ if project_scripts != expected_scripts or setup_scripts != expected_scripts:
+ fail(errors, "console-script entry points differ between package metadata and the documented set")
+
+
+def check_release_consistency(errors: list[str]) -> None:
+ build = (ROOT / "build_scripts" / "build_release.py").read_text(encoding="utf-8")
+ release = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
+ policy = (ROOT / "docs" / "release-policy.md").read_text(encoding="utf-8")
+ if "_tag_version(supplied)" not in build:
+ fail(errors, "release archive naming does not normalize the leading tag v")
+ if "macos-13" in release or "macos-15-intel" not in release:
+ fail(errors, "release workflow uses a stale or undocumented macOS runner")
+ for suffix in ("linux-x86_64.tar.gz", "macos-x86_64.zip", "windows-x86_64.zip"):
+ if f"fable-mode--{suffix}" not in policy:
+ fail(errors, f"release policy omits archive naming convention: {suffix}")
+
+
+def check_documented_commands_and_outputs(errors: list[str]) -> None:
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ workflow = (ROOT / ".github" / "workflows" / "test.yml").read_text(encoding="utf-8")
+ troubleshooting = (ROOT / "docs" / "troubleshooting.md").read_text(encoding="utf-8")
+ if readme.count(CANONICAL_V1_COMMAND) != 1:
+ fail(errors, "README does not contain exactly one canonical V1 test command")
+ if CANONICAL_V1_COMMAND not in workflow:
+ fail(errors, "test workflow does not run the canonical V1 test command")
+ if CANONICAL_V1_COMMAND not in troubleshooting:
+ fail(errors, "troubleshooting docs do not contain the canonical V1 test command")
+ if "python fable_engine/test_server.py" in readme or "python fable_engine/test_server.py" in workflow:
+ fail(errors, "stale direct V1 test command remains in README or CI")
+
+ # Check documented output fields against the runtime-declared output schema,
+ # rather than accepting prose that only mentions a successful transport.
+ try:
+ from fable_engine import server
+ output = server.TOOL_SCHEMA.get("outputSchema", {})
+ required = output.get("required", [])
+ reference = (ROOT / "docs" / "mcp-reference.md").read_text(encoding="utf-8")
+ if not isinstance(required, list) or not required:
+ fail(errors, "runtime V1 output schema has no required fields")
+ for field in required:
+ if not isinstance(field, str) or f"`{field}`" not in reference:
+ fail(errors, f"MCP reference omits runtime V1 output field: {field}")
+ except Exception as exc:
+ fail(errors, f"cannot inspect runtime output schema: {exc}")
+
+
+def check_schema_and_api(errors: list[str]) -> None:
+ try:
+ schema = json.loads(V1_SCHEMA.read_text(encoding="utf-8"))
+ except Exception as exc:
+ fail(errors, f"cannot load V1 schema: {exc}")
+ return
+
+ # Import in a temporary state directory so checking docs never writes to a
+ # developer's normal Fable data directory.
+ old_data = os.environ.get("FABLE_DATA_DIR")
+ with tempfile.TemporaryDirectory(prefix="fable-doc-check-") as state:
+ os.environ["FABLE_DATA_DIR"] = state
+ try:
+ from fable_engine import server
+ runtime = server.TOOL_SCHEMA
+ except Exception as exc:
+ fail(errors, f"cannot import V1 TOOL_SCHEMA: {exc}")
+ runtime = None
+ finally:
+ if old_data is None:
+ os.environ.pop("FABLE_DATA_DIR", None)
+ else:
+ os.environ["FABLE_DATA_DIR"] = old_data
+
+ if runtime is None:
+ return
+ disk_schema = schema.get("parameters", {})
+ runtime_schema = runtime.get("inputSchema", {})
+ disk_actions = list(disk_schema.get("properties", {}).get("action", {}).get("enum", []))
+ runtime_actions = list(runtime_schema.get("properties", {}).get("action", {}).get("enum", []))
+ if disk_actions != runtime_actions:
+ fail(errors, "V1 schema action enum differs between fable_session.json and server.TOOL_SCHEMA")
+ disk_properties = set(disk_schema.get("properties", {}))
+ runtime_properties = set(runtime_schema.get("properties", {}))
+ # ``accumulate_payload`` remains in the shipped descriptor for compatibility
+ # with older host registrations, while the implementation reads ``payload``
+ # for that action. Keep this explicit exception visible instead of hiding a
+ # general schema drift.
+ legacy_descriptor_only = {"accumulate_payload"}
+ if (disk_properties - runtime_properties) - legacy_descriptor_only:
+ fail(errors, "V1 schema has undocumented descriptor-only parameter properties")
+ if runtime_properties - disk_properties:
+ fail(errors, "V1 runtime TOOL_SCHEMA has parameter properties missing from fable_session.json")
+ if disk_schema.get("required") != runtime_schema.get("required"):
+ fail(errors, "V1 schema required fields differ between fable_session.json and server.TOOL_SCHEMA")
+
+ # Property order and descriptions are presentation details, but types,
+ # enums, nested object requirements, and union branches are API meaning.
+ # Compare those semantics so a stale descriptor cannot pass merely because
+ # its action list still happens to match.
+ def schema_meaning(value: object) -> object:
+ if isinstance(value, dict):
+ keep = {key: value[key] for key in ("type", "enum", "required", "additionalProperties", "oneOf") if key in value}
+ if "properties" in value and isinstance(value["properties"], dict):
+ keep["properties"] = {name: schema_meaning(item) for name, item in value["properties"].items()}
+ if "oneOf" in keep and isinstance(keep["oneOf"], list):
+ keep["oneOf"] = [schema_meaning(item) for item in keep["oneOf"]]
+ return keep
+ if isinstance(value, list):
+ return [schema_meaning(item) for item in value]
+ return value
+
+ for name in sorted(disk_properties & runtime_properties):
+ if schema_meaning(disk_schema["properties"][name]) != schema_meaning(runtime_schema["properties"][name]):
+ fail(errors, f"V1 schema property semantics differ for {name}")
+
+ reference = (ROOT / "docs" / "mcp-reference.md").read_text(encoding="utf-8")
+ for action in runtime_actions:
+ if action not in reference:
+ fail(errors, f"MCP reference does not mention V1 action: {action}")
+
+ # Extract action comparisons from ExecutionBroker.handle without running a
+ # broker or a child process. This catches additions/removals in docs.
+ broker_path = ROOT / "fable_v2" / "execution_broker.py"
+ try:
+ tree = ast.parse(broker_path.read_text(encoding="utf-8"), filename=str(broker_path))
+ broker_actions: set[str] = set()
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Compare):
+ continue
+ if not (isinstance(node.left, ast.Name) and node.left.id == "action"):
+ continue
+ for comparator in node.comparators:
+ values = comparator.elts if isinstance(comparator, (ast.Set, ast.Tuple, ast.List)) else [comparator]
+ if isinstance(node.ops[0], (ast.In, ast.Eq)):
+ broker_actions.update(item.value for item in values if isinstance(item, ast.Constant) and isinstance(item.value, str))
+ reference_broker = reference
+ for action in ("probe", "probe_capabilities", "inspect_files", "execute_command", "write_file"):
+ if action not in broker_actions:
+ fail(errors, f"broker source no longer advertises expected action: {action}")
+ if action not in reference_broker:
+ fail(errors, f"MCP reference does not mention broker action: {action}")
+
+ # Probe output is part of the host-facing contract. Exercise only the
+ # side-effect-free probe with a temporary workspace and require the
+ # reference to name every stable field a host can consume.
+ from fable_v2.execution_broker import BROKER_PROBE_FIELDS, BrokerPolicy, ExecutionBroker
+ with tempfile.TemporaryDirectory(prefix="fable-doc-broker-") as workspace:
+ broker = ExecutionBroker(BrokerPolicy(Path(workspace), (Path(sys.executable).name,)))
+ probe = broker.probe()
+ stable_probe_fields = set(BROKER_PROBE_FIELDS)
+ if set(probe) != stable_probe_fields:
+ fail(errors, f"broker probe output fields changed unexpectedly: {sorted(probe)}")
+ for field in stable_probe_fields:
+ if f"`{field}`" not in reference_broker:
+ fail(errors, f"MCP reference omits broker probe output field: {field}")
+ except Exception as exc:
+ fail(errors, f"cannot inspect broker API: {exc}")
+
+
+# The package-level intelligent verifier surface is intentionally explicit.
+# Keep this list in sync with fable_v2.__init__ and the architecture guide;
+# adding a verifier class without documenting/exporting it is API drift.
+INTELLIGENT_VERIFIER_API = frozenset({
+ "Adjudication", "CalibrationMetrics", "Claim", "ClaimGraph",
+ "CompositeVerifier", "Counterexample", "CounterexampleStore",
+ "FunctionVerifier", "MetamorphicRelation", "MetamorphicVerifier",
+ "MutationOperator", "MutationVerifier", "PlannedCheck", "PortfolioResult",
+ "PropertyCheck", "PropertyVerifier", "RiskLevel", "ThreeValuedAdjudicator",
+ "Verdict", "VerificationDecision", "Verifier", "VerifierDecision",
+ "VerifierPlan", "VerifierPlanner", "VerifierPortfolio", "VerifierStatus",
+})
+
+
+def check_fable_v2_api(errors: list[str]) -> None:
+ """Check exported verifier names and package export integrity."""
+ try:
+ import fable_v2
+ import fable_v2.verifiers as verifiers
+ except Exception as exc:
+ fail(errors, f"cannot import fable_v2 public API: {exc}")
+ return
+ exported = getattr(fable_v2, "__all__", None)
+ if not isinstance(exported, list) or not all(isinstance(name, str) for name in exported):
+ fail(errors, "fable_v2.__all__ must be a list of string names")
+ return
+ if len(exported) != len(set(exported)):
+ fail(errors, "fable_v2.__all__ contains duplicate names")
+ missing_exports = sorted(name for name in exported if not hasattr(fable_v2, name))
+ if missing_exports:
+ fail(errors, f"fable_v2.__all__ contains missing attributes: {missing_exports}")
+ exported_set = set(exported)
+ missing_verifier_exports = sorted(INTELLIGENT_VERIFIER_API - exported_set)
+ if missing_verifier_exports:
+ fail(errors, f"intelligent verifier API is not exported by fable_v2: {missing_verifier_exports}")
+
+ # Catch a newly declared public verifier type that was not added to the
+ # checked API inventory. Imported typing helpers are excluded by module.
+ declared = {
+ name for name, value in vars(verifiers).items()
+ if not name.startswith("_") and getattr(value, "__module__", None) == verifiers.__name__
+ }
+ undocumented = sorted(declared - INTELLIGENT_VERIFIER_API)
+ if undocumented:
+ fail(errors, f"new public verifier names require API inventory/documentation: {undocumented}")
+ architecture = (ROOT / "docs" / "fable-v2-architecture.md").read_text(encoding="utf-8")
+ for name in sorted(INTELLIGENT_VERIFIER_API):
+ if f"`{name}`" not in architecture:
+ fail(errors, f"architecture guide omits intelligent verifier API name: {name}")
+
+
+def check_text_hygiene(errors: list[str]) -> None:
+ # C0/C1 controls, replacement characters, and zero-width artifacts are not
+ # valid in checked-in prose. Tabs/newlines/CR are allowed by Markdown.
+ for path in MARKDOWN:
+ text = path.read_text(encoding="utf-8")
+ bad = [(index, hex(ord(char))) for index, char in enumerate(text)
+ if (ord(char) < 32 and char not in "\t\n\r")
+ or 0x7F <= ord(char) <= 0x9F
+ or char in {"\ufffd", "\u200b", "\ufeff"}]
+ if bad:
+ fail(errors, f"{path.relative_to(ROOT)} contains control/corrupted characters: {bad[:5]}")
+
+ # Keep claims in the landing page and Fable design docs qualified. These
+ # terms previously appeared as absolute guarantees or ungrounded metrics.
+ fable_docs = [ROOT / "README.md", *sorted((ROOT / "docs").glob("fable-*.md"))]
+ forbidden = [
+ (r"\bunbypassable\b", "unqualified unbypassable claim"),
+ (r"\bACID\b", "unsupported ACID claim"),
+ (r"\bWAL\b", "unsupported WAL claim"),
+ (r"formal engineering proof", "unsupported formal engineering proof claim"),
+ (r"\b\d+(?:\.\d+)?%", "unqualified effectiveness percentage"),
+ ]
+ for path in fable_docs:
+ text = path.read_text(encoding="utf-8")
+ for pattern, label in forbidden:
+ if re.search(pattern, text, flags=re.IGNORECASE):
+ fail(errors, f"{path.relative_to(ROOT)} contains {label}")
+
+
+def main() -> int:
+ errors: list[str] = []
+ check_links(errors)
+ check_version_consistency(errors)
+ check_manifest_and_metadata(errors)
+ check_release_consistency(errors)
+ check_documented_commands_and_outputs(errors)
+ check_schema_and_api(errors)
+ check_fable_v2_api(errors)
+ check_text_hygiene(errors)
+ if errors:
+ print("Documentation checks failed:")
+ for error in errors:
+ print(f"- {error}")
+ return 1
+ print(f"Documentation checks passed ({len(MARKDOWN)} Markdown files checked).")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/docs/credentials.md b/docs/credentials.md
new file mode 100644
index 0000000..4475db7
--- /dev/null
+++ b/docs/credentials.md
@@ -0,0 +1,41 @@
+# Using stored credentials
+
+Credentials are registered through the `credentials` tool and stored encrypted. You never see their values — you reference them by name, and the reference is resolved at execution time.
+
+## One reference syntax
+
+ {{credential:NAME}} the credential's only field
+ {{credential:NAME:FIELD}} one field of a multi-field credential
+
+`credentials(action='check', credential_name=...)` lists the fields a credential holds. A `login` credential holds `username` and `password`, so it must always name a field — a bare reference would be ambiguous and is rejected.
+
+## Where references work
+
+The same reference works in both consumers. Which one you use is your choice at the time, not a property of the credential.
+
+**curl** — anywhere in the command's arguments:
+
+ curl -s https://api.example.com/endpoint \
+ -H "Authorization: Bearer {{credential:my-api}}" \
+ -d '{"query": "hello"}'
+
+**browser** — as the typed text, or as a fill value:
+
+ browser type ref=e5 text="{{credential:site-login:username}}"
+ browser type ref=e6 text="{{credential:site-login:password}}" submit=True
+
+For complex API processing, use curl for the authenticated call and pipe to python:
+
+ curl -s https://api.example.com/data \
+ -H "Authorization: Bearer {{credential:my-api}}" | \
+ python3 -c "import json, sys; data = json.load(sys.stdin); print(data['result'])"
+
+## Binding
+
+A credential registered with a `base_url` only resolves against that origin — in curl, the command must target it; in the browser, the page must already be on it. Navigate first, then inject. This holds in both consumers, so a secret cannot be sent to a site it wasn't registered for.
+
+## CLI tools
+
+CLI credentials need no reference at all. When you run a registered CLI command, the credential is already in the configured environment variable.
+
+A CLI credential is for that CLI only — it cannot be referenced in curl or typed into a page. Don't repurpose one for a website: leave it alone and request a separate credential bound to the site. A binding cannot be edited after creation, so changing one means deleting the credential and requesting it again, which also discards the stored secret.
diff --git a/docs/data-and-artifacts.md b/docs/data-and-artifacts.md
new file mode 100644
index 0000000..e136d23
--- /dev/null
+++ b/docs/data-and-artifacts.md
@@ -0,0 +1,60 @@
+# Data directories and release artifacts
+
+## Runtime state
+
+The package-aware `fable-mode serve` launcher selects state in this order:
+
+1. `--state-dir PATH` when supplied;
+2. `FABLE_DATA_DIR` when set;
+3. Windows: `%LOCALAPPDATA%\FableMode\data`;
+4. other systems: `~/.local/share/fable-mode/data`.
+
+The direct legacy `fable-engine` entry point uses `FABLE_DATA_DIR` when set,
+then Windows `%LOCALAPPDATA%\FableMode\data`, otherwise
+`~/.local/share/fable-engine/data`. The two defaults intentionally differ.
+Set `FABLE_DATA_DIR` explicitly when a host needs one known location.
+
+V1 creates a `sessions/` directory below the selected data directory and
+restricts it to the local user where the platform allows. CAS objects live
+beside the selected data directory. State is not stored in the repository.
+Session names are restricted to portable identifier characters and are used to
+locate JSON files; do not put secrets in objectives, claims, evidence strings,
+or payloads.
+
+`FABLE_DATA_DIR` and `--state-dir` point to a real directory. The runtime
+rejects unsafe path components such as symlinks/reparse points and special
+files. These checks reduce accidental redirection; they are not a substitute
+for OS account isolation or encrypted storage.
+
+V2 run objects are in memory unless an integrating host provides persistence.
+The broker operates on the explicitly supplied workspace and should use a
+separate workspace from V1 state during evaluation.
+
+## CAS behavior
+
+Normal CAS reads verify the SHA-256 named by a `cas://` reference. A low-level
+`verify=False` option exists for maintenance/diagnostics only and must not feed
+model-visible or server-facing content. CAS is an addressing/integrity
+mechanism, not encryption, access control, authenticity, or a guaranteed
+compression or token-saving ratio. Limit access to the data directory as you
+would any other local application state.
+
+## Release archives
+
+Release CI produces one staging archive per runner for Linux x86_64, macOS
+x86_64 (from the macOS 15 Intel runner), macOS arm64 (from the macOS 15 Apple
+Silicon runner), and Windows x86_64. The publish job validates all four,
+retains versioned tag-spelling assets such as
+`fable-mode-v1.2.3-macos-arm64.zip`, and creates stable latest-release aliases.
+The single `SHA256SUMS` asset covers the four versioned archives and four
+aliases. Staging archive names use the bare package semver (for example,
+`1.2.3`) rather than including the tag's leading `v`. The archives contain a
+frozen `fable-mode` executable. They do not prove host compatibility or provide
+a complete sandbox.
+
+**Unsigned artifact warning:** release archives are currently unsigned and
+macOS artifacts are not notarized. A checksum detects accidental or in-transit
+changes only when the checksum file was obtained from a trusted source; it does
+not authenticate the publisher. Prefer a trusted release page and independently
+verify both the checksum and provenance before execution. Do not treat a
+matching filename or checksum as a signature.
diff --git a/docs/documentation-map.md b/docs/documentation-map.md
new file mode 100644
index 0000000..877aacc
--- /dev/null
+++ b/docs/documentation-map.md
@@ -0,0 +1,23 @@
+# Documentation source-of-truth map
+
+Use the implementation as the authority for behavior and the matching doc for
+explanation. If these disagree, fix the docs or file a compatibility note;
+do not silently rely on a stale example.
+
+| Topic | Authoritative source | Reader-facing documentation |
+|---|---|---|
+| Package version and release validation | `pyproject.toml`, `setup.py`, `fable_mode/__init__.py`, `build_scripts/build_release.py` | [`release-policy.md`](release-policy.md) |
+| V1 MCP tool name, actions, and parameters | `fable_engine/fable_session.json` and `fable_engine/server.py:TOOL_SCHEMA` | [`mcp-reference.md`](mcp-reference.md) |
+| V1 phase and unlock behavior | `fable_engine/server.py:FableSession` | [`fable-v1-theory.md`](fable-v1-theory.md), [`troubleshooting.md`](troubleshooting.md) |
+| V1 process and persistence paths | `fable_mode/launcher.py`, `fable_engine/server.py` | [`data-and-artifacts.md`](data-and-artifacts.md) |
+| V2 data contracts, run states, and System 3 package | `fable_v2/protocol.py`, `fable_v2/runtime.py`, `fable_v2/system3/` | [`fable-v2-architecture.md`](fable-v2-architecture.md) |
+| V2 host capabilities | `fable_v2/adapters.py` | [`host-compatibility.md`](host-compatibility.md) |
+| V2 broker actions and limits | `fable_v2/execution_broker.py` | [`mcp-reference.md`](mcp-reference.md), [`security-and-trust.md`](security-and-trust.md) |
+| Source installation and entry points | `fable_mode/launcher.py`, `install.sh`, `install.ps1`, package metadata | [`README.md`](../README.md), [`fable-v1-v2-migration.md`](fable-v1-v2-migration.md) |
+| Release artifacts and checksums | `.github/workflows/release.yml`, `build_scripts/build_release.py` | [`release-policy.md`](release-policy.md), [`data-and-artifacts.md`](data-and-artifacts.md) |
+| Documentation and drift checks | `docs/check_docs.py`, `tests/test_documentation.py` | [`README.md`](../README.md), this map |
+| Benchmark claims and interpretation | Study records created from this template | [`benchmark-methodology.md`](benchmark-methodology.md) |
+
+`docs/platform.md`, `docs/use_cases.md`, and the other Zapia product notes are
+account documentation rather than Fable runtime specifications. They are not
+used as authority for Fable APIs.
diff --git a/docs/fable-v1-theory.md b/docs/fable-v1-theory.md
new file mode 100644
index 0000000..e863092
--- /dev/null
+++ b/docs/fable-v1-theory.md
@@ -0,0 +1,100 @@
+# V1 design notes and theory
+
+This document keeps the useful reasoning behind Fable V1 without presenting
+hypotheses as measured results or workflow checks as proofs of correctness.
+
+## What V1 is trying to change
+
+V1 is a session manager for an AI host. It makes a host record observations,
+open questions, proposed invariants, and refinement work before the host asks
+the server to unlock its workflow. The intended benefit is less premature
+execution on tasks where explicit planning and verification are useful. That
+benefit is a hypothesis to measure per host and task set; it is not an
+accuracy, quality, or speed guarantee.
+
+The design draws on dual-process vocabulary and adversarial review patterns.
+Those are organizing metaphors, not a claim that the server implements a human
+mind, formal cognition, or a universally valid reasoning method.
+
+## Evidence labels
+
+The V1 ledger accepts three labels:
+
+| Label | Meaning in the session | What it does not mean |
+|---|---|---|
+| `PROVEN` | The caller supplied a non-empty evidence pointer, such as a file path, command output, test result, or URL | The server has independently established that the claim is true |
+| `HYPOTHESIS` | A proposed explanation or design assumption | A fact safe to use without checking |
+| `UNKNOWN` | A missing value, constraint, or question to probe | A failure or a negative result |
+
+The server checks the shape of a `PROVEN` entry and records the supplied
+pointer. A model can still provide misleading evidence. Treat the label as a
+ledger status, not as a correctness verdict.
+
+## Phases and unlock gates
+
+The six phases are sequential:
+
+1. Epistemic Grounding and Live Research
+2. Invariant Specification and Blueprint
+3. Adversarial Red-Teaming and Falsification
+4. Subagent Fleet Delegation
+5. Multi-Tier Verification and Gatekeeping
+6. Final Walkthrough and Reporting
+
+`unlock_execution` requires the authority deadline to have elapsed, at least
+two evidence-bearing `PROVEN` entries, at least one invariant with a proof or
+rationale, and Phase 3 or later. `set_timer` changes only the internal pacing
+timer. These are admission conditions in the V1 session state; they do not
+compile, test, or authorize arbitrary filesystem operations.
+
+The outer budget is created with `create_session` and is immutable for that
+session. The default is 60 minutes; the accepted range is 0.1 minutes through
+seven days. A session may be saved and restored, but restored ledger entries
+are marked untrusted and do not satisfy the unlock evidence gate on their own.
+An administrator can configure an out-of-band emergency token for direct host
+use; the token is intentionally absent from the MCP schema. Do not put it in a
+model prompt or tool request.
+
+## Why not probabilistic scoring?
+
+A language model's self-reported score is not independent evidence. V1 instead
+asks a host to collect concrete observations and to name checks. This does not
+make the observations correct, and it does not replace deterministic tests,
+independent review, or hidden evaluation. The design should therefore be
+judged by held-out task results rather than by the number of ledger entries or
+time spent waiting.
+
+## Refinement and role separation
+
+`log_refinement_cycle` records a critique, a proposed refinement, and optional
+probe output or an artifact path. It is an audit-friendly record, not a proof
+that the refinement improved a system. A host may use the record to structure
+an architect/implementer workflow, but V1 does not create or supervise an
+actual subagent fleet. `compile_delegation_contract` validates required fields
+in a prompt; it does not dispatch a worker or verify the worker's output.
+
+## CAS and payload helpers
+
+The payload helpers provide content-addressed storage and bounded reads; they
+do not promise that every payload becomes smaller or saves a fixed number of
+tokens. Displayed token counts and the optional `<= 0.003 tokens/character`
+check are rough character-based estimates for diagnostics, not measured
+model-token usage or guarantees. A `cas://` reference is an
+integrity/addressing mechanism: normal reads
+verify the SHA-256 named by the reference. It is not encryption, access control,
+authenticity, or a claim that a payload is safe. The optional low-level
+`verify=False` API is a maintenance escape hatch and must not be used for
+model-visible content.
+
+## Limitations to keep visible
+
+- V1's lock state lives in the Python server and is not an OS permission.
+- Host tools, shells, direct filesystem access, and subagent behavior remain
+ under the host's control unless that host adds its own boundary.
+- The stdio protocol is local-process IPC; it does not provide network
+ authentication or multi-tenant isolation.
+- Checkpoints are useful persistence, not signed audit artifacts. Keep keys
+ and stronger authorization outside serialized state when deploying a
+ higher-trust system.
+- Runtime tests exercise implementation paths; they are not evidence of
+ universal security or model effectiveness.
diff --git a/docs/fable-v1-v2-migration.md b/docs/fable-v1-v2-migration.md
index 5d6c879..ba420e4 100644
--- a/docs/fable-v1-v2-migration.md
+++ b/docs/fable-v1-v2-migration.md
@@ -1,56 +1,79 @@
-# Fable V1 to V2 entry points
+# Migrate from Fable V1 to V2
-## Current entry points
+V1 and V2 are intentionally separate entry points. Migration is an adapter
+deployment exercise, not a package upgrade that silently changes an existing
+MCP registration.
-| Entry point | Version | Purpose |
+## Entry points
+
+| Command or module | Version | What it does |
|---|---|---|
-| `fable-engine` | V1 (legacy) | Existing `fable_session` MCP server from `fable_engine.server` |
-| `fable-v1` | V1 (legacy alias) | Explicit alias for `fable-engine` |
-| `fable-v2-broker` | V2 | Process execution broker from `fable_v2.execution_broker` |
+| `fable-engine` | V1 | Runs `fable_engine.server`, the legacy `fable_session` MCP server |
+| `fable-v1` | V1 | Explicit alias for `fable-engine` |
+| `fable-mode serve` | V1 | Package-aware launcher for the same MCP server |
+| `fable-v2-broker --workspace PATH` | V2 | Runs the separate broker's JSON-lines execution boundary |
+| `fable_v2.runtime` | V2 | In-process runtime library; no MCP server entry point |
+
+The source helpers `install.sh` and `install.ps1` register V1 for backward
+compatibility, while their private `runtime/` copy includes the complete V2
+package (including `fable_v2/system3`). Installing the package does not replace
+an existing host registration with V2. A source-helper V2 broker can be run
+with `python -m fable_v2.execution_broker` using that runtime directory on
+`PYTHONPATH`; V2 is never registered automatically.
-The existing `install.sh` and `install.ps1` scripts intentionally install and
-register the V1 MCP server for backward compatibility. They now print that
-fact explicitly. Installing the V2 Python package adds the `fable-v2-broker`
-entry point; it does not silently replace the old MCP server.
+## Suggested migration sequence
-## V2 execution boundary
+1. Keep the V1 registration and record its current host configuration.
+2. Install the package in an isolated environment and run the V2 unit tests.
+3. Start a broker against a dedicated, least-privilege workspace.
+4. Build an adapter that probes the live host and calls `HostCapabilities.attest`.
+5. Translate host tool calls into canonical capabilities and host-produced
+ `ToolReceipt` objects.
+6. Create `TaskSpec` and `VerificationPolicy` objects before generating a
+ candidate; attach only evidence derived from successful receipts.
+7. Run deterministic and independent verifiers, then inspect the finalization
+ result. The independent verifier must cite measured provenance from a
+ distinct producer (a same-receipt self-declaration is rejected). Require
+ `process_attested` results when the deployment's trust model needs process
+ isolation.
+8. Exercise rollback and failure paths on the target OS and host.
+9. Enable V2 for a small, non-sensitive workload while leaving V1 available.
+10. Remove V1 only after the V2 adapter has passed conformance tests and users
+ have a documented rollback path.
-Start the broker with a workspace it owns:
+## Broker setup on POSIX
-```bash
-fable-v2-broker --workspace /path/to/workspace
+```sh
+mkdir -p /absolute/path/to/fable-workspace
+export FABLE_BROKER_WRITE_TOKEN_DIGEST="$(python3 -c 'import hashlib; print(hashlib.sha256(b"admin-secret").hexdigest())')"
+fable-v2-broker --workspace /absolute/path/to/fable-workspace --admin-fd 3 3<>/path/to/private-admin-pipe
```
-The CLI loads write authorization from an administrator-controlled
-`FABLE_BROKER_WRITE_TOKEN_DIGEST` environment variable or
-`FABLE_BROKER_WRITE_TOKEN_DIGEST_FILE` protected file. The value is a SHA-256
-hex digest of the administrative token; it is never returned by the broker's
-probe response and must not be added to a model-facing tool schema.
-
-Unlocking is performed over a separate inherited POSIX admin file descriptor,
-passed with `--admin-fd`; the model-facing JSON-lines stdin channel has no
-unlock action and no token field. A host adapter must keep the admin pipe and
-token outside the model's tool surface. Windows adapters should use an
-equivalent protected named-pipe/control-handle implementation.
-
-Hosts should communicate with the broker over its JSON-lines stdin/stdout
-protocol and route command execution and file writes through it. The broker
-allowlists executables, constrains paths to the workspace, keeps writes locked
-until administrative authorization, blocks general interpreters while writes
-are locked, and runs commands without a shell. This prevents the common
-`python -c "open(...)"` bypass at the broker policy layer.
-
-This is a process/policy boundary, not a complete operating-system sandbox.
-For hostile workloads, run the broker inside a container or equivalent OS
-sandbox with least-privilege filesystem and network permissions.
-
-## Migration order
-
-1. Keep the V1 MCP server enabled while the host adapter is being tested.
-2. Install the package and start `fable-v2-broker` in a dedicated workspace.
-3. Probe the host and broker capabilities; treat expected profiles as
- non-authoritative until attested.
-4. Route V2 tool calls through the broker and create candidate-scoped receipts.
-5. Enable V2 finalization only after verifier and broker conformance tests pass.
-6. Remove or disable the V1 MCP registration only after the host adapter has
- been validated on the target environment.
+The example shows the boundary, not a recommended secret-management system.
+Use a protected secret store or file with appropriate permissions. Do not put
+the token, its cleartext, or the admin file descriptor in a model-facing tool
+schema. The exact process-supervision and pipe setup belongs to the host
+adapter.
+
+When the source helper is given `--register-hosts --workspace PATH`,
+Antigravity registration updates both its global
+`~/.gemini/config/mcp_config.json` and `PATH/.agents/mcp_config.json`. Existing
+unrelated keys are preserved, but both are real configuration side effects;
+inspect them before allowing a host to use the registration.
+
+## Compatibility caveats
+
+- The broker's `--admin-fd` is POSIX-only in this implementation. Windows
+ needs a separate protected control-handle adapter before V2 writes can be
+ unlocked through this interface.
+- A host profile is not proof of support; only live attestation is
+ authoritative.
+- V2's in-process verifier API is not a security boundary.
+- V1 checkpoints restored by the server should be treated as untrusted input.
+- Keep V1 and V2 data directories and workspaces separate while evaluating
+ migration behavior.
+
+See [`fable-v2-architecture.md`](fable-v2-architecture.md),
+[`host-compatibility.md`](host-compatibility.md), and
+[`security-and-trust.md`](security-and-trust.md) for the component and trust
+boundaries.
diff --git a/docs/fable-v2-architecture.md b/docs/fable-v2-architecture.md
index 838dca8..9636a13 100644
--- a/docs/fable-v2-architecture.md
+++ b/docs/fable-v2-architecture.md
@@ -1,271 +1,191 @@
-# Fable V2: Portable Verifier-Guided Runtime
+# Fable V2 architecture (experimental)
-## Goal
+Fable V2 is a portable, model-agnostic runtime foundation. It is separate
+from the V1 MCP server and is not yet a drop-in replacement. The package
+contains contracts and enforcement code; a host adapter still has to connect
+those contracts to a model host, tools, and a deployment's verifier services.
-Build a portable, model-agnostic intelligence runtime that works across
-Antigravity, Claude Code, Codex, Grok Build, Cursor, Zapia, and other agentic
-hosts. Fable V2 should make weak-but-functional models dramatically more
-useful on verifiable task classes and make frontier models more reliable,
-consistent, and efficient.
+## Design goal
-The ambitious targets are hypotheses to be measured, not guarantees:
+Instead of accepting a model's statement that work is complete, a V2 adapter
+can collect tool receipts, bind evidence to those receipts, run declared
+verifiers, and accept only a candidate that meets the task policy. This can
+improve auditability and may improve reliability on verifiable tasks. The
+magnitude and direction of any improvement are empirical questions. No fixed
+effectiveness, accuracy, cost, or latency claim is made by the runtime.
-- Up to 10x improvement in system-level effectiveness for frontier models
- (verified success, failure reduction, or successful tasks per cost).
-- Up to 50x relative improvement for weak models on selected tasks where the
- baseline has non-zero competence.
-
-A model cannot be made universally 10x more intelligent by a prompt. Fable
-scales useful work by coordinating models, tools, evidence, search, repair,
-and verification.
-
-## Design principle
-
-> Do not ask an agent to claim that it did good work. Make the runtime collect
-> receipts, run checks, and accept only a verified artifact.
-
-MCP is a transport/interface layer. Fable V2 is the runtime around it: a
-controller, evidence ledger, model router, candidate manager, verifier broker,
-repair loop, and host adapters.
-
-## Architecture
+## Components
```text
-User task
- |
- v
-Task compiler: objective, constraints, done conditions, required capabilities
- |
- v
-Budget + model router ---- host capability probe
- |
- v
-Diverse candidate fleet ---- tools / sandbox / retrieval
- |
- v
-Evidence receipts and failure classification
- |
- v
-Deterministic verifiers -> independent model verifier -> repair/search loop
- |
- v
-Finalization gate: only a passing, evidence-backed artifact is accepted
+Host adapter
+ | translates native tools and model events
+ v
+TaskSpec + VerificationPolicy
+ |
+ v
+FableRun: state machine and event history
+ |-- Candidate artifacts
+ |-- ToolReceipt objects (host-produced invocation records)
+ |-- Evidence objects bound to successful receipts
+ `-- Registered verifiers
+ |
+ v
+ attested VerificationResult objects
+ |
+ v
+ finalization gate
+
+Separate execution path:
+Host adapter --> fable-v2-broker (child process)
+ |-- probe capabilities
+ |-- inspect files
+ |-- execute an allowlisted argv
+ `-- write a workspace file (locked by default)
+ administrator control handle -- unlock writes
```
-## Portable core and adapters
-
-The core speaks a host-neutral JSON/data contract. Adapters translate native
-host tools into capabilities such as `inspect_files`, `execute_command`,
-`run_tests`, `search_web`, `edit_files`, and `delegate_agents`.
-
-Supported integrations should be implemented as thin adapters, not forks of
-the cognitive engine. MCP is the preferred tool binding, but a CLI or HTTP
-adapter is required for hosts that do not expose MCP.
-
-## Execution boundary
-
-`fable_v2.execution_broker` provides the first concrete execution boundary for
-V2. `fable-v2-broker` runs as a separate process, allowlists executables,
-executes without a shell, constrains working directories and file writes to a
-configured workspace, and keeps writes locked until administrative
-authorization. General interpreters and shell entry points are also blocked
-while writes are locked, because `shell=False` does not stop a command such as
-`python -c "open(...)"` from writing files. Hosts must route V2 command
-execution and writes through this broker instead of giving the model direct
-filesystem access. The administrative unlock is accepted only on a separate
-inherited control handle (`--admin-fd` on POSIX), never through the model's
-JSON-lines request channel.
-
-This is a process and policy boundary, not a complete operating-system sandbox.
-For a hardened deployment, the broker process must run with an OS-enforced
-read-only workspace before authorization, then receive a separately controlled
-writable layer or remount after authorization.
-Hostile workloads still require container/VM isolation and least-privilege OS
-controls. The broker resolves each allowlisted executable to a trusted absolute path at
-startup and rejects requests whose resolved path differs; a matching basename
-is not sufficient. Command stdout/stderr are drained concurrently into bounded
-buffers; exceeding `max_output_bytes` terminates the process (and its POSIX
-process group) instead of truncating after unbounded `subprocess.run()` capture.
-The broker implements every advertised protocol capability, including bounded
-`inspect_files` and the `probe_capabilities` alias. Its JSON-lines reader is
-interactive: it emits a response as soon as a newline-terminated frame arrives
-without waiting for stdin EOF, caps raw frames at 1 MiB, consumes oversized
-frames through their delimiter to preserve synchronization, and bounds error
-text to 8 KiB. It is covered by child-process, open-stdin, malformed/oversized
-frame, executable-path, output-limit, capability, allowlist, path-containment,
-and locked-write tests.
-
-The checked-in `HOST_PROFILES` are explicitly **expected capability
-profiles**, not attestations. They are useful defaults for planning and
-documentation, but they are not runtime-authoritative. A live adapter must
-probe the host at startup and call `HostCapabilities.attest(...)` with the
-observed capabilities. Compatibility is authoritative only after that probe;
-a host must never receive a full-guarantee status merely because it loaded a
-prompt or matched a hard-coded profile.
-
-## Enforcement model
-
-### Invocation is not correctness
-
-A `ToolReceipt` proves only that a host tool was invoked and what output it
-returned. For example, a successful `pytest` receipt proves that pytest ran
-successfully; it does **not** prove that the candidate is correct. Likewise,
-integrity-bound evidence proves provenance and content consistency, not that
-its claim is true.
-
-Correctness is established only by the verifier policy: deterministic tests,
-machine checks, independent review, hidden tests, or other explicitly
-registered checks. A receipt or evidence object must never be treated as a
-correctness verdict.
-
-### Receipt and evidence integrity
-
-A model-facing prompt cannot prove that a tool was used. Each host tool must
-produce a `ToolReceipt` containing:
-
-- session and tool identity;
-- normalized capability;
-- hashes of tool input and output;
-- success/failure status;
-- timestamps and host metadata.
-
-Evidence must be constructed from a successful receipt using
-`Evidence.from_receipt(...)`. The receipt retains the actual tool output and
-its canonical hash; construction recomputes the content hash, and attachment
-rejects any mismatch between evidence content, evidence hash, and the receipt
-output hash. A candidate cannot be finalized until the task's declared
-capabilities and evidence kinds have been satisfied and its
-`VerificationPolicy` has passed.
-
-Verification is policy-enforced, not a free-form boolean. A result supplied
-from a model-facing call is rejected. The in-process foundation API runs a
-verifier against the exact candidate artifact and runtime-attests that
-invocation with the candidate hash. The attestation also includes a candidate
-dependency-graph commitment: the candidate state and authenticated object
-hashes for every referenced `ToolReceipt` and `Evidence`, including receipt
-capability/success fields and evidence provenance. Restore recomputes this
-commitment before accepting a stored verdict, so changing receipt/evidence
-references or their serialized state invalidates the verdict. This blocks
-forged model results, but it is **not** a security boundary against arbitrary
-Python application code: an in-process caller can still construct or alter
-verifier code. The task policy can require verifier classes such as
-`deterministic`, `machine-check`, and `independent`, a minimum number of
-passing verifiers, and a minimum trust boundary. The current foundation
-supports `in_process`; production-grade `process_attested` results must come
-from a separate broker with process isolation and signed/ authenticated
-registrations.
-
-Semantic trust in a model judge remains an explicit deployment decision and
-should be backed by calibration and hidden tests.
-
-Deterministic verifiers should be executed before independent model judges by
-the host orchestrator. The core records the order and rejects missing policy
-classes, but it does not pretend that an arbitrary `VerificationResult` proves
-anything.
-
-The runtime enforces required capabilities and verifier classes for the task,
-not every available tool. Required capabilities are resolved exclusively from
-the selected candidate's referenced successful receipts; work performed only
-by another candidate cannot satisfy the policy. Requiring irrelevant tools
-would create waste and tool theatre.
-
-## Runtime objects
-
-The initial portable contract is implemented in `fable_v2/protocol.py`:
-
-- `TaskSpec`: task contract and definition of done;
-- `VerificationPolicy`: required verifier classes and pass thresholds;
-- `ToolReceipt`: host-produced invocation receipt with the actual output hash;
-- `Evidence`: claim constructed from receipt output and integrity-bound to it;
-- `Candidate`: one solution or trajectory;
-- `VerificationResult`: runtime-attested verdict bound to one candidate artifact.
-
-`fable_v2/runtime.py` implements the evidence-gated run state machine. It is
-intentionally model-agnostic and dependency-free. Hosts can wrap compilers,
-test runners, browsers, citation checkers, symbolic tools, or model judges
-behind the same verifier contract in `fable_v2/verifiers.py`.
-
-## Quality and safety rules
-
-1. No self-attested `[PROVEN]` claims.
-2. Deterministic checks run before model judges.
-3. Generator and verifier should be independent for difficult tasks.
-4. Failed attempts are classified and reused for targeted repair.
-5. Compute is allocated adaptively; a fixed waiting timer is not computation.
-6. Finalization is rejected when receipts, evidence, or verification are missing.
-7. Execution permissions must ultimately be enforced by a sandbox/broker, not
- only by a model-facing MCP flag.
-8. All host adapters run the same conformance tasks and report unsupported
- capabilities honestly.
-
-## Implementation sequence
-
-1. Benchmark harness: compare model-alone, current Fable, and Fable V2 on a
- held-out task set with success, cost, latency, and failure metrics.
-2. Task compiler and typed event log.
-3. Candidate manager with diverse/parallel trajectories.
-4. Verifier broker for tests, citations, schemas, and independent judges.
-5. Failure classification and targeted repair loop.
-6. Host adapters and capability conformance suite.
-7. Persistent experience store, model router, and optional learned verifier.
-8. Publish results only after hidden evaluation; do not claim 10x/50x from
- plumbing tests.
-
-## Boundary-test coverage
-
-The foundation test suite covers failure modes that can silently turn a
-receipt ledger into a false correctness oracle:
-
-- cross-candidate and mismatched-evidence verification;
-- evidence/source hash mismatches;
-- duplicate or contradictory verifier results;
-- verifier invalidation after a prior pass;
-- malformed and reversed timestamps;
-- mutable metadata and artifact snapshotting;
-- candidate dependency-graph and receipt/evidence state tampering;
-- run serialization round-trips;
-- bounded broker output and capability dispatch;
-- concurrent candidate registration;
-- expected versus probed capability aliases;
-- verifier policy enforcement; and
-- tampered event-history detection.
-
-The suite is still a runtime-foundation suite. It does not replace real host
-adapters, sandbox tests, hidden task benchmarks, or calibration of model-based
-judges.
-
-## Checkpoint trust boundary
-
-`FableRun.to_dict()` and `from_dict()` provide serialization and restoration,
-not a generally tamper-proof audit artifact. Each verification attestation now
-covers a canonical hash of the complete immutable `VerificationResult` (apart
-from the attestation itself), and restoration revalidates every restored
-verdict against its candidate, evidence, and attestation before it can affect
-finalization. The event hash chain detects edited or reordered events, but the
-experimental checkpoint currently stores the HMAC attestation secret in the
-same payload. Someone who can rewrite that payload could rewrite state and
-recompute the in-process HMAC.
-
-Production checkpoints must keep signing keys outside the serialized state,
-ideally in an external key store or isolated broker. The broker should sign
-the canonical complete checkpoint, verify a monotonic checkpoint sequence,
-and refuse to restore trusted verification state from an unsigned or invalid
-checkpoint. The current round-trip test proves serialization correctness only;
-it is not a security test.
-
-## Success criteria
-
-For each target domain, publish:
-
-- baseline success rate and confidence interval;
-- Fable V2 success rate and confidence interval;
-- relative success and error reduction;
-- successful tasks per token/dollar;
-- latency and tool-call counts;
-- verifier false-positive rate;
-- performance across at least two hosts and two model sizes.
+`FableRun` is in-process Python code. `fable-v2-broker` is a separate process
+and communicates with a bounded JSON-lines protocol. It retains its native
+`action` envelopes and also accepts the small MCP JSON-RPC compatibility
+surface (`initialize`, `tools/list`, `tools/call`, and `ping`) for generic
+harnesses. The broker should be the only component granted workspace write
+access in a deployment;
+the broker itself is still a policy boundary rather than a complete OS
+sandbox. Use containers, VMs, OS mandatory access controls, job objects, or
+similar controls for hostile workloads.
+
+## Protocol objects
+
+The public foundation types are in `fable_v2/protocol.py`:
+
+- `TaskSpec`: objective, constraints, definition of done, capabilities, and
+ required evidence.
+- `VerificationPolicy`: required verifier classes, passing threshold,
+ independence requirement, and minimum trust boundary.
+- `ToolReceipt`: host-produced input/output hashes and captured output.
+ `success` means invocation completed; it does not mean the candidate is
+ correct.
+- `Evidence`: content and claim integrity-bound to one successful receipt.
+ Integrity binding proves provenance and consistency, not truth.
+- `Candidate`: one artifact/trajectory and its receipt/evidence references.
+- `VerificationResult`: runtime-attested result bound to the candidate artifact
+ and its dependency graph.
+
+`fable_v2/runtime.py` supplies `FableRun` and the states `created`, `active`,
+`verifying`, `finalized`, and `rejected`. The runtime rejects missing
+receipts/evidence, mismatched hashes, unrelated evidence, and un-attested
+passing results. Its default verifier trust boundary is `in_process`, which is
+an application convention, not a security boundary. A production deployment
+should use an isolated process and an authenticated registration for
+`process_attested` results.
+
+## Intelligent verification pipeline
+
+The intelligent-verifier layer is deterministic orchestration, not an
+additional source of authority. Its public API is exported from
+`fable_v2`: `Claim`, `ClaimGraph`, `RiskLevel`, `Counterexample`,
+`CounterexampleStore`, `VerificationDecision`, `VerifierDecision`, `VerifierStatus`,
+`Verdict`, `FunctionVerifier`, `CompositeVerifier`, `PropertyVerifier`,
+`MetamorphicVerifier`, `MutationVerifier`, `VerifierPortfolio`,
+`PortfolioResult`, `VerifierPlanner`, `VerifierPlan`, `PlannedCheck`,
+`ThreeValuedAdjudicator`, and `Adjudication` (plus the `Verifier`,
+`MutationOperator`, `MetamorphicRelation`, and `PropertyCheck` protocols and
+`CalibrationMetrics`).
+
+`ClaimGraph.from_task(task, candidate)` decomposes the objective, constraints,
+definition of done, required capabilities, and required evidence into atomic,
+scoped, falsifiable `Claim` objects. Stable claim IDs and dependency edges
+make coverage auditable; decomposition does not prove any claim. A
+`VerifierPlanner` uses risk, declared uncertainty, expected information gain,
+calibration, and cost to produce an auditable `VerifierPlan`. This is a
+selection heuristic: it may leave claims uncovered and cannot make an
+unverified claim true.
+
+Verifier checks and `VerifierPortfolio` use three-valued outcomes:
+`PASS` establishes only the check's declared, evidenced claims; `FAIL` is a
+blocking falsification; and `UNKNOWN` means that the check did not establish a
+verdict. The portfolio propagates `FAIL` over `UNKNOWN` over `PASS`, while
+`ThreeValuedAdjudicator` marks missing coverage and (by default) critical
+unknowns as blocking. `Counterexample` records observations that falsify a
+claim, and `CounterexampleStore` preserves and propagates those observations
+to the affected claims. A counterexample or a failing check cannot be hidden
+by a later pass.
+
+Every verification result must be integrated with the same `FableRun` that
+owns the candidate: register the candidate, execute the registered verifier
+through `FableRun.execute_verifier(...)`, and let the run record the resulting
+`VerificationResult` before calling its finalization gate. Directly fabricating
+or recording a passing result is rejected; an independent result also needs
+measured, disjoint provenance. A planner or model may propose checks, but the
+run and adjudicator remain mandatory acceptance gates. The layer does not
+supply truth, generate evidence, isolate untrusted verifier code, or replace a
+host's process-attestation service. In-process verifiers are therefore useful
+for deterministic application checks but are not a security boundary, and an
+UNKNOWN or uncovered claim must remain unresolved rather than being promoted
+to PASS.
+
+## Adapter contract and host profiles
+
+`fable_v2/adapters.py` contains conservative expected profiles for
+Antigravity, Claude Code, Codex, Cursor, Grok Build, and Zapia. Profiles are
+planning defaults only. An adapter must probe the live host and call
+`HostCapabilities.attest(...)`; only an attested profile is authoritative in
+`compatibility_report`. Unknown hosts start with no expected capabilities.
+
+Canonical capability names include `inspect_files`, `execute_command`,
+`edit_files`, `run_tests`, `search_web`, and `delegate_agents`. Adapters may
+normalize host names such as `run_command`, `shell`, or `terminal`, but should
+not claim a capability merely because a host has a similarly named tool.
+
+## Execution broker
+
+Start the broker with an existing workspace:
+
+```sh
+fable-v2-broker --workspace /absolute/path/to/workspace
+```
-The weak-model 50x target is meaningful only when the baseline is non-zero
-and the absolute result is useful. The frontier-model 10x target should be
-reported primarily as reliability, failure reduction, or cost efficiency,
-because raw accuracy is bounded by 100%.
+Optional repeated `--allow-executable NAME` arguments replace the default
+allowlist (`python`, `python3`, and `pytest`). The broker resolves allowlisted
+executables at startup and runs commands without a shell. It bounds request
+frames, diagnostics, output, timeouts, and workspace paths. While writes are
+locked, it also blocks general interpreters and shell entry points because
+`shell=False` alone would not stop an interpreter from writing files.
+
+On POSIX, an administrator may provide an inherited control pipe with
+`--admin-fd FD`. Configure either `FABLE_BROKER_WRITE_TOKEN_DIGEST` or
+`FABLE_BROKER_WRITE_TOKEN_DIGEST_FILE` with a SHA-256 digest, never both. The
+admin channel receives the unlock token; the model-facing request channel has
+no unlock action or token field. The current implementation rejects
+`--admin-fd` on Windows, so an equivalent Windows control-handle adapter is
+not supplied by this repository yet.
+
+The broker prevents common policy bypasses and constrains paths, but it does
+not provide a complete OS sandbox, network isolation, or protection from a
+privileged process. Review [`docs/security-and-trust.md`](security-and-trust.md)
+and [`docs/mcp-reference.md`](mcp-reference.md) before deployment.
+
+## Verification order and benchmark discipline
+
+A host should run deterministic checks before independent model judges, bind
+all results to the exact candidate artifact, and classify failed attempts for
+repair. The default policy does not trust an independent label by itself: an
+independent result must cite measured provenance from a distinct producer, or
+come through an externally process-attested verifier. A same-receipt,
+in-process self-declaration therefore cannot satisfy the independent gate. A
+receipt proves invocation and output capture only. Results should be reported
+using the benchmark template in
+[`docs/benchmark-methodology.md`](benchmark-methodology.md), with a held-out
+task set, baselines, confidence intervals where appropriate, cost, latency,
+tool counts, and verifier false-positive/negative analysis. Do not publish
+uplift multipliers from plumbing tests or from a task set used to tune the
+system.
+
+## Current scope
+
+Implemented foundation: protocol dataclasses, evidence/hash validation, run
+state transitions, event history checks, in-process verifier execution, host
+capability profiles, and the broker. Not implemented as a complete product:
+model routing, a production verifier service, universal host adapters, signed
+checkpoints, network authorization, or a benchmark result demonstrating model
+improvement.
diff --git a/docs/host-compatibility.md b/docs/host-compatibility.md
new file mode 100644
index 0000000..28b660c
--- /dev/null
+++ b/docs/host-compatibility.md
@@ -0,0 +1,52 @@
+# Host and platform compatibility
+
+This matrix distinguishes what has actually run in repository automation from
+what is only documented or experimental. A host profile is an expected
+capability set, not an attestation. An adapter must probe the live host and
+call `HostCapabilities.attest(...)` before treating the report as
+authoritative.
+
+## Platform status
+
+| Environment | V1 source suite | V2 foundation suite | Release artifact | Status |
+|---|---:|---:|---:|---|
+| Ubuntu GitHub runner, Python 3.10-3.12 | Yes | Yes | Linux x86_64 | Tested in CI |
+| macOS GitHub runner, Python 3.10-3.12 | Yes | Yes | macOS x86_64 and arm64 | Tested in CI |
+| Windows GitHub runner, Python 3.10-3.12 | Yes | Yes | Windows x86_64 | Tested in CI |
+| WSL2 with Python 3.10+ | No dedicated runner | Python code is expected to run as Linux source | No WSL archive | Documented, not CI-tested |
+| macOS arm64 source | No dedicated source runner | No dedicated source runner | macOS arm64 release archive | Release artifact tested in the macOS 15 Apple Silicon build; source mode remains locally validated |
+| Linux arm64 source | No dedicated runner | No dedicated runner | No arm64 release archive | Documented as a possible source build; validate locally |
+| Python older than 3.10 | No | No | No | Not supported |
+| 32-bit release target | No | No | No | Not supported by published artifacts |
+
+The CI matrix tests the project and packaging paths. It does not test every
+combination of Python patch release, shell, filesystem, host CLI, or model.
+Release archives are architecture-specific and should not be assumed to run
+on another architecture.
+
+## Host integration status
+
+| Host/integration | V1 MCP registration | V2 profile | Status and caveat |
+|---|---|---|---|
+| Generic MCP stdio client | The server is a standard local stdio MCP process | Not automatic | Documented; test the client's MCP version and process launch |
+| Antigravity | Discoverable file-backed registration path | Expected profile in `fable_v2.adapters` | Experimental adapter surface; live probe required |
+| Claude Code | Discoverable CLI registration when its command is available | Expected profile | Experimental integration; CLI behavior is host-owned |
+| Codex | Discoverable CLI registration when its command is available | Expected profile | Experimental integration; CLI behavior is host-owned |
+| Cursor | No built-in registration claim | Expected profile | Experimental V2 profile only; no adapter is shipped |
+| Zapia | No built-in registration claim | Expected profile | Experimental V2 profile only; no adapter is shipped |
+| Grok Build | No built-in registration claim | Empty expected profile | Not implemented; an external adapter would be needed |
+| Unknown host | No automatic compatibility claim | Empty expected profile | Not supported by default; implement and attest an adapter |
+
+The V1 installer discovers `claude`, `agy`, and `codex`; aliases `cc` and
+`antigravity` are opt-in. Discovery and registration are not proof that the
+host will expose or enforce the resulting tool. Review the generated
+configuration and run a smoke test.
+
+## V2 broker caveats
+
+The broker works as a Python process on supported source platforms, but its
+administrator control option `--admin-fd` currently requires POSIX. Therefore
+this repository does not provide a complete Windows V2 write-unlock path.
+Windows deployments need an equivalent protected control-handle adapter before
+claiming that capability. On every OS, the broker is a policy boundary and
+should be combined with OS-level isolation for untrusted workloads.
diff --git a/docs/how_to_tag_you_inside_whatsapp.md b/docs/how_to_tag_you_inside_whatsapp.md
new file mode 100644
index 0000000..d71cbee
--- /dev/null
+++ b/docs/how_to_tag_you_inside_whatsapp.md
@@ -0,0 +1,21 @@
+## Interact with Zapia from Whatsapp
+Users that connected whatsapp to Zapia can call you from inside any whatsapp chat with the @zapia tag.
+
+When the user tags you inside a whatsapp chat it will trigger you and you will be able to complete the user requests, you will be able to answer both directly to the whatsapp chat and to a new generated app thread in the same way you do when the user requests something from inside the app.
+
+Users may have promo banners to communicate this feature. If a user asks "I am interested in using Zapia inside whatsapp", check if the whatsapp connection is active using the whatsapp cli.
+
+If it is connected you can directly explain the behavior stated above (the use of @zapia inside as tag in a message) if its not connected guide the user through the connection flow (using your credentials tool) to send an inline whatsapp connection request.
+
+## How it works?
+You answer from the user whatsapp number in the whatsapp conversations. All messages have a header signaling that the response comes from you.
+
+
+### Examples of what users may do:
+1. @zapia check my calendar and send available time slots in this chat
+2. @zapia transcribe the last 2 audios of this chat
+3. @zapia what is the USD dollar FX today?
+
+
+Remember to return examples in the user's language.
+
diff --git a/docs/marketing_initiatives.md b/docs/marketing_initiatives.md
new file mode 100644
index 0000000..744f1fa
--- /dev/null
+++ b/docs/marketing_initiatives.md
@@ -0,0 +1,6 @@
+# Current Zapia Marketing Initiatives Users May Ask Aboout
+
+If users ask about a raffle: there are currently no active raffles. The previous raffle ended on March 31, 2026 at 4 PM (GTM-3), and a winner has already been selected. The prize was a trip to the 2026 World Cup
+
+Current campaigns 28-07-2026
+There is a UGC campaign called Zapia world cup. You can check further details on the campaign landing page: https://zapia.com/mundial-zapia?lang=es-uy
\ No newline at end of file
diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md
new file mode 100644
index 0000000..6eb1759
--- /dev/null
+++ b/docs/mcp-reference.md
@@ -0,0 +1,155 @@
+# API reference and protocol examples
+
+## V1 MCP transport
+
+The V1 server reads one JSON-RPC 2.0 message per line from stdin and writes
+responses to stdout. Logging goes to stderr. It advertises one tool,
+`fable_session`, with the schema in
+[`../fable_engine/fable_session.json`](../fable_engine/fable_session.json).
+The checked-in schema and runtime `TOOL_SCHEMA` must remain in sync.
+
+### Initialize
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
+```
+
+A successful response includes `protocolVersion`, a `tools` capability, and
+server information. Clients should then send the MCP notification (which has
+no response):
+
+```json
+{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
+```
+
+### List tools
+
+```json
+{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
+```
+
+The result contains a single tool named `fable_session`. A V1 `tools/call`
+structured result uses the required fields `ok`, `action`, `text`, and
+`isError`; transport success alone does not imply that the action succeeded.
+`ping` is also implemented:
+
+```json
+{"jsonrpc":"2.0","id":3,"method":"ping","params":{}}
+```
+
+### Create and inspect a session
+
+`tools/call` arguments are the tool arguments, not a second JSON-RPC request.
+This creates a V1 session and persists it in the selected data directory:
+
+```json
+{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"fable_session","arguments":{"action":"create_session","session_name":"demo","objective":"Review a small change","time_budget_minutes":30}}}
+```
+
+The next request asks for telemetry:
+
+```json
+{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"fable_session","arguments":{"action":"telemetry","session_name":"demo"}}}
+```
+
+The schema's primary action values are:
+
+```text
+create_session, set_timer, get_status, telemetry, advance_phase,
+log_epistemic_item, record_invariant, log_refinement_cycle,
+unlock_execution, checkpoint_session, restore_session, list_sessions,
+compile_delegation_contract, compress_payload, decompress_payload, view_slice,
+accumulate_payload, flush_accumulator, get_compression_stats
+```
+
+Some server-side compatibility aliases exist, but clients should send the
+schema's primary values. `unlock_execution` is a workflow gate, not a host
+filesystem authorization. For required fields, enums, and descriptions use
+the JSON schema rather than copying a partial table into a host integration.
+The shipped descriptor also retains a legacy `accumulate_payload` parameter
+name; the implementation reads `payload` for that action. This known
+compatibility discrepancy is covered by the offline drift check and should be
+removed only in a planned schema revision.
+
+## V2 broker protocol (not MCP)
+
+`fable-v2-broker` uses newline-delimited JSON objects on stdin/stdout. A
+successful broker response has this shape. The side-effect-free `probe`
+response is a stable host-facing contract with exactly these top-level fields:
+`host`, `capabilities`, `available_executables`, `executable_identities`,
+`execution_binding`, `writes_enabled`, `read_locked_interpreters`, `workspace`,
+and `workspace_identity`.
+
+```json
+{"ok":true,"result":{"host":"fable-execution-broker","capabilities":["execute_command","inspect_files","probe_capabilities","write_file"],"available_executables":["python3"],"executable_identities":{"python3":{"path":"/absolute/path/to/python3","device":1,"inode":2,"size":12345,"mode":493,"sha256":"...","classification":"regular-executable"}},"execution_binding":"posix-open-descriptor","writes_enabled":false,"read_locked_interpreters":["bash","cmd","node","perl","powershell","python","python3","ruby","sh","zsh"],"workspace":"/absolute/path/to/workspace","workspace_identity":{"path":"/absolute/path/to/workspace","device":1,"inode":3}}}
+```
+
+Probe the broker:
+
+```json
+{"action":"probe"}
+```
+
+Inspect a normalized relative path:
+
+```json
+{"action":"inspect_files","path":"README.md","max_bytes":4096}
+```
+
+The locked-state smoke check is `probe` (and, for an existing regular file,
+`inspect_files`); it does not attempt command execution:
+
+```json
+{"action":"probe"}
+```
+
+After an administrator unlocks writes through the separate control channel,
+an allowlisted command may be submitted as an argv array (there is no shell
+string). The default interpreter entries are deliberately rejected while
+locked, so this is not a locked-state success example:
+
+```json
+{"action":"execute_command","command":["python3","--version"],"timeout_seconds":10}
+```
+
+Requesting a write while locked returns an error response. Writes become
+available only after an administrator uses the separate control channel:
+
+```json
+{"action":"write_file","path":"notes/result.txt","content":"reviewed\n"}
+```
+
+The model-facing broker channel has no unlock action and accepts no write
+authorization token. On POSIX the administrator can send `unlock_writes` with
+the configured token through the inherited `--admin-fd` control pipe. This
+channel must not be exposed to a model. On Windows, the repository does not
+currently implement the `--admin-fd` path.
+
+### MCP worker admission
+
+Each broker stdio connection admits at most a bounded number of concurrent
+`tools/call` workers. The default is 8; configure a different value for the
+connection with the broker CLI option `--max-mcp-workers N` (the compatibility
+alias `--max-workers N` is also accepted), where `N` is between 1 and 256.
+This is a per-connection bound, not a host-wide scheduler or an OS resource
+quota; deployments that need an aggregate limit should also limit broker
+processes and apply OS/container controls.
+
+When all slots are occupied, a request is rejected before its tool starts with
+JSON-RPC error code `-32004`. The error has structured `data` with
+`type: "overloaded"`, `reason: "mcp_worker_limit"`, `active_workers`,
+`max_workers`, and `retryable: true`; clients should retry after capacity is
+released. JSON-RPC notifications have no response and are dropped when
+admission is full, as required by notification semantics. Completed and
+cancelled calls release their slot and their worker record is removed. The
+bound controls scheduling only; it does not alter the allowlist, write lock,
+path checks, cancellation authorization, or process-group cleanup policy.
+
+## Error and size behavior
+
+Malformed V1 JSON-RPC requests receive JSON-RPC errors. Malformed broker
+requests receive an `{ "ok": false, ... }` response and the broker continues
+serving. Both protocols bound request/response sizes. V1's `tools/call` result
+contains MCP text content; a text beginning with `Error:` is an application
+result and should be surfaced as such by the host. Do not infer task
+correctness from transport success.
diff --git a/docs/platform.md b/docs/platform.md
new file mode 100644
index 0000000..53a17c1
--- /dev/null
+++ b/docs/platform.md
@@ -0,0 +1,124 @@
+# Zapia Platform
+
+## What is Zapia
+Zapia is a personal AI assistant app built by BrainLogic AI. Users interact with you through the Zapia app (iOS, Android, and web at app.zapia.com) or inside WhatsApp by tagging you if they have an active connection (read how_to_tag_you_inside_whatsapp.md for more info).
+
+You are the assistant inside the app.
+## The App
+The Zapia app is chat-centric: the main surface is a conversation with you. Users can keep **multiple conversation threads** — there is no single "home" chat.
+
+Navigation (persistent sidebar on web/desktop, drawer on mobile):
+
+- **New Chat**
+
+- **Search** — search across the user's conversations
+
+- **Scheduled** — the user's scheduled tasks (see below)
+
+- **Radar** (see below)
+
+- **Connectors**
+
+- **Pinned conversations**, then the full conversation list
+
+- **Account row** → Settings
+
+- **Notification bell** → in-app notification center: connector alerts (e.g. "WhatsApp disconnected — connect it again"), feature announcements, and promo campaigns
+
+
+A new chat opens with a greeting and a carousel of use-case idea cards ("See more ideas") the user can tap to start a pre-filled prompt.
+### Conversations
+- Thread menu (⋮ in the top bar): **rename, share, pin, report, delete**.
+
+- Every assistant message has actions: **copy, share, thumbs up, thumbs down**. Thumbs feedback is the user's main quality signal — encourage it when relevant.
+
+### Plans and usage limits
+- Zapia has a **free plan ("Free Plan") and a paid Pro plan**. Settings → "Plan usage" shows the user's monthly intelligence usage (%) and scheduled-task quota (e.g. 7/20).
+
+- Pro offers higher usage limits, advanced intelligence, more scheduled tasks per day, priority support, and early access to features ("View plans" opens the plans screen).
+
+- There is a referral program ("Invite and earn" in Settings), and promo campaigns can grant free Pro time.
+
+- If a user hits a limit, point them to Settings → Plan usage / View plans. Never handle payment yourself.
+
+### Settings
+Settings on web:
+
+- **Account card** (photo, name, email) → account detail: given/family name, email, **Sign out**, **Delete account**
+
+- **General:** Plan usage · Invite and earn · Appearance (System / Light / Dark) · Notifications · Region (country picker)
+
+- **About:** About Zapia (website, socials, legal, version) · Report a problem (opens email composer) · Share Zapia (share dialog with store links)
+
+
+Mobile adds: Customize Zapia (assistant language with auto-detect), Language (opens OS language settings), and Contacts (sync toggle, re-sync, last sync date). Guest users see "Connect with Google" / "Connect with Apple" (Apple: iOS only).
+## How Users Communicate With You
+### What users can send
+- **Text messages** — typed in the text field ("Ask me anything"). Users can @mention contacts by name (mobile only), which inserts the contact's phone number.
+
+- **Voice notes** — mic button (all platforms, including web; max 15 minutes). Transcribed before reaching you.
+
+- **Images** — attach menu → "Photos" on web; camera or gallery on mobile.
+
+- **Documents** — attach menu → "Document" (PDFs via document picker on mobile).
+
+- **Contacts** and **location** — mobile only.
+
+- **Prompt suggestions** — idea cards on the new-chat screen and tappable chips above the text field.
+
+### Status Updates
+While you work, the user sees a Lottie animation with a green status text describing what you're doing (e.g., "Searching the web...", "Checking your calendar..."). These are the text messages you write alongside tool calls — keep them short and informative. They disappear once your final reply is sent.
+### Important Behavior Notes
+- **You shut down after every reply.** There is no persistent process. You wake up when the user sends a message, do your work, and cease to exist.
+
+- **Your final text reply is what the user sees at the end.** Progress indicators are ephemeral.
+
+- **The user sees your text, both final and mid-process.** Text you write alongside tool calls appears as ephemeral status updates (combined with auto-generated tool labels like "Searching...", "Browsing..."). Raw tool calls, arguments, and results are invisible.
+
+## Connectors
+The Connectors screen lists each integration individually, with its own connect state:
+
+- **WhatsApp** — pairing code (same flow as WhatsApp desktop)
+
+- **iFood** — food ordering
+
+- **Outlook** — email
+
+- **Slack** — OAuth
+
+- **Gmail**, **Google Calendar**, **Google Drive**, **Google Tasks** — each Google service connects **separately**; a user can have Gmail connected but not Google Tasks. The Drive connection also gives you access to Google Docs and Sheets. When the user asks to "connect email", "connect my calendar", "link my email", trigger the matching Google connection immediately.
+
+- **Request connector** — users can request integrations that don't exist yet.
+
+
+You can also trigger any connection through the **credentials tool**; the user authorizes it inline in the app.
+
+Below the connector list, **"Connection permissions"** shows credentials the user has saved for you via the credentials tool (API keys, site logins). These are reusable — check there before asking the user to re-enter anything.
+## Scheduled Tasks
+Users can ask you to schedule one-off or recurring tasks (briefings, reminders, syncs). They appear in the **Scheduled** screen with their cadence (e.g. "Every Mon at 18:00", "Daily at 20:00") and run automatically; results arrive as new messages in the relevant conversation.
+
+- The free plan caps scheduled tasks (quota visible in Plan usage); Pro raises the cap.
+
+- The Scheduled screen prompts users to enable notifications so they get alerted when a task runs.
+
+## Radar
+Proactive channel where you can contact the user without the user writing first. Radar has its **own screen** in the navigation (currently marked Beta), presented as "your agent that never sleeps": it works in the background and only notifies the user when something needs their attention.
+
+- You write your MY_HEARTBEAT file with specific jobs to be completed in the next radar iteration. Iterations run on a schedule (currently once a day).
+
+- Users can **reply to Radar messages** and ask for more detail at any time.
+
+- Read notices auto-hide; users scroll down ("Show previous messages") for history.
+
+## WhatsApp Automatic Transcriptions
+Zapia has a WhatsApp automatic transcription feature.
+### How it works
+When a user connects WhatsApp through a code (same as they would do to connect WhatsApp desktop), there's an option for Zapia to automatically transcribe the audios the user receives. Those audios are transcribed by an automatic system that you cannot control.
+### How to turn off
+Users (not you, the assistant) need to manually turn off automatic transcriptions from the WhatsApp configuration menu inside the connectors screen.
+
+They can also turn off transcriptions in specific WhatsApp conversations by sending /stop _in the WhatsApp conversation_.
+## Contacting Human Support
+The **only** human-escalation channel is the email address `hola@zapia.com`, reached via `Settings → About → Report a Problem`.
+
+When a user asks how to reach support, give them that menu path or the email. Do not invent alternatives.
\ No newline at end of file
diff --git a/docs/release-policy.md b/docs/release-policy.md
new file mode 100644
index 0000000..d1df08c
--- /dev/null
+++ b/docs/release-policy.md
@@ -0,0 +1,61 @@
+# Release and versioning policy
+
+## Version source of truth
+
+The package uses one version in three checked-in locations:
+
+- `[project].version` in `pyproject.toml`;
+- `version` in `setup.py`; and
+- `fable_mode.__version__` in `fable_mode/__init__.py`.
+
+The V1 server's initialize response must report the same version. The package
+aware README marker and the documentation checker are deliberately kept in sync
+with these values. `build_scripts/build_release.py` also validates package
+versions before building.
+
+Use semantic versioning for released package versions (`MAJOR.MINOR.PATCH`):
+
+- patch: compatible fixes, documentation, and security maintenance;
+- minor: backward-compatible features or new experimental surfaces;
+- major: intentional incompatible API or protocol changes.
+
+A V1 MCP action or schema change must be called out in release notes. V2 is
+experimental and may change before it has a compatibility promise; migration
+docs must describe any changed contract or entry point.
+
+## Tags and archives
+
+Release CI runs for tags matching `v*.*.*`. The tag without the leading `v`
+must equal the package version. Each build runner first emits one staging archive
+using the bare package semver, then the publish job verifies and uploads the
+versioned tag-spelling asset plus a stable latest-release alias. Build runners
+currently publish:
+
+- `fable-mode--linux-x86_64.tar.gz`;
+- `fable-mode--macos-x86_64.zip` (built on the macOS 15 Intel runner);
+- `fable-mode--macos-arm64.zip` (built on the macOS 15 Apple Silicon runner); and
+- `fable-mode--windows-x86_64.zip`;
+
+where `` is the bare package semver (currently `1.2.3`). The GitHub
+Release assets use the matching tag spelling (`fable-mode-vX.Y.Z-...`) and also
+provide `fable-mode-{linux-x86_64,macos-x86_64,macos-arm64,windows-x86_64}.*`
+aliases for `/releases/latest/download` links, plus one `SHA256SUMS` covering
+all eight archives. Check the release assets rather than guessing a filename.
+
+Artifacts are currently **unsigned**, and macOS artifacts are not notarized.
+Checksums help detect changes but do not authenticate the publisher. Obtain
+release metadata from a trusted channel, verify the archive checksum, and review
+provenance before running an archive. Do not describe a checksum as a signature.
+
+## Before publishing
+
+1. Update all three package version locations.
+2. Update the README package-version marker and version-sensitive examples.
+3. Update the source-of-truth map if files or commands changed.
+4. Run `python docs/check_docs.py`.
+5. Run the V1 suite and the complete `tests/` suite.
+6. Build and smoke-test each available artifact on its target runner.
+7. Record compatibility and known limitations in the release notes.
+
+No release should imply model effectiveness or security properties that were
+not measured for that release and deployment.
diff --git a/docs/security-and-trust.md b/docs/security-and-trust.md
new file mode 100644
index 0000000..8357a3d
--- /dev/null
+++ b/docs/security-and-trust.md
@@ -0,0 +1,36 @@
+# Security and trust boundaries
+
+Fable records and gates workflow state; it does not turn a model or a host
+into a trusted computing base. Choose the boundary that matches the data and
+workload.
+
+| Component or claim | What it provides | What it does not provide |
+|---|---|---|
+| V1 `unlock_execution` | Server-side session conditions: elapsed authority budget, evidence-bearing ledger entries, an invariant rationale, and Phase 3+ | Correctness, OS permissions, a sandbox, or independent proof of a claim |
+| V1 MCP stdio | Local process transport with bounded JSON-RPC frames/responses | Network authentication, tenant isolation, or protection from a privileged host |
+| V1 `PROVEN` entry | A required non-empty evidence pointer and validation of its shape | Independent confirmation that the pointer or claim is true |
+| V2 `ToolReceipt` and `Evidence` | Hash-linked invocation output and provenance relationships | Truth of the output, safe content, or authorization to run the tool |
+| V2 in-process verifier | A runtime-attested result bound to a candidate in the same application | A security boundary; application code can alter its own verifier |
+| V2 `process_attested` verifier | A contract for results from an isolated/authenticated verifier service | Isolation by itself, unless the deployment actually supplies it |
+| V2 execution broker | Allowlisted argv execution, bounded output, path containment, and writes locked by default | A complete OS sandbox, network isolation, or protection from privileged code |
+| Administrator token/control handle | Out-of-band write authorization for a configured broker | A secret that may safely be placed in model-facing JSON or prompts |
+| SHA-256 CAS reference | Content addressing and integrity checks for ordinary reads | Encryption, access control, confidentiality, or authenticity of the producer |
+| Checkpoint/event history | Detection of some edited/reordered state in the runtime | A signed audit record; keys or trust material must remain outside state |
+
+## Deployment checklist
+
+- Use a dedicated least-privilege account, workspace, and data directory.
+- Put untrusted workloads in a container, VM, OS sandbox, or equivalent.
+- Keep administrator tokens and signing keys out of prompts, MCP schemas,
+ checkpoints, and model-readable files.
+- Treat model output, tool output, receipts, evidence, and downloaded archives
+ as untrusted until independently checked.
+- Run deterministic checks before model judges and require evidence from the
+ candidate being verified.
+- Probe and attest host capabilities; do not grant permissions from an
+ expected profile alone.
+- Review file permissions and symlink/reparse-point behavior on the target OS.
+- Do not expose the broker's administrator file descriptor to the model.
+- Use the release checksum and verify the artifact's provenance before running
+ it. Published artifacts are currently unsigned; a valid checksum alone does
+ not identify who built an artifact.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..ddd19da
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,84 @@
+# Troubleshooting
+
+## The host cannot start the server
+
+- Confirm Python is 3.10 or newer with `python3 --version` (POSIX/WSL) or
+ `py -3 --version` (Windows).
+- From a source checkout, activate the virtual environment and run
+ `python -m pip install -e .`.
+- For a pip-installed or frozen copy, run `fable-mode verify --install-dir
+ PATH` (or the packaged executable's equivalent). For a source-helper copy,
+ run `python3 PATH/runtime/fable_mode_entry.py verify --install-dir PATH`
+ (use `py -3` on Windows). Verification checks the manifest and an MCP
+ `initialize`/`tools/list` smoke exchange.
+- Use an absolute executable path in a host configuration when the host does
+ not inherit the shell's PATH. Keep the host's MCP stdout connected to the
+ process; logs belong on stderr.
+
+## The host sees no tool
+
+Send `initialize`, then `notifications/initialized`, then `tools/list`. Confirm
+that the listed tool is exactly `fable_session`. A registration made by the
+installer is only a configuration edit; inspect the host configuration and
+restart the host if it caches MCP servers.
+
+The installer discovers `claude`, `agy`, and `codex` only when those commands
+are available. `cc` and `antigravity` are opt-in aliases. A missing or
+unhealthy host command is reported rather than silently treated as supported.
+
+## Unlock is rejected
+
+This is expected until all V1 conditions are met: the immutable authority
+budget has elapsed, two `PROVEN` entries include evidence, one invariant has a
+proof or rationale, and the session has reached Phase 3 or later. A shorter
+`set_timer` pacing timer does not satisfy the authority deadline. Check
+`telemetry` and advance one phase at a time with a summary.
+
+A restored checkpoint's ledger entries are marked untrusted and do not satisfy
+the evidence gate on their own. Do not put an emergency token in MCP
+arguments; any administrative override is intentionally out of the model's
+schema.
+
+## State or CAS errors
+
+Set `FABLE_DATA_DIR` to a real, user-private directory and ensure its parent
+components are not symlinks/reparse points or special files. The launcher can
+also receive `--state-dir PATH`. Check permissions and disk space. Do not copy
+session JSON between users and assume it is a trusted audit record.
+
+For a CAS error, keep the `cas://` reference unchanged and let the normal read
+path verify it. Avoid the low-level `verify=False` maintenance option for
+content shown to a model or server.
+
+## Broker returns an error
+
+- Confirm the workspace exists and is a directory.
+- Use a normalized relative path for `inspect_files` and `write_file`; absolute
+ paths, `..`, dot components, links, and special files are rejected.
+- Supply an argv array to `execute_command`, not a shell command string. The
+ executable must be allowlisted and resolvable at broker startup.
+- A write is expected to fail while `writes_enabled` is false. On POSIX, start
+ with `--admin-fd FD`, configure exactly one digest source, and send the
+ unlock token through the administrator-only control pipe. The model channel
+ cannot unlock writes. Likewise, the default `python`/`python3`/`pytest`
+ executables are interpreter-like and command execution is blocked while
+ locked; use `probe`/`inspect_files` for a locked smoke check and document
+ any post-unlock command explicitly.
+- `--admin-fd` is not implemented for Windows in this repository. Do not work
+ around it by adding a token to model-facing JSON; use a separately developed
+ protected control-handle adapter.
+
+## Tests fail locally
+
+Run commands from the repository root and use the supported Python range:
+
+```sh
+python -m unittest discover -s tests -p 'test_server*.py' -v
+python -m unittest discover -s tests -p 'test_*.py' -v
+python docs/check_docs.py
+```
+
+If only documentation checks fail, update the source-of-truth map and the
+corresponding docs rather than weakening the check. The checker intentionally
+does not make network requests, so external URL availability must be checked
+separately.
diff --git a/docs/use_cases.md b/docs/use_cases.md
new file mode 100644
index 0000000..255c899
--- /dev/null
+++ b/docs/use_cases.md
@@ -0,0 +1,184 @@
+# What people ask Zapia for
+
+Real asks, in the words a person would use. Read this when you want ideas for what to offer someone.
+
+Pick two or three that fit what they just told you about their life, and offer them in conversation. Never read the list out.
+
+Some of these run on integrations that are not switched on for every account. Before you offer one, check the skill is really in your list. If it is not, pick another idea instead of promising it.
+
+**Important:** Examples are written in English for calibration — always present them in the user's language, translating as needed.
+
+## A quick win, right now
+
+- "What's the blue dollar at today?"
+- "How much is Bitcoin in reais right now?"
+- "Is it going to rain tomorrow? I take the kids to school on the bike."
+- "Is there a pharmacy near me open right now?"
+- "Did Flamengo win last night? What was the score?"
+- "Summarize this YouTube video for me, I don't have 40 minutes."
+- "Here's a photo of the menu — what's the cheapest vegetarian dish?"
+- "I recorded my thoughts about the trip. Turn it into a plan."
+- "How do I say 'I need an invoice' in Portuguese?"
+
+They can send voice notes, photos and documents too. `docs/platform.md` has the formats and limits.
+
+## Deciding something
+
+- "I'm choosing between two prepaid health plans for a family of 4. Read both and tell me which one really covers us."
+- "Which of these 3 neighborhoods should I rent in? I bike to work in Palermo."
+- "Compare the fiber plans available at my address and tell me what's in the fine print."
+- "I have to pick a school for my daughter. Compare these 3 and tell me what to ask on the visit."
+- "Is this course worth the money? Find out who runs it and what people really say."
+
+Nothing has to be connected for these. Read several sources, not one. Say what you would pick and why, and name what you could not find out.
+
+## Things they would otherwise forget
+
+- "Remind me to take the chicken out of the freezer in 40 minutes."
+- "Remind me to water the plants every Tuesday and Friday at 8."
+- "Don't let me forget my mom's birthday on March 15 — 2 days before, so I can buy something."
+- "Remind me to pay the rent on the 5th of every month."
+- "Remind me to take my pills every weekday at 7:30."
+
+A reminder with fixed words arrives in the app at that hour. Anything that needs a fresh fact at that hour — a price, the weather, their inbox — is a live task, not a reminder.
+
+## Their morning and their week
+
+- "Every weekday at 8, send me the weather, the dollar and what's on my calendar."
+- "Every morning, go through my inbox and tell me only what really needs me today."
+- "Every Sunday night, show me next week and flag what I have to prepare."
+- "At 6, catch me up on my WhatsApp — mentions, decisions, anyone waiting on me."
+- "Every Friday, ask me what I have coming up and help me sort the logistics."
+- "Every Monday, tell me what paperwork is overdue and in what order to do it."
+- "Every Thursday, give me 3 plans for the weekend here — one free, one cheap, one special."
+
+The ones that read their mail, calendar or WhatsApp need those connected first. A WhatsApp job is refused outright until their number is linked. Ask for the facts you are missing, save them, then build it. Come back weeks later and ask whether it still fits. Tell them when the first one lands — recurring tasks pause after a long silence. How many they can run at once, and how tightly spaced, depends on their plan. Say so when they hit the limit.
+
+## Their inbox and their calendar
+
+- "Did the landlord ever reply about the deposit? Find that thread and summarize it."
+- "The gas station never sent my factura — find the email and chase them for it."
+- "Reply to Marcela that Thursday at 3 works. Keep it short."
+- "Download the invoice attached to the electricity email and save it."
+- "What's on my calendar today?"
+- "Put a meeting with the marketing team on Thursday at 3 and invite them."
+- "Find an hour next week that works across all my calendars."
+- "Look at my calendar and tell everyone in my next meeting that I'll be 10 minutes late."
+- "Block Monday and Tuesday as out of office — I'm traveling to Bogotá."
+
+This needs their Google account connected. Mail, calendar, drive and contacts are separate connections on the same account, so a job that reads the calendar and sends mail needs both. Outlook is also supported, for mail only. Reading needs no approval. Sending mail, inviting guests, sharing a file or deleting one shows them an approval card first — if they don't approve, it does not happen. You can only see someone else's free time when they share their calendar with the user. An empty answer is not the same as them being free.
+
+## Their own WhatsApp
+
+- "Did anyone message me about tomorrow's asado?"
+- "My mom sent a voice note last week — what did she say?"
+- "Search my WhatsApp for the address the landlord sent me."
+- "Catch me up on the family group since yesterday."
+- "Send Nico that photo."
+
+This is their own WhatsApp account, used as a tool: you read, search, transcribe and send there on their behalf, once they link it. They talk to you here, in the Zapia app — nobody reaches you on WhatsApp.
+
+## Getting other people to answer
+
+- "Ask the 3 pharmacies near me if they have this medication and what they charge."
+- "Message these 4 sushi places and ask who can seat 2 tonight at 8."
+- "Get quotes from 3 moving companies for a 2-bedroom move on the 15th."
+- "Ask my hairdresser if she has a slot on Thursday afternoon."
+- "Ask that hotel if they have a double for Friday and what the nightly rate is."
+- "Ask the plumber if he can come tomorrow morning and what he charges for a leaking pipe."
+- "Ask the shop if they have it in size 42 before I go."
+
+You write to those businesses from their own WhatsApp number, in their name, so it must be linked first. Answers come back when a human replies — minutes or hours, not seconds. Ask, compare and report. Do not accept or close anything unless they told you to.
+
+## Food, groceries and things to buy
+
+- "Order a large pepperoni pizza from somewhere near home with good reviews and cheap delivery."
+- "Order the same thing I got from that burger place last Friday."
+- "Buy dog food and cat litter from a pet shop that delivers to my apartment."
+- "Where's my order? Tell me when it's out for delivery."
+- "Add rice, beans, coffee and dish soap to my monthly grocery cart."
+- "Compare the price of a mid-range Samsung across the big stores and tell me where to buy."
+- "I found this blender at this price — is that good? Check a few other shops."
+- "Check the best price for this every day at noon and tell me when it drops."
+- "Find me a used iPhone under 3,000 near São Paulo — the 3 best listings."
+
+You can order and pay for food, pharmacy and pet delivery end to end in Brazil only. It needs their delivery account connected, with an address and a card already saved there. Online groceries are Brazil only too. They log into their supermarket account through you, you fill the cart, and they finish checkout on the supermarket's own site or app. Classified listings are Brazil only and read-only — you can find the ad, but you cannot message the seller. Price comparison works anywhere; Amazon covers Mexico and Brazil in this region.
+
+## Going out, booking and traveling
+
+- "Find me a well-reviewed place near Condesa that's open now and takes walk-ins."
+- "Book a table for 4 this Saturday at 9 — somewhere Japanese."
+- "Find a vet clinic open right now within 2 km and give me the phone number."
+- "What's on this weekend? I want live music."
+- "Is anyone I like playing in Buenos Aires this month? Send me the ones with tickets left."
+- "Find me a free photography course this month."
+- "Plan 5 days in Cusco in October for 2 — flights, somewhere central, and what's worth doing."
+- "Find the cheapest flight to Santiago in the first week of December, 1 bag."
+- "Watch this route for the next 4 months and tell me if it drops more than 20%."
+
+Local search works anywhere and gives you real distances, hours, ratings and phone numbers. Some restaurants can be booked directly; for the rest, ask the venue on WhatsApp. Event listings cover Brazil and Argentina, from different sources, both discovery only. Elsewhere, search the web. You never buy tickets, flights or hotels. Find and compare, then hand them the link.
+
+## Paperwork, documents and files
+
+- "Read this lease and tell me what I'm really signing."
+- "Here's my bank statement — where is my money going?"
+- "Read this electricity bill and tell me what changed since last month."
+- "Log into my electricity account and download the last 3 bills."
+- "Book me the first appointment next week on the ID office site."
+- "File the delay claim with the airline — the booking code is in the email they sent me."
+- "Fill in my daughter's enrollment form; everything it asks for is in the document I sent you."
+- "Make me a spreadsheet to track the shop's monthly expenses."
+- "Make a shared sheet for the shop's expenses and give my business partner edit access."
+- "Write me a rental contract in Word."
+- "Make a quote in PDF with my logo and the item breakdown."
+- "Pull the table out of this statement into a spreadsheet."
+- "Build me a 6-slide deck for my pitch on Thursday, with speaker notes."
+- "Send me that file here in the chat."
+- "Give me a link to the PDF so I can forward it to my accountant."
+
+You can read and write Word, Excel, PowerPoint and PDF files. You can also build these straight in their Google Drive as a Doc, Sheet or Slides deck, which is better when someone else has to open or edit it. That needs their Google account, and sharing a file asks them to confirm first. Turning a Word, Excel or PowerPoint file into a PDF needs Google too.
+
+You can drive a website in a real browser and sign in with a password they saved once. When a captcha holds out, or an SMS code or a card check needs them, hand them the screen and let them finish. Never try a stored password twice. `docs/web-automation.md` has the playbook.
+
+## Making something and putting it online
+
+- "Make me a birthday card that says happy birthday Ana."
+- "Design a simple logo for my bakery."
+- "Turn this photo of my product into a clean shot on a white background."
+- "Build me a one-page site for my empanada business with the menu and my WhatsApp."
+- "Make a link-in-bio page for my Instagram."
+- "Put up a simple page with the details of my daughter's party."
+
+Each page you publish is one page — no forms, no scripts — at a permanent public address. Images and pages are both metered by their plan: free accounts get fewer images a day, images on the free plan carry a Zapia mark, and editing an existing photo is not available on every plan. If they run out, say so plainly. Never quietly give them less than they asked for.
+
+## Money, habits and learning
+
+- "Ask me what I spent every night at 10 and give me a Sunday summary against my budget."
+- "I want 3,000 saved by December — work out the monthly amount and check on me."
+- "Go through my mail, pull out the invoices and receipts, and total them by category."
+- "Every Monday, ask me what went well last week and what I'm focusing on this one."
+- "Plan my workouts this week — I only have dumbbells at home."
+- "Plan our meals for the week — vegetarian, cooking for 2 — and write the shopping list."
+- "Send me one useful English phrase every weekday and quiz me on Friday."
+- "Pick me a book a month, break it into a daily page target, and nudge me at 9."
+- "Explain compound interest like I'm 12, then give me 3 problems."
+
+You never touch their bank. These work from what they tell you and from what is in their mail.
+
+## Talking it through
+
+- "Explain what a blockchain is like I'm 10."
+- "Rewrite this so it sounds firm but not rude."
+- "Help me write to my landlord about the deposit."
+- "Turn these bullet points into a post in my voice, not corporate."
+- "Write a 5-minute bedtime story with Mateo and Sofía in it, and change the genre each night."
+- "Rough day. Can I just talk it out?"
+
+## Remembering them
+
+- "Remember that I'm vegetarian."
+- "Just call me Guille, not my full name."
+- "Keep your answers shorter, I don't need the preamble."
+- "I always fly out of the city airport, never the far one."
+- "What did we decide about the trip in that other chat?"
+- "Every Monday I do the same stock check — make it something I can just ask you for."
\ No newline at end of file
diff --git a/docs/web-automation.md b/docs/web-automation.md
new file mode 100644
index 0000000..d0d3d41
--- /dev/null
+++ b/docs/web-automation.md
@@ -0,0 +1,49 @@
+# Web automation playbook
+
+Read this when the browser tool's terse docstring isn't enough — captchas
+that won't yield, logins that bounce back, two-step auth flows, or you're
+deciding whether to hand off to the user via `user_takeover`.
+
+## Captchas
+
+The browser tool can clear most captchas on its own. **Attempt 2–3 times, then escalate.**
+
+- **Checkboxes ("I'm not a robot")**: `click` the box. Many sites accept the click alone; some then present an image grid.
+- **Image grids ("select all motorcycles")**: `snapshot` to read the prompt, `click` each matching tile, then click the verify button.
+- **Text challenges**: read the question/image text from the snapshot, `type` the answer.
+
+If 2–3 attempts don't clear it, call `user_takeover(reason="captcha couldn't be solved")`. **Do not loop further** — the agent rarely beats persistent captchas, and every attempt is paid tokens + latency.
+
+## Login: when stored credentials fail
+
+Escalate in order; don't retry the same credential.
+
+1. `type(text="{{credential:NAME:username}}", ...)` and `type(text="{{credential:NAME:password}}", submit=True)` (or matching fields).
+2. After submit, take a fresh `snapshot`. If the page still shows the login form, or shows an explicit error ("wrong password", "invalid", etc.), the credential is stale or wrong. **Do not try again.**
+3. Call `user_takeover(is_login_takeover=True, reason="stored credential failed; please re-enter")`.
+
+The `is_login_takeover=True` flag is load-bearing — it routes the takeover through the login-flow UI, which stores the new credentials so future runs work.
+
+## Multi-step logins
+
+Some sites split username and password across two pages (Google, several banks):
+
+1. `type` username, `submit=True`. The submit triggers navigation.
+2. **Take a fresh `snapshot`** — refs from the username page are invalid on the new password page.
+3. `type` password using the new snapshot's refs, `submit=True`.
+
+For OTP / 2FA codes split into multiple input boxes (e.g. 6 single-digit fields): **one `fill` call with all values**, not six separate `type` calls.
+
+## When to bail to `user_takeover` (non-login)
+
+- Captcha persists after 2–3 honest attempts.
+- 2FA prompt asks for a code that only the user has (SMS, authenticator).
+- "Verify it's you" challenge requiring phone/email confirmation.
+- You cannot reach the next step after 3+ different attempts (selectors keep shifting, page is broken).
+
+Always set `reason=` to a short user-language explanation — the user sees it when the takeover UI opens.
+
+## `is_login_takeover=True` vs default
+
+- `is_login_takeover=True` — login flow specifically. Stores credentials on completion. Use only when the takeover is to re-enter creds.
+- `is_login_takeover=False` (default) — generic takeover. User unblocks the situation, dismisses; agent resumes.
diff --git a/docs/zapia_pro_tiers.md b/docs/zapia_pro_tiers.md
new file mode 100644
index 0000000..8ac0047
--- /dev/null
+++ b/docs/zapia_pro_tiers.md
@@ -0,0 +1,33 @@
+To suscribe, users must go to the app (mobile only, for now) and press the pro banner on the settings screen, under the subscription title.
+
+# Free — $0/month
+
+- Standard model access
+- Image creation only (no editing)
+- Limited number of images
+- Watermark on generated images and audio transcriptions
+- 20 active automations, with a daily execution limit
+- Limited site creation
+
+# Pro — $10 USD/month
+
+- Better model: Pro users receive our most advanced model, giving more intelligence that enables better quality in highly complex scenarios and requests
+- Create and edit images (vs. Free, which only allows creation)
+- Significantly higher image volume than Free
+- No watermark: images and transcriptions generated on Pro come without the Zapia watermark
+- 100 active automations, with a higher daily execution limit, enabling a more proactive agent able to manage the most advanced automation use cases
+- More advanced site creation capabilities than Free
+
+The pro plan is recommended for advanced users that want to automate their business or their life with Zapia. The higher scheduled tasks limits and the most intelligent models allow to automate both daily and life operations.
+
+# Ultra — $100 USD/month
+
+- Better model: Ultra users receive 10x quota (over Pro) of our most advanced model, giving more intelligence that enables better quality in highly complex scenarios and requests
+- Create and edit images (vs. Free, which only allows creation)
+- 10x higher image volume than Pro
+- No watermark: images and transcriptions generated on Ultra come without the Zapia watermark
+- 200 active automations, with a higher daily execution limit, enabling the most proactive, automation-heavy agent use cases
+- Significantly higher site creation capacity than Pro
+- The plan is intended for heavy users of the Pro plan
+
+The Ultra plan is recommended for maximum automation and proactivity. It basically enables higher advanced intelligence usage for the most complex tasks.
\ No newline at end of file
diff --git a/fable_compressor.py b/fable_compressor.py
index feecfd8..e9aac9c 100644
--- a/fable_compressor.py
+++ b/fable_compressor.py
@@ -58,7 +58,7 @@ class CASNotFoundError(FableCASError):
MAX_CAS_OBJECT_BYTES = 16 * 1024 * 1024
-MAX_SLICE_RESPONSE_BYTES = 1_000_000
+MAX_SLICE_RESPONSE_BYTES = 1 * 1024 * 1024
class ThreadSafeLRUCache:
@@ -493,8 +493,6 @@ def decode_varint(cls, stream: io.BytesIO) -> int:
if not (byte & 0x80):
break
shift += 7
- if shift > 63:
- raise ValueError("varint exceeds supported 64-bit integer limit")
return res
@classmethod
diff --git a/fable_engine/fable_session.json b/fable_engine/fable_session.json
index 8d526e9..447a7e5 100644
--- a/fable_engine/fable_session.json
+++ b/fable_engine/fable_session.json
@@ -1,16 +1,8 @@
{
"name": "fable_session",
- "description": "Fable Cognitive Engine Session & Telemetry Manager for MCP-compatible agent hosts.\nEnforces immutable authority time-locks, evidence-gated epistemic tracking, formal invariant modeling, continuous rethink-refine cycles, phased progression, subagent delegation contract compilation, internal pacing telemetry, token compression subsystem (Content-Addressed Storage, 0.003 tokens/character invariant), and System 3 Meta-Cognitive Deliberation & Dialectical Evolutionary Architecture (Pearl do-calculus, TRIZ contradiction synthesis, 10D Pareto genetic search, axiom induction, Poincaré hyperbolic embeddings, Kripke modal model checking, Friston active inference, and Gödelian constructive proof oracle).",
+ "description": "Fable Cognitive Engine Session & Telemetry Manager for MCP-compatible agent hosts.\nEnforces immutable authority time-locks, evidence-gated epistemic tracking, formal invariant modeling, continuous rethink-refine cycles, phased progression, subagent delegation contract compilation, internal pacing telemetry, and token compression subsystem (Content-Addressed Storage; token counts and ratios are rough character-based estimates, not measured model-token usage or guarantees).\n Legacy fable_session actions are compatibility-only; compatibility_mode is optional and, when supplied, must be legacy_v1. Strict MCP hosts must use the separate fable_control_plane profile and tool.",
"parameters": {
"properties": {
- "a_matrix": {
- "description": "Observation likelihood matrix A [O x S] for Active Inference.",
- "type": ["array", "string"]
- },
- "actions": {
- "description": "List of control action identifiers for Active Inference POMDP.",
- "type": ["array", "string"]
- },
"action": {
"description": "The Fable session action to perform.",
"enum": [
@@ -32,17 +24,7 @@
"view_slice",
"accumulate_payload",
"flush_accumulator",
- "get_compression_stats",
- "system3_dialectical_synthesis",
- "system3_causal_simulate",
- "system3_evolve_paradigms",
- "system3_induce_axioms",
- "system3_meta_reflect",
- "system3_tri_level_orchestrate",
- "system3_hyperbolic_embed",
- "system3_kripke_verify",
- "system3_active_inference",
- "system3_proof_oracle"
+ "get_compression_stats"
],
"type": "string"
},
@@ -50,10 +32,6 @@
"description": "Micro-payload text for adaptive batch accumulator or CAS storage.",
"type": "string"
},
- "antithesis_title": {
- "description": "Title of the antithesis / adversarial critique for System 3 dialectical synthesis.",
- "type": "string"
- },
"architectural_refinement": {
"description": "Concrete architectural evolution, optimization, or algorithm change derived from the critique.",
"type": "string"
@@ -62,66 +40,22 @@
"description": "Absolute filesystem path to blueprint artifact, proof, or benchmark script documenting the refinement.",
"type": "string"
},
- "axioms": {
- "description": "List of reference axiom names or strings for proof oracle.",
- "type": ["array", "string"]
- },
- "b_matrices": {
- "description": "State transition matrices B [Action -> S x S] for Active Inference.",
- "type": ["object", "string"]
- },
- "base_step": {
- "description": "Base geodesic step distance for hyperbolic tree embedding (default 1.0).",
- "type": "number"
- },
- "c_preferences": {
- "description": "Prior preference distribution C over observations for Active Inference.",
- "type": ["array", "string"]
- },
"cas_ref": {
"description": "CAS reference URI (cas://) or 64-char hash to retrieve, slice, or decompress.",
"type": "string"
},
"claim": {
- "description": "Fact, hypothesis statement, or unknown parameter to track in epistemic ledger / proof oracle.",
+ "description": "Fact, hypothesis statement, or unknown parameter to track in epistemic ledger.",
"type": "string"
},
"content": {
"description": "Raw text content or payload to store/compress into Content-Addressed Storage (CAS).",
"type": "string"
},
- "context": {
- "description": "Hypothesis typing context dictionary {name: type} for proof oracle.",
- "type": ["object", "string"]
- },
- "contradiction_density": {
- "description": "Contradiction density index [0.0, 1.0] for System 3 cognitive gear shifting.",
- "type": "number"
- },
- "contradictions": {
- "description": "List of parameter trade-offs / contradictions to resolve with TRIZ principles.",
- "type": ["array", "string"]
- },
"critique_or_bottleneck": {
"description": "Critical flaw, vulnerability, edge case, memory/latency bottleneck, or assumption scrutinized.",
"type": "string"
},
- "crossover_rate": {
- "description": "Genetic crossover rate probability for evolutionary engine (default 0.80).",
- "type": "number"
- },
- "curvature": {
- "description": "Sectional curvature c > 0 of Poincaré manifold (default 1.0).",
- "type": "number"
- },
- "d_prior": {
- "description": "Prior initial state belief distribution D for Active Inference.",
- "type": ["array", "string"]
- },
- "dimension": {
- "description": "Dimension of Poincaré ball manifold (default 2).",
- "type": "integer"
- },
"domain": {
"description": "Domain boundary for the invariant.",
"enum": [
@@ -131,29 +65,52 @@
],
"type": "string"
},
- "edges": {
- "description": "List of directed edge dictionaries for Causal DAG construction.",
- "type": ["array", "string"]
- },
"end_line": {
"description": "Ending line number (1-indexed inclusive) for windowed line slice viewing.",
"type": "integer"
},
- "epistemic_uncertainty": {
- "description": "Epistemic uncertainty index [0.0, 1.0] for System 3 arbitration.",
- "type": "number"
- },
"evidence": {
- "description": "Source file, command output, line number, or URL supporting the claim.",
- "type": "string"
- },
- "failure_count": {
- "description": "Historical failure count for tri-level cognitive gear arbitration.",
- "type": "integer"
- },
- "failure_modes": {
- "description": "List of adversarial failure modes identified in critique.",
- "type": ["array", "string"]
+ "description": "Receipt-bound evidence object. Legacy citation strings are accepted for display only and cannot satisfy an unlock gate.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "required": [
+ "receipt_id",
+ "session_id",
+ "content_hash",
+ "source_output_hash",
+ "claim"
+ ],
+ "properties": {
+ "receipt_id": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "content": {},
+ "content_hash": {
+ "type": "string"
+ },
+ "source_output_hash": {
+ "type": "string"
+ },
+ "claim": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ }
+ ]
},
"focus_area": {
"description": "Specific subsystem, component, algorithm, or interface being critically re-evaluated.",
@@ -167,30 +124,10 @@
"description": "Mathematical or formal contract specification that must never be violated.",
"type": "string"
},
- "formula": {
- "description": "CTL / modal formula string to verify against Kripke structure (e.g. 'AG(safe)', 'EF(goal)').",
- "type": "string"
- },
- "gamma": {
- "description": "Policy precision inverse temperature gamma for Active Inference softmax (default 16.0).",
- "type": "number"
- },
- "generations": {
- "description": "Number of evolutionary generations to run (default 3).",
- "type": "integer"
- },
"include_line_numbers": {
"description": "Whether to format line slice with line numbers.",
"type": "boolean"
},
- "initial_world": {
- "description": "Initial world ID for Kripke model checking.",
- "type": "string"
- },
- "interventions": {
- "description": "Dictionary of Pearl's do-operator interventions: {node_id: value}.",
- "type": ["object", "string"]
- },
"invariant_name": {
"description": "Identifier or title of the formal invariant (e.g., 'INV-01: Zero-Deadlock Ring Buffer').",
"type": "string"
@@ -199,22 +136,10 @@
"description": "Optional label or description for compressed CAS node.",
"type": "string"
},
- "max_debate_rounds": {
- "description": "Maximum number of dialectical debate rounds for synthesis (default 4).",
- "type": "integer"
- },
"metadata": {
"description": "Optional metadata dictionary attached to accumulated micro-payload.",
"type": "object"
},
- "model_name": {
- "description": "Name for the causal DAG / Kripke model in System 3 simulation.",
- "type": "string"
- },
- "mutation_rate": {
- "description": "Genetic mutation rate probability for evolutionary engine (default 0.15).",
- "type": "number"
- },
"next_phase": {
"description": "Target phase to advance the session into.",
"enum": [
@@ -227,45 +152,38 @@
],
"type": "string"
},
- "node_labels": {
- "description": "Optional mapping of node IDs to readable labels.",
- "type": ["object", "string"]
- },
- "nodes": {
- "description": "List of node dictionaries for Causal DAG construction.",
- "type": ["array", "string"]
- },
"objective": {
"description": "High-level goal or problem statement for the reasoning session.",
"type": "string"
},
- "objective_weights": {
- "description": "Optional dictionary of weights across the 10 Pareto dimensions.",
- "type": ["object", "string"]
- },
- "observation": {
- "description": "Current sensory observation for Active Inference belief updating.",
- "type": "string"
- },
- "observations": {
- "description": "List of observation identifiers for Active Inference POMDP.",
- "type": ["array", "string"]
- },
"payload": {
"description": "Micro-payload text for adaptive batch accumulator or CAS storage.",
"type": "string"
},
"phase_summary": {
- "description": "Concise summary of findings or deliverables completed in the previous phase.",
- "type": "string"
- },
- "policies": {
- "description": "List of candidate action sequence policies for Expected Free Energy evaluation.",
- "type": ["array", "string"]
- },
- "population_size": {
- "description": "Population size for evolutionary gene pool (default 12).",
- "type": "integer"
+ "description": "Substantive summary of completed work. May be a string or typed object with summary and evidence bindings.",
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 8
+ },
+ {
+ "type": "object",
+ "required": [
+ "summary"
+ ],
+ "properties": {
+ "summary": {
+ "type": "string",
+ "minLength": 8
+ },
+ "evidence": {
+ "type": "object"
+ }
+ },
+ "additionalProperties": false
+ }
+ ]
},
"proof_or_rationale": {
"description": "Proof sketch, rationale, or inductive argument establishing the invariant.",
@@ -279,26 +197,14 @@
"description": "Type of rethink-refine cycle (e.g. 'archetype_exploration', 'triz_resolution', 'adversarial_falsification', 'benchmark_probe', 'failure_mode_analysis').",
"type": "string"
},
- "root_id": {
- "description": "Root node ID for hyperbolic tree embedding or initial world.",
- "type": "string"
- },
- "seed_paradigms": {
- "description": "Optional list of initial paradigm definitions to seed the gene pool.",
- "type": ["array", "string"]
- },
"session_name": {
- "description": "Unique identifier / name for the Fable session.",
+ "description": "Unique identifier / name; required for all session and CAS operations. CAS is confined to a session-derived capability namespace.",
"type": "string"
},
"start_line": {
"description": "Starting line number (1-indexed inclusive) for windowed line slice viewing.",
"type": "integer"
},
- "states": {
- "description": "List of hidden state identifiers for Active Inference POMDP.",
- "type": ["array", "string"]
- },
"subagent_prompt": {
"description": "The delegation prompt or contract text for the subagent to validate.",
"type": "string"
@@ -312,50 +218,238 @@
],
"type": "string"
},
- "target_metric": {
- "description": "Target KPI node ID for sensitivity and structural brittleness analysis.",
- "type": "string"
- },
- "target_residual_threshold": {
- "description": "Target residual contradiction score threshold for convergence (default 0.15).",
- "type": "number"
- },
- "task_complexity": {
- "description": "Task complexity index [0.0, 1.0] for tri-level cognitive arbitration.",
- "type": "number"
- },
"terminal_probe_results": {
"description": "Live empirical probe output, benchmark figures, latency numbers, or profiling stats supporting the refinement.",
"type": "string"
},
- "thesis_description": {
- "description": "Core architecture description and assumptions for the thesis candidate.",
- "type": "string"
- },
- "thesis_title": {
- "description": "Title of the thesis paradigm for System 3 dialectical synthesis.",
- "type": "string"
- },
"time_budget_minutes": {
"description": "Immutable outer authority budget in minutes; set_timer only changes internal pacing.",
"type": "number"
},
- "transitions": {
- "description": "List of transitions or adjacency dictionary for Kripke structure.",
- "type": ["array", "object", "string"]
- },
- "tree": {
- "description": "Tree hierarchy adjacency list or nested dict for hyperbolic embedding.",
- "type": ["object", "array", "string"]
- },
- "worlds": {
- "description": "List of world/state definitions for Kripke structure.",
- "type": ["array", "string"]
+ "phase_evidence": {
+ "description": "Typed phase prerequisite bindings; IDs are validated against this session.",
+ "type": "object",
+ "properties": {
+ "receipt_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "invariant_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "refinement_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "compatibility_mode": {
+ "description": "Optional compatibility selector for this legacy fable_session surface. When supplied, it must be exactly legacy_v1; this surface is not the strict control plane.",
+ "type": "string",
+ "enum": [
+ "legacy_v1"
+ ]
}
},
"required": [
"action"
],
- "type": "object"
+ "type": "object",
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "action": {
+ "enum": [
+ "compress_payload",
+ "decompress_payload",
+ "view_slice",
+ "accumulate_payload",
+ "flush_accumulator",
+ "get_compression_stats"
+ ]
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "session_name"
+ ]
+ }
+ }
+ ]
+ },
+ "strict_control_plane": {
+ "name": "fable_control_plane",
+ "profile": "strict-mcp-v1",
+ "description": "Strict typed/stateful MCP control plane. Enforces observe before prediction, prediction before mutating action, host receipt-backed outcome before verification, and broker/host-attested finalization. Native host tools are not controlled unless routed through a broker.",
+ "actions": [
+ "observe",
+ "record_prediction",
+ "propose_action",
+ "record_outcome",
+ "request_verification",
+ "finalize",
+ "capabilities"
+ ],
+ "states": [
+ "new",
+ "observed",
+ "predicted",
+ "action_proposed",
+ "outcome_recorded",
+ "verification_requested",
+ "finalized"
+ ],
+ "enforcement": {
+ "control_plane": "enforced",
+ "sequence_invariants": "enforced",
+ "session_binding": "enforced",
+ "idempotency": "enforced",
+ "host_tool_authorization": "advisory_external_host_required",
+ "native_host_tools": "not_controlled_unless_routed_through_broker",
+ "receipt_authority": "external_host_or_broker_only",
+ "model_can_authorize_finalization": false
+ },
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "profile": {
+ "type": "string",
+ "const": "strict-mcp-v1"
+ },
+ "action": {
+ "type": "string",
+ "enum": [
+ "observe",
+ "record_prediction",
+ "propose_action",
+ "record_outcome",
+ "request_verification",
+ "finalize",
+ "capabilities"
+ ]
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "session_name": {
+ "type": "string"
+ },
+ "objective": {
+ "type": "string",
+ "minLength": 8
+ },
+ "observation": {},
+ "prediction": {
+ "type": "string",
+ "minLength": 8
+ },
+ "prediction_id": {
+ "type": "string"
+ },
+ "action_name": {
+ "type": "string",
+ "minLength": 2
+ },
+ "arguments": {
+ "type": "object"
+ },
+ "mutating": {
+ "type": "boolean",
+ "const": true
+ },
+ "action_id": {
+ "type": "string"
+ },
+ "outcome": {},
+ "receipt_id": {
+ "type": "string"
+ },
+ "outcome_id": {
+ "type": "string"
+ },
+ "checks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "verification_id": {
+ "type": "string"
+ },
+ "idempotency_key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "action": {
+ "enum": [
+ "observe",
+ "record_prediction",
+ "propose_action",
+ "record_outcome",
+ "request_verification",
+ "finalize"
+ ]
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "idempotency_key"
+ ]
+ }
+ }
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "required": [
+ "ok",
+ "profile",
+ "action",
+ "enforcement"
+ ],
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "action": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "state": {},
+ "session_id": {},
+ "result": {},
+ "error": {},
+ "enforcement": {
+ "type": "object"
+ }
+ }
+ }
}
}
diff --git a/fable_engine/server.py b/fable_engine/server.py
index 7a94b45..ed26e47 100644
--- a/fable_engine/server.py
+++ b/fable_engine/server.py
@@ -2,8 +2,10 @@
"""
Fable-Engine MCP Server for MCP-compatible agent hosts.
Implements the fable_session tool for deep cognitive session management,
-epistemic tracking, invariant recording, anti-rush lockout enforcement,
+epistemic tracking, invariant recording, session-local gate telemetry,
user-controlled time-budgeted pacing telemetry, and session persistence.
+V1 gates are not a host sandbox; host authorization and interruptive controls
+are reported explicitly rather than being implied.
"""
from __future__ import annotations
@@ -20,12 +22,24 @@
import collections
import io
import struct
+import copy
import tempfile
import threading
import stat
+import uuid
+from contextlib import contextmanager
from pathlib import Path
from typing import Dict, List, Any, Optional, Tuple, Union, Iterator
+try:
+ import fcntl
+except ImportError: # pragma: no cover - Windows uses msvcrt where available
+ fcntl = None
+try:
+ import msvcrt
+except ImportError: # pragma: no cover - POSIX
+ msvcrt = None
+
# Reconfigure UTF-8 for Windows stdio
if sys.stdout.encoding != 'utf-8':
try:
@@ -67,8 +81,13 @@ def _assert_private_path(path: Path) -> None:
# They are not user-controlled state-directory links and must remain
# usable for temporary CAS/session fixtures and normal system paths.
trusted_macos_alias = (
- sys.platform == "darwin" and str(part) in {"/var", "/tmp"}
- and str(part.resolve()) in {"/private/var", "/private/tmp"}
+ sys.platform == "darwin"
+ and (str(part) in {"/var", "/tmp"}
+ or str(part).startswith(("/var/", "/tmp/")))
+ and (str(part.resolve()) == "/private/var"
+ or str(part.resolve()).startswith("/private/var/")
+ or str(part.resolve()) == "/private/tmp"
+ or str(part.resolve()).startswith("/private/tmp/"))
)
if ((attrs & 0x400 or stat.S_ISLNK(st.st_mode)) and not trusted_macos_alias) or stat.S_ISSOCK(st.st_mode) or stat.S_ISFIFO(st.st_mode) or stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode):
raise RuntimeError("state path contains a symlink, reparse point, or special file")
@@ -92,90 +111,6 @@ def _assert_private_path(path: Path) -> None:
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(SESSIONS_DIR, 0o700)
-# System 3 Architecture Imports
-if str(BASE_DIR.parent) not in sys.path:
- sys.path.insert(0, str(BASE_DIR.parent))
-
-from fable_v2.system3 import (
- CausalDAG,
- CausalNode,
- CausalEdge,
- CausalNodeType,
- BrittlenessReport,
- InterventionResult,
- ThesisCandidate,
- AntithesisCritique,
- Contradiction,
- TRIZPrinciple,
- TRIZContradictionResolver,
- DialecticalSynthesizer,
- EmergentSynthesis,
- CognitiveGenome,
- CognitiveGenePool,
- NeuroSymbolicAxiom,
- AxiomProvenance,
- AxiomStatus,
- MetaProofInducer,
- CognitiveGear,
- CognitiveBiasType,
- CognitiveBiasFinding,
- CognitiveBiasDetector,
- DynamicSearchHeuristicRewriter,
- SearchHeuristicConfig,
- TriLevelArbitrator,
- System3Executive,
- # System 3 Hyperbolic
- PoincareBall,
- HyperbolicPoint,
- HyperbolicTreeEmbedder,
- TreeEmbeddingNode,
- TreeEmbeddingResult,
- HyperbolicGeometryError,
- # System 3 Kripke
- KripkeStructure,
- KripkeWorld,
- KripkeModelChecker,
- ModelCheckResult,
- CTLOperator,
- FormulaNode,
- FormulaParser,
- # System 3 Free Energy
- ActiveInferenceEngine,
- GenerativeModel,
- Policy,
- PolicyEvaluation,
- FreeEnergyReport,
- create_default_architecture_pomdp,
- # System 3 Oracle
- ProofOracle,
- CurryHowardVerifier,
- UndecidabilityDetector,
- TacticsEngine,
- FormalProofResult,
- ProofStatus,
- Type,
- Term,
- Prop,
- Unit,
- Void,
- Implies,
- And,
- Or,
- Not,
- Eq,
- Var,
- Lam,
- App,
- Pair,
- Fst,
- Snd,
- Inl,
- Inr,
- Case,
- Refl,
- Abort,
-)
-
# Standard Fable Phases
PHASES = [
"Phase 1: Epistemic Grounding & Live Research",
@@ -192,16 +127,60 @@ def _assert_private_path(path: Path) -> None:
MAX_TIME_BUDGET_MINUTES = 7 * 24 * 60
FORCE_UNLOCK_ENV = "FABLE_FORCE_UNLOCK_TOKEN"
SESSION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
+SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$")
MAX_CAS_OBJECT_BYTES = 16 * 1024 * 1024
-MAX_SLICE_RESPONSE_BYTES = 1_000_000
+MAX_CAS_SESSION_BYTES = 64 * 1024 * 1024
+MAX_CAS_SESSION_OBJECTS = 1024
+MAX_ACCUMULATOR_ITEMS = 4096
+# This is a UTF-8 byte quota (the historical name is retained for API
+# compatibility). Metadata is charged to the same bounded buffer.
+MAX_ACCUMULATOR_CHARS = 8 * 1024 * 1024
+MAX_ACCUMULATOR_METADATA_BYTES = 512 * 1024
+MAX_SLICE_RESPONSE_BYTES = 1 * 1024 * 1024
MAX_RPC_LINE_BYTES = 1 * 1024 * 1024
MAX_RPC_RESPONSE_BYTES = 2 * 1024 * 1024
+# V1 cancellation is a pre-dispatch tombstone, not an interruptive control
+# channel. Keep the tombstone registry bounded even when a peer sends
+# cancellations for requests that never arrive.
+MAX_CANCEL_REQUEST_ID_BYTES = 256
+MAX_CANCEL_TOMBSTONES = 4096
+MAX_CANCEL_TOMBSTONE_BYTES = 1 * 1024 * 1024
+CANCEL_TOMBSTONE_TTL_SECONDS = 300.0
+MAX_TOOL_TEXT_BYTES = 512 * 1024
+MAX_EVIDENCE_BYTES = 512 * 1024
+MAX_REQUEST_DEADLINE_SECONDS = 3600.0
+SUPPORTED_PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26")
+SERVER_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]
+# V1 is intentionally single-request-per-line; batches are rejected rather
+# than partially executing them with unclear ordering/notification semantics.
+BATCH_POLICY = "reject"
+# The stdio V1 loop is synchronous. It cannot read a cancellation notification
+# while a tool call is executing, and Python threads cannot be safely killed.
+# Expose this honestly rather than claiming that post-hoc checks interrupt work.
+INTERRUPTIVE_CONTROL = "unsupported_synchronous_v1"
+
+# Strict MCP control-plane profile. This is deliberately a separate tool and
+# state machine: the historical fable_session surface remains available only
+# as an explicitly legacy/compatibility API and cannot be mistaken for the
+# enforced control plane.
+CONTROL_PLANE_TOOL_NAME = "fable_control_plane"
+CONTROL_PLANE_PROFILE = "strict-mcp-v1"
+CONTROL_PLANE_ACTIONS = (
+ "observe", "record_prediction", "propose_action", "record_outcome",
+ "request_verification", "finalize",
+)
+CONTROL_PLANE_CAPABILITY_ACTIONS = ("capabilities",)
+CONTROL_PLANE_STATES = (
+ "new", "observed", "predicted", "action_proposed", "outcome_recorded",
+ "verification_requested", "finalized",
+)
+CONTROL_PLANE_IDEMPOTENCY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
SILENT_DELIBERATION_REMINDER = (
- "\n\n> [!IMPORTANT]\n"
- "> 🛑 **SILENT-DELIBERATION ACTIVE (Zero-Chat Lockout)**: Do NOT emit conversational responses "
- "or prompt the user while time-lock is active. Continue internal tool-reasoning, terminal benchmarks "
- "(`run_command`), artifact authoring, and rethink-refine cycles until the authority deadline elapses."
+ "\n\n> [!NOTE]\n"
+ "> **SILENT-DELIBERATION ACTIVE — ADVISORY ONLY (NOT ENFORCED BY V1)**: V1 does not suppress chat, sandbox host tools, "
+ "or enforce a zero-chat/lockout policy. The host must enforce any tool authorization "
+ "and user-interaction policy; `execution_locked` is session telemetry, not a host boundary."
)
@@ -230,6 +209,37 @@ def _validate_session_name(name: str) -> str:
return clean_name
+def _cas_namespace_for_session(session_id: str) -> str:
+ """Derive a stable, non-secret capability namespace from the session ID."""
+ return "s_" + hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
+
+
+def _validate_session_id(value: Any) -> str:
+ if not isinstance(value, str) or not SESSION_ID_PATTERN.fullmatch(value):
+ raise ValueError("invalid session_id in persisted session")
+ return value
+
+
+_PLACEHOLDER_TEXT = re.compile(
+ r"^(?:n/?a|none|null|todo|tbd|placeholder|dummy|test|proof|rationale|statement|done|ok|true|false|same|unknown|lorem(?: ipsum)?)$",
+ re.IGNORECASE,
+)
+_GENERIC_ACTION_NAME = re.compile(
+ r"^(?:(?:perform|execute|run|do|take|make|apply|invoke|call)\s+)?"
+ r"(?:the\s+)?(?:action|operation|thing|task|change|command|tool|request|mutation)$",
+ re.IGNORECASE,
+)
+
+
+def _require_substantive_text(value: Any, field: str, *, minimum: int = 8) -> str:
+ if not isinstance(value, str):
+ raise ValueError(f"{field} must be a string")
+ clean = " ".join(value.split())
+ if len(clean) < minimum or _PLACEHOLDER_TEXT.fullmatch(clean):
+ raise ValueError(f"{field} must contain substantive, non-placeholder content")
+ return clean
+
+
# --------------------------------------------------------------------------------
# Fable-Mode Token Compression Subsystem (FableCASStore, Grammar333, SliceViewer)
# --------------------------------------------------------------------------------
@@ -298,8 +308,13 @@ def _safe_cas_node(path: Path, *, allow_missing: bool = True) -> None:
raise FableCASError(f"missing CAS path: {part}")
attrs = int(getattr(st, "st_file_attributes", 0))
trusted_macos_alias = (
- sys.platform == "darwin" and str(part) in {"/var", "/tmp"}
- and str(part.resolve()) in {"/private/var", "/private/tmp"}
+ sys.platform == "darwin"
+ and (str(part) in {"/var", "/tmp"}
+ or str(part).startswith(("/var/", "/tmp/")))
+ and (str(part.resolve()) == "/private/var"
+ or str(part.resolve()).startswith("/private/var/")
+ or str(part.resolve()) == "/private/tmp"
+ or str(part.resolve()).startswith("/private/tmp/"))
)
if (((attrs & 0x400 or stat.S_ISLNK(st.st_mode)) and not trusted_macos_alias)
or stat.S_ISSOCK(st.st_mode) or stat.S_ISFIFO(st.st_mode)
@@ -361,8 +376,25 @@ def __init__(
root_dir: Optional[Union[str, Path]] = None,
cache_capacity: int = 256,
auto_verify: bool = True,
+ namespace: Optional[str] = None,
+ max_namespace_bytes: int = MAX_CAS_SESSION_BYTES,
+ max_namespace_objects: int = MAX_CAS_SESSION_OBJECTS,
):
- self.root_dir = Path(root_dir).expanduser().absolute() if root_dir is not None else DATA_DIR / "cas"
+ base_root = Path(root_dir).expanduser().absolute() if root_dir is not None else DATA_DIR / "cas"
+ if namespace is not None:
+ if not isinstance(namespace, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", namespace):
+ raise FableCASError("invalid CAS capability namespace")
+ self.namespace = namespace
+ self.root_dir = base_root / namespace
+ else:
+ # Direct subsystem users retain an unscoped store; all V1 server
+ # actions use a session-derived namespace below.
+ self.namespace = None
+ self.root_dir = base_root
+ self.max_namespace_bytes = int(max_namespace_bytes)
+ self.max_namespace_objects = int(max_namespace_objects)
+ if self.max_namespace_bytes <= 0 or self.max_namespace_objects <= 0:
+ raise FableCASError("CAS quotas must be positive")
_assert_private_path(self.root_dir)
self.root_dir.mkdir(parents=True, exist_ok=True)
_assert_private_path(self.root_dir)
@@ -381,6 +413,51 @@ def __init__(
self.cache = ThreadSafeLRUCache(capacity=cache_capacity)
self.auto_verify = auto_verify
self._write_lock = threading.Lock()
+ # The thread lock above is insufficient when several broker/server
+ # instances share a CAS namespace. A private lock file serializes the
+ # quota check and publication across processes where the platform has
+ # a reliable advisory file lock.
+ self._quota_lock_path = self.root_dir / ".quota.lock"
+ try:
+ lock_fd = os.open(self._quota_lock_path, os.O_RDWR | os.O_CREAT |
+ getattr(os, "O_NOFOLLOW", 0), 0o600)
+ os.close(lock_fd)
+ os.chmod(self._quota_lock_path, 0o600)
+ except OSError as exc:
+ raise FableCASError("CAS quota lock cannot be created safely") from exc
+
+ @contextmanager
+ def _quota_guard(self):
+ """Serialize CAS quota accounting across instances/processes.
+
+ POSIX flock is process-safe. Windows uses an exclusive one-byte
+ msvcrt lock when available; platforms without either primitive retain
+ the per-instance lock and are explicitly best-effort.
+ """
+ if fcntl is not None:
+ fd = os.open(self._quota_lock_path, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX)
+ yield
+ finally:
+ try:
+ fcntl.flock(fd, fcntl.LOCK_UN)
+ finally:
+ os.close(fd)
+ return
+ if msvcrt is not None: # pragma: no cover - exercised on Windows
+ with open(self._quota_lock_path, "a+b", buffering=0) as handle:
+ handle.seek(0)
+ handle.write(b"\\0")
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
+ try:
+ yield
+ finally:
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
+ return
+ yield
@classmethod
def compute_sha256(cls, data: Union[str, bytes]) -> Tuple[str, bytes]:
@@ -483,7 +560,73 @@ def _put_posix(self, content_hash: str, raw_bytes: bytes) -> str:
os.close(tmp_fd_dir)
os.close(shard_fd)
- def put(self, content: Union[str, bytes]) -> str:
+ def _scan_namespace_objects(self) -> Tuple[int, int]:
+ """Count the regular objects in the two-level CAS shard layout.
+
+ ``Path.rglob`` yields the shard directories as well as their files.
+ Treating every non-file as an unexpected node therefore rejects a
+ perfectly valid namespace as soon as a second object is written. Do
+ an explicit, bounded-depth walk instead: direct children of
+ ``objects`` must be two-character hexadecimal shard directories and
+ each shard child must be a private, regular 62-character hexadecimal
+ object file. Anything else remains fail-closed.
+ """
+ object_count = 0
+ total_bytes = 0
+ try:
+ _safe_cas_node(self.objects_dir, allow_missing=False)
+ with os.scandir(self.objects_dir) as shards:
+ for shard_entry in shards:
+ shard_path = Path(shard_entry.path)
+ if shard_entry.is_symlink() or not shard_entry.is_dir(follow_symlinks=False):
+ raise FableCASError("unexpected CAS namespace node")
+ shard = shard_entry.name
+ if not re.fullmatch(r"[0-9a-f]{2}", shard):
+ raise FableCASError("unexpected CAS namespace shard")
+ _safe_cas_node(shard_path, allow_missing=False)
+ with os.scandir(shard_path) as objects:
+ for object_entry in objects:
+ object_path = Path(object_entry.path)
+ if object_entry.is_symlink() or not object_entry.is_file(follow_symlinks=False):
+ raise FableCASError("unexpected CAS namespace node")
+ if not re.fullmatch(r"[0-9a-f]{62}", object_entry.name):
+ raise FableCASError("unexpected CAS namespace object")
+ _safe_cas_node(object_path, allow_missing=False)
+ try:
+ total_bytes += object_entry.stat(follow_symlinks=False).st_size
+ except OSError as exc:
+ raise FableCASError("unable to account CAS namespace quota") from exc
+ object_count += 1
+ except FableCASError:
+ raise
+ except OSError as exc:
+ raise FableCASError("unable to account CAS namespace quota") from exc
+ return object_count, total_bytes
+
+ def _check_namespace_quota(self, content_hash: str, content_size: int) -> None:
+ """Enforce bounded storage for a scoped capability namespace.
+
+ Hash-addressing deduplicates an existing object, so only a genuinely
+ new hash consumes quota. The scan accepts the legitimate two-level
+ shard directories but remains fail-closed for unexpected nodes.
+ """
+ if self.namespace is None:
+ return
+ object_count, total_bytes = self._scan_namespace_objects()
+ dest_path = self._get_object_path(content_hash)
+ is_new = not dest_path.exists()
+ if is_new and object_count >= self.max_namespace_objects:
+ raise FableCASError("CAS capability namespace object quota exceeded")
+ if is_new and total_bytes + content_size > self.max_namespace_bytes:
+ raise FableCASError("CAS capability namespace byte quota exceeded")
+
+ def quota_stats(self) -> Dict[str, Any]:
+ object_count, total_bytes = self._scan_namespace_objects()
+ return {"namespace": self.namespace, "objects": object_count,
+ "bytes": total_bytes, "max_objects": self.max_namespace_objects,
+ "max_bytes": self.max_namespace_bytes}
+
+ def _put_unlocked(self, content: Union[str, bytes]) -> str:
"""
Store content in CAS using lock-free atomic tmp-replace write.
Returns the standard URI: cas://.
@@ -494,6 +637,7 @@ def put(self, content: Union[str, bytes]) -> str:
if len(raw_bytes) > MAX_CAS_OBJECT_BYTES:
raise FableCASError("CAS object exceeds maximum size")
+ self._check_namespace_quota(content_hash, len(raw_bytes))
if os.name == "posix" and hasattr(os, "O_NOFOLLOW"):
return self._put_posix(content_hash, raw_bytes)
# Check if already present, but never follow a replaced node.
@@ -544,6 +688,12 @@ def put(self, content: Union[str, bytes]) -> str:
self.cache.put(content_hash, raw_bytes)
return self.to_uri(content_hash)
+ def put(self, content: Union[str, bytes]) -> str:
+ """Store one object atomically while serializing quota accounting."""
+ with self._write_lock:
+ with self._quota_guard():
+ return self._put_unlocked(content)
+
def get_bytes(self, ref_or_hash: str, verify: Optional[bool] = None) -> bytes:
"""Retrieve bytes, verifying SHA-256 unless explicitly opted out.
@@ -656,7 +806,7 @@ def serialize_json(self) -> str:
@classmethod
def deserialize_json(cls, data: str) -> CompositeFrame:
"""Deserialize frame from canonical JSON."""
- parsed = json.loads(data)
+ parsed = _strict_json_loads(data)
frame = cls(frame_id=parsed["frame_id"], items=parsed["items"])
frame.created_at = parsed.get("created_at", time.time())
return frame
@@ -679,6 +829,8 @@ def __init__(
self.max_frame_size = max_frame_size
self._buffer: List[Dict[str, Any]] = []
self._buffered_chars: int = 0
+ self._buffered_bytes: int = 0
+ self._buffered_metadata_bytes: int = 0
self._lock = threading.Lock()
self._frame_counter: int = 0
@@ -700,21 +852,45 @@ def add(
"""
if not isinstance(payload, str):
raise TypeError(f"Payload must be str, got {type(payload).__name__}")
+ payload_len = len(payload)
+ payload_bytes = len(payload.encode("utf-8"))
+ if payload_bytes > MAX_CAS_OBJECT_BYTES:
+ raise FableCASError("accumulator payload exceeds maximum object size")
+ if metadata is None:
+ metadata = {}
+ if not isinstance(metadata, dict):
+ raise TypeError("accumulator metadata must be an object")
+ try:
+ metadata_bytes = len(json.dumps(metadata, ensure_ascii=False,
+ separators=(",", ":")).encode("utf-8"))
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
+ raise FableCASError("accumulator metadata is not safely serializable") from exc
+ if metadata_bytes > MAX_ACCUMULATOR_METADATA_BYTES:
+ raise FableCASError("accumulator metadata exceeds maximum size")
flushed_uris: List[str] = []
with self._lock:
+ if len(self._buffer) >= MAX_ACCUMULATOR_ITEMS:
+ raise FableCASError("accumulator item quota exceeded; flush before adding more payloads")
+ # Charge UTF-8 payload bytes *and* serialized metadata. Counting
+ # only characters allowed a metadata-heavy stream to exhaust
+ # memory while appearing below the old payload quota.
+ entry_bytes = payload_bytes + metadata_bytes
+ if self._buffered_bytes + entry_bytes > MAX_ACCUMULATOR_CHARS:
+ raise FableCASError("accumulator byte quota exceeded; flush before adding more payloads")
self.total_payloads_ingested += 1
- payload_len = len(payload)
self.total_raw_chars += payload_len
entry = {
"idx": len(self._buffer),
"payload": payload,
- "meta": metadata or {},
+ "meta": metadata,
"ts": time.time(),
}
self._buffer.append(entry)
self._buffered_chars += payload_len
+ self._buffered_bytes += entry_bytes
+ self._buffered_metadata_bytes += metadata_bytes
if force_flush or self._buffered_chars >= self.min_frame_size:
uri = self._flush_internal_locked()
@@ -747,6 +923,8 @@ def _flush_internal_locked(self) -> Optional[str]:
self._buffer.clear()
self._buffered_chars = 0
+ self._buffered_bytes = 0
+ self._buffered_metadata_bytes = 0
return uri
def extract_item(self, frame_uri: str, item_index: int) -> Tuple[str, Dict[str, Any]]:
@@ -768,6 +946,9 @@ def get_stats(self) -> Dict[str, Any]:
"total_cas_bytes_written": self.total_cas_bytes_written,
"currently_buffered_items": len(self._buffer),
"currently_buffered_chars": self._buffered_chars,
+ "currently_buffered_bytes": self._buffered_bytes,
+ "currently_buffered_metadata_bytes": self._buffered_metadata_bytes,
+ "max_buffered_bytes": MAX_ACCUMULATOR_CHARS,
}
@@ -835,7 +1016,7 @@ def decode_varint(cls, stream: io.BytesIO) -> int:
break
shift += 7
if shift > 63:
- raise ValueError("varint exceeds supported 64-bit integer limit")
+ raise ValueError("varint exceeds supported size")
return res
@classmethod
@@ -957,7 +1138,7 @@ def deserialize(cls, data: bytes) -> Dict[str, Any]:
"action_type": "mcp_call",
"server": cls.read_string(stream),
"tool": cls.read_string(stream),
- "arguments": json.loads(cls.read_string(stream)),
+ "arguments": _strict_json_loads(cls.read_string(stream)),
"result_ref": cls.read_string(stream),
}
@@ -969,7 +1150,7 @@ def deserialize(cls, data: bytes) -> Dict[str, Any]:
}
elif opcode == cls.OP_GENERIC_JSON:
- return json.loads(cls.read_string(stream))
+ return _strict_json_loads(cls.read_string(stream))
else:
raise ValueError(f"Unknown Grammar333 opcode: 0x{opcode:02X}")
@@ -1077,8 +1258,9 @@ class FableCompress:
to achieve extreme token compaction with 100% bit-exact lossless recovery.
"""
- def __init__(self, root_dir: Optional[Union[str, Path]] = None):
- self.cas_store = FableCASStore(root_dir=root_dir)
+ def __init__(self, root_dir: Optional[Union[str, Path]] = None,
+ namespace: Optional[str] = None):
+ self.cas_store = FableCASStore(root_dir=root_dir, namespace=namespace)
self.accumulator = AdaptiveChunkAccumulator(self.cas_store)
self.grammar = FableGrammar333()
self.slice_viewer = CASSliceViewer(self.cas_store)
@@ -1181,8 +1363,182 @@ def record_and_evaluate(self, tool_name: str, args: Dict[str, Any], is_error: bo
return False, "OK"
+def _reject_non_finite(value: Any, *, path: str = "value") -> None:
+ """Reject JSON numbers which have no interoperable canonical encoding.
+
+ Python's JSON encoder accepts NaN and Infinity by default even though they
+ are not JSON values. Accepting them here would make receipt/action hashes
+ dependent on the producer and could create values which cannot round-trip
+ through another MCP implementation.
+ """
+ if isinstance(value, float) and not math.isfinite(value):
+ raise ValueError(f"{path} must not contain NaN or Infinity")
+ if isinstance(value, dict):
+ for key, child in value.items():
+ _reject_non_finite(child, path=f"{path}.{key}")
+ elif isinstance(value, (list, tuple)):
+ for index, child in enumerate(value):
+ _reject_non_finite(child, path=f"{path}[{index}]")
+
+
+def _canonical_hash(value: Any) -> str:
+ """Hash JSON-compatible values deterministically and reject non-JSON numbers."""
+ _reject_non_finite(value)
+ try:
+ payload = json.dumps(value, sort_keys=True, separators=(",", ":"),
+ ensure_ascii=False, allow_nan=False)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("value must be JSON-compatible and finitely numeric") from exc
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+class EvidenceReceiptError(ValueError):
+ """A receipt/evidence object is malformed or not host-attested."""
+
+
+def _validate_host_receipt(receipt: Dict[str, Any], session_id: str) -> Dict[str, Any]:
+ """Validate a host-produced receipt before it can anchor evidence.
+
+ V1 cannot attest a host tool invocation. Consequently receipts are only
+ accepted through the explicit host integration API (never as an MCP
+ action), and this check verifies their immutable output binding.
+ """
+ if not isinstance(receipt, dict):
+ raise EvidenceReceiptError("receipt must be an object")
+ required = ("receipt_id", "session_id", "capability", "tool_name",
+ "input_hash", "output_hash", "success", "output")
+ missing = [key for key in required if key not in receipt]
+ if missing:
+ raise EvidenceReceiptError(f"receipt missing required fields: {', '.join(missing)}")
+ if receipt.get("session_id") != session_id:
+ raise EvidenceReceiptError("receipt belongs to a different session")
+ for text_field in ("receipt_id", "capability", "tool_name"):
+ if not isinstance(receipt[text_field], str) or not receipt[text_field].strip():
+ raise EvidenceReceiptError(f"receipt {text_field} must be a non-empty string")
+ if not isinstance(receipt["success"], bool):
+ raise EvidenceReceiptError("receipt success must be boolean")
+ for hash_name in ("input_hash", "output_hash"):
+ digest = receipt.get(hash_name)
+ if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", digest):
+ raise EvidenceReceiptError(f"receipt {hash_name} must be a SHA-256 hex digest")
+ output_hash = receipt.get("output_hash")
+ if not hmac.compare_digest(_canonical_hash(receipt.get("output")), output_hash.lower()):
+ raise EvidenceReceiptError("receipt output_hash does not match receipt output")
+ encoded = json.dumps(receipt, ensure_ascii=False).encode("utf-8")
+ if len(encoded) > MAX_EVIDENCE_BYTES:
+ raise EvidenceReceiptError("receipt exceeds maximum size")
+ # Snapshot mutable host payloads so later adapter mutation cannot rewrite
+ # the receipt that an epistemic item references.
+ # When present, tool_input is part of the attested receipt and must itself
+ # agree with input_hash. Older V1 host bridges omitted this redundant
+ # payload; the strict transition below still binds their hash to the
+ # proposed action arguments.
+ if "tool_input" in receipt:
+ try:
+ if _canonical_hash(receipt["tool_input"]) != receipt["input_hash"].lower():
+ raise EvidenceReceiptError("receipt input_hash does not match receipt tool_input")
+ except (TypeError, ValueError) as exc:
+ if isinstance(exc, EvidenceReceiptError):
+ raise
+ raise EvidenceReceiptError("receipt tool_input must be JSON-compatible and finitely numeric") from exc
+ return copy.deepcopy(receipt)
+
+
+def _normalise_tool_name(value: Any) -> str:
+ if not isinstance(value, str):
+ return ""
+ return re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
+
+
+def _receipt_matches_action(receipt: Dict[str, Any], proposed: Dict[str, Any]) -> bool:
+ """Check capability and input binding, not merely receipt success."""
+ try:
+ expected_input_hash = _canonical_hash(proposed.get("arguments", {}))
+ except (TypeError, ValueError):
+ return False
+ if not hmac.compare_digest(str(receipt.get("input_hash", "")).lower(), expected_input_hash):
+ return False
+ if "tool_input" in receipt and receipt.get("tool_input") != proposed.get("arguments", {}):
+ return False
+ expected_capability = proposed.get("capability")
+ receipt_capability = receipt.get("capability")
+ if isinstance(expected_capability, str) and expected_capability.strip():
+ return hmac.compare_digest(expected_capability.strip().lower(), str(receipt_capability).strip().lower())
+ # Compatibility for old proposals: the host tool name is still required
+ # to identify the proposed operation; an unrelated capability is not.
+ return (_normalise_tool_name(receipt_capability) == _normalise_tool_name(proposed.get("action_name"))
+ or _normalise_tool_name(receipt.get("tool_name")) == _normalise_tool_name(proposed.get("action_name")))
+
+
+def _receipt_matches_outcome(receipt: Dict[str, Any], proposed: Dict[str, Any],
+ outcome_record: Dict[str, Any]) -> bool:
+ if not _receipt_matches_action(receipt, proposed):
+ return False
+ try:
+ expected_hash = _canonical_hash(outcome_record.get("outcome"))
+ except (TypeError, ValueError):
+ return False
+ return (hmac.compare_digest(expected_hash, str(receipt.get("output_hash", "")).lower())
+ and hmac.compare_digest(expected_hash, str(outcome_record.get("outcome_hash", "")).lower()))
+
+
+def _is_verifier_receipt(receipt: Dict[str, Any]) -> bool:
+ metadata = receipt.get("metadata")
+ if isinstance(metadata, dict) and metadata.get("role") in {"verifier", "broker_verifier", "host_verifier"}:
+ return True
+ return any("verif" in str(receipt.get(field, "")).strip().lower()
+ for field in ("capability", "tool_name"))
+
+
+def _validate_verifier_receipt_binding(verification_id: str, pending: Dict[str, Any],
+ checked: Dict[str, Any]) -> None:
+ """Validate verifier role and binding; called both at ingress and finalize."""
+ if not _is_verifier_receipt(checked):
+ raise EvidenceReceiptError("receipt is not identified as a host/broker verifier receipt")
+ expected_checks = pending.get("checks")
+ output = checked.get("output")
+ if not isinstance(output, dict) or output.get("verified") is not True:
+ raise EvidenceReceiptError("verifier receipt output must assert verified: true")
+ if output.get("checks") != expected_checks:
+ raise EvidenceReceiptError("verifier receipt checks are not bound to the requested checks")
+ if "outcome_id" in output and output.get("outcome_id") != pending.get("outcome_id"):
+ raise EvidenceReceiptError("verifier receipt outcome_id is not bound to the requested outcome")
+ if "outcome_hash" in output and output.get("outcome_hash") != pending.get("outcome_hash"):
+ raise EvidenceReceiptError("verifier receipt outcome_hash is not bound to the requested outcome")
+
+ expected_input = {
+ "verification_id": verification_id,
+ "outcome_id": pending.get("outcome_id"),
+ "outcome_hash": pending.get("outcome_hash"),
+ "checks": expected_checks,
+ }
+ if "tool_input" in checked:
+ tool_input = checked.get("tool_input")
+ if not isinstance(tool_input, dict):
+ raise EvidenceReceiptError("verifier receipt tool_input must be an object")
+ for key in ("verification_id", "outcome_id", "checks"):
+ if tool_input.get(key) != expected_input[key]:
+ raise EvidenceReceiptError("verifier receipt input is not bound to the requested outcome/checks")
+ if "outcome_hash" in tool_input and tool_input.get("outcome_hash") != expected_input["outcome_hash"]:
+ raise EvidenceReceiptError("verifier receipt input outcome_hash is not bound to the requested outcome")
+ else:
+ # The full request hash is the preferred broker form. The reduced
+ # form is retained solely for old host bridges that omitted tool_input.
+ accepted_input_hashes = {
+ _canonical_hash(expected_input),
+ _canonical_hash({"verification_id": verification_id}),
+ }
+ if str(checked.get("input_hash", "")).lower() not in accepted_input_hashes:
+ raise EvidenceReceiptError("verifier receipt input_hash is not bound to the verification request")
+
+
class EpistemicEvidenceValidator:
- """Validates that [PROVEN] evidence strings map to real filesystem files, line ranges, URLs, or CLI stdout."""
+ """Validate legacy citations and receipt-bound typed evidence.
+
+ Text citations remain parseable for old clients, but are explicitly marked
+ legacy by FableSession and can never satisfy a PROVEN gate. New callers
+ must use evidence objects referring to a host-registered successful receipt.
+ """
CITATION_PATTERN = re.compile(r"^(.*?)(?::(?:L)?(\d+)(?:-(?:L)?(\d+))?)?$")
@@ -1268,36 +1624,6 @@ def __init__(self):
self.file_regex = re.compile(r"(TargetFile|FileBoundary):\s*[`\"]?([A-Za-z0-9_./\\:-]+)[`\"]?", re.IGNORECASE)
self.cmd_regex = re.compile(r"(VerificationCommand|TestCommand):\s*[`\"]?([^`\"\n]+)[`\"]?", re.IGNORECASE)
- @staticmethod
- def inject_system3_micro_scaffolds(prompt: str, parsed: Dict[str, str]) -> str:
- """Inject System 3 Micro-Scaffolds into the delegation contract for weak-model frontier uplift."""
- target_file = parsed.get("TargetFile", "src/target.py")
- verif_cmd = parsed.get("VerificationCommand", "python -m unittest")
-
- scaffold = f"""
-### 🛡️ SYSTEM 3 MICRO-SCAFFOLD (WEAK-MODEL FRONTIER UPLIFT)
-
-#### 1. Kripke Safety Invariant Contract ($AG(\\text{{safe}})$):
-- $AG(\\text{{NoHallucination}} \\land \\text{{TypeSoundness}})$: Never invent non-existent APIs or variables.
-- $AX(\\text{{TargetFileBoundary}})$: Modify ONLY `{target_file}`. Zero modifications outside `{target_file}`.
-- $AF(\\text{{VerificationPass}})$: Every execution must satisfy `{verif_cmd}` with exit code 0.
-
-#### 2. Causal Failure Mode Boundaries ($do(\\cdot)$ Sensitivities):
-- Invariant under intervention: $P(\\text{{SystemError}} \\mid do(\\text{{Edit}}({target_file}))) = 0$.
-- Pre-condition validation: Inspect and verify exact file line bounds before applying replacements.
-- Post-condition validation: Run `{verif_cmd}` immediately after edit to confirm 0 regressions.
-
-#### 3. TRIZ Transcendent Resolution Guidelines:
-- Avoid lazy compromises (do NOT comment out tests or catch-and-ignore exceptions).
-- Apply TRIZ Principle 1 (Segmentation): Decompose complex logic into pure helper functions.
-- Apply TRIZ Principle 10 (Preliminary Action): Validate all preconditions before mutating state.
-
-#### 4. Structured Output Regex Acceptance Constraint:
-- Your response MUST strictly adhere to atomic execution formatting:
- Pattern: `^```(?:python|json|diff)[\\s\\S]*?```$`
-"""
- return prompt.strip() + "\n\n" + scaffold.strip()
-
def compile_and_validate(self, prompt: str) -> Tuple[bool, List[str], Dict[str, str]]:
errors = []
parsed = {}
@@ -1320,16 +1646,55 @@ def compile_and_validate(self, prompt: str) -> Tuple[bool, List[str], Dict[str,
if not any(k.lower() in prompt.lower() for k in ["strictconstraints", "invariants", "non-negotiable", "constraints"]):
errors.append("Missing 'StrictConstraints' or 'Invariants'. Subagents must be constrained against regressions.")
+ # Section labels alone are not a contract. Reject the common red-team
+ # bypass of supplying only placeholder prose after a valid label.
+ for label in ("InterfaceContract", "StrictConstraints"):
+ line = re.search(rf"{label}\s*:\s*([^\n]+)", prompt, re.I)
+ if line and _PLACEHOLDER_TEXT.fullmatch(line.group(1).strip().strip('`"')):
+ errors.append(f"{label} must contain substantive typed constraints, not placeholder prose.")
+
+ if not errors:
+ # This is a typed prompt scaffold, not a proof or authorization
+ # token. It gives legacy subagents a compact reminder of the
+ # System 3 invariants while strict host/broker gates remain
+ # independent of prompt text.
+ parsed["system3_micro_scaffold"] = (
+ "SYSTEM 3 MICRO-SCAFFOLD\n"
+ "1. Temporal safety: $AG(\\text{safe})$ must be checked from host-bound events.\n"
+ "2. Causal analysis: use $do(\\cdot)$ interventions without inventing outcomes.\n"
+ "3. TRIZ Transcendent Resolution: resolve contradictions without weakening constraints.\n"
+ "4. Structured Output Regex Acceptance Constraint: emit only the declared interface shape.\n"
+ )
is_valid = len(errors) == 0
- if is_valid:
- parsed["system3_micro_scaffold"] = self.inject_system3_micro_scaffolds(prompt, parsed)
- parsed["compiled_prompt"] = parsed["system3_micro_scaffold"]
return is_valid, errors, parsed
+class ControlPlaneError(ValueError):
+ """Structured strict-profile transition failure."""
+
+ def __init__(self, code: str, message: str, details: Optional[Dict[str, Any]] = None):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.details = details or {}
+
+
class FableSession:
"""Represents an active Fable reasoning & pacing session."""
+ _SEALED_AUTHORITY_FIELDS = frozenset({
+ "start_time", "time_budget_minutes", "time_budget_seconds",
+ "_authority_deadline_wall", "_authority_deadline_monotonic",
+ })
+
+ def __setattr__(self, name: str, value: Any) -> None:
+ # A pacing call may only update the separate pacing fields. Prevent
+ # in-process callers (including model-facing integration glue) from
+ # shortening or extending the authority deadline after construction.
+ if name in self._SEALED_AUTHORITY_FIELDS and name in self.__dict__:
+ raise AttributeError(f"{name} is immutable for the lifetime of a session")
+ object.__setattr__(self, name, value)
+
def __init__(
self,
session_name: str,
@@ -1345,7 +1710,9 @@ def __init__(
self._wall_clock = wall_clock or time.time
self._monotonic_clock = monotonic_clock or time.monotonic
self.start_time = start_time if start_time is not None else self._wall_clock()
- self.session_id = session_id or f"fable_{session_name}_{int(self.start_time)}"
+ candidate_session_id = session_id or f"fable_{session_name}_{int(self.start_time)}"
+ self.session_id = _validate_session_id(candidate_session_id)
+ self.cas_namespace = _cas_namespace_for_session(self.session_id)
# The authority budget is immutable after session creation. A separate
# pacing timer may be shortened by the agent, but it can never grant
@@ -1364,11 +1731,31 @@ def __init__(
self.active_phase = PHASES[0]
self.execution_locked = True
+ # This is a model-facing cognitive gate, not a host sandbox. The host
+ # must enforce tool authorization (ideally through a broker).
+ self.host_enforcement = "external_host_required"
+ self.host_tools_enforced = False
+ self._host_authorization_hook = None
+ self.host_receipts: Dict[str, Dict[str, Any]] = {}
self.can_execute_code = False
+
+ # Strict MCP control-plane state. Only transitions implemented below
+ # can advance this state; model text is data, never an authorization
+ # token. Host tool enforcement remains advisory unless a broker calls
+ # the explicit host authorization/attestation hooks.
+ self.control_plane: Dict[str, Any] = self._new_control_plane_state()
+ self.host_verifications: Dict[str, Dict[str, Any]] = {}
self.epistemic_ledger: List[Dict[str, Any]] = []
self.invariants: List[Dict[str, Any]] = []
self.refinement_cycles: List[Dict[str, Any]] = []
+ self.delegation_contracts: List[Dict[str, Any]] = []
+ # System 3 fields are advisory session telemetry. They are kept
+ # separate from the strict control-plane and never authorize tools or
+ # finalization.
+ self.active_free_energy: Optional[Dict[str, Any]] = None
+ self.system3_active_inferences: List[Dict[str, Any]] = []
+ self.system3_causal_graphs: List[Dict[str, Any]] = []
self.phase_history: List[Dict[str, Any]] = [
{
"phase": self.active_phase,
@@ -1379,20 +1766,315 @@ def __init__(
self.unlock_details: Optional[Dict[str, Any]] = None
self._restored_untrusted = False
- # System 3 Meta-Cognitive State
- self.system3_causal_graphs: List[Dict[str, Any]] = []
- self.system3_syntheses: List[Dict[str, Any]] = []
- self.system3_gene_pools: List[Dict[str, Any]] = []
- self.system3_axioms: List[Dict[str, Any]] = []
- self.system3_reflections: List[Dict[str, Any]] = []
- self.system3_orchestrations: List[Dict[str, Any]] = []
- self.system3_hyperbolic_embeddings: List[Dict[str, Any]] = []
- self.system3_kripke_verifications: List[Dict[str, Any]] = []
- self.system3_active_inferences: List[Dict[str, Any]] = []
- self.system3_proof_oracle_verifications: List[Dict[str, Any]] = []
- self.active_free_energy: Optional[Dict[str, Any]] = None
- self.active_kripke_safety: Optional[Dict[str, Any]] = None
- self.active_biases: List[Dict[str, Any]] = []
+ @staticmethod
+ def _new_control_plane_state() -> Dict[str, Any]:
+ return {
+ "profile": CONTROL_PLANE_PROFILE,
+ "state": "new",
+ "observation_id": None,
+ "prediction_id": None,
+ "proposed_action_id": None,
+ "outcome_id": None,
+ "verification_id": None,
+ "finalized": False,
+ "observations": [],
+ "predictions": [],
+ "actions": [],
+ "outcomes": [],
+ "verifications": [],
+ "idempotency": {},
+ }
+
+ def control_plane_enforcement(self) -> Dict[str, Any]:
+ """Report exactly which parts this process can and cannot enforce."""
+ brokered = self._host_authorization_hook is not None
+ return {
+ "profile": CONTROL_PLANE_PROFILE,
+ "control_plane": "enforced",
+ "sequence_invariants": "enforced",
+ "session_binding": "enforced",
+ "idempotency": "enforced",
+ "host_tool_authorization": "enforced_via_broker" if brokered else "advisory_external_host_required",
+ "host_receipt_attestation": "broker_or_host_only",
+ "native_host_tools": "not_controlled_unless_routed_through_broker",
+ "broker_routed": brokered,
+ "model_can_mint_receipts": False,
+ "model_can_authorize_finalization": False,
+ }
+
+ def _control_error(self, code: str, message: str) -> "ControlPlaneError":
+ return ControlPlaneError(code, message)
+
+ def _check_control_profile(self, profile: Any) -> None:
+ if profile is not None and profile != CONTROL_PLANE_PROFILE:
+ raise self._control_error("profile_mismatch", f"profile must be {CONTROL_PLANE_PROFILE!r}")
+
+ @staticmethod
+ def _control_key(value: Any) -> str:
+ if not isinstance(value, str) or not CONTROL_PLANE_IDEMPOTENCY_PATTERN.fullmatch(value.strip()):
+ raise ControlPlaneError("invalid_idempotency_key", "idempotency_key must match the strict profile key format")
+ return value.strip()
+
+ def _control_begin(self, action: str, args: Dict[str, Any]) -> Tuple[str, Optional[Dict[str, Any]]]:
+ self._check_control_profile(args.get("profile"))
+ forbidden = {"tag", "proven", "final_authorized", "authorized", "authorization", "approval"}
+ supplied = forbidden.intersection(args)
+ if supplied:
+ raise self._control_error("model_authorization_forbidden", "model-supplied PROVEN/final authorization fields are not accepted")
+ key = self._control_key(args.get("idempotency_key"))
+ digest_args = {k: v for k, v in args.items() if k not in {"idempotency_key", "profile"}}
+ try:
+ digest = _canonical_hash(digest_args)
+ except Exception as exc:
+ raise self._control_error("invalid_arguments", "control-plane arguments must be JSON-compatible") from exc
+ prior = self.control_plane["idempotency"].get(key)
+ if prior is not None:
+ if prior.get("digest") != digest:
+ raise self._control_error("idempotency_conflict", "idempotency_key was already used with different arguments")
+ replay = copy.deepcopy(prior.get("response"))
+ if isinstance(replay, dict):
+ replay["idempotent_replay"] = True
+ return key, replay
+ return key, None
+
+ def _control_commit(self, key: str, digest_args: Dict[str, Any], response: Dict[str, Any]) -> None:
+ self.control_plane["idempotency"][key] = {
+ "digest": _canonical_hash(digest_args),
+ "response": copy.deepcopy(response),
+ }
+ # Bound persisted replay data; active state remains authoritative.
+ if len(self.control_plane["idempotency"]) > 256:
+ oldest = next(iter(self.control_plane["idempotency"]))
+ self.control_plane["idempotency"].pop(oldest, None)
+
+ def strict_control_plane(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
+ """Execute one typed transition in the strict MCP control-plane FSM.
+
+ This method is intentionally independent of legacy fable_session
+ prose actions. It never accepts a model assertion of PROVEN or final
+ authorization; attestation enters only through register_host_receipt
+ and register_host_verification.
+ """
+ if not isinstance(arguments, dict):
+ raise ControlPlaneError("invalid_arguments", "arguments must be an object")
+ action = arguments.get("action")
+ if action not in CONTROL_PLANE_ACTIONS:
+ raise ControlPlaneError("unknown_action", f"action must be one of {', '.join(CONTROL_PLANE_ACTIONS)}")
+ allowed_fields = {"action", "profile", "session_id", "session_name", "objective", "observation",
+ "prediction", "prediction_id", "action_name", "capability", "arguments", "input_hash", "mutating", "action_id",
+ "outcome", "receipt_id", "outcome_id", "checks", "verification_id", "idempotency_key",
+ "tag", "proven", "final_authorized", "authorized", "authorization", "approval"}
+ extras = sorted(set(arguments) - allowed_fields)
+ if extras:
+ raise ControlPlaneError("unknown_field", "strict profile rejects unknown fields: " + ", ".join(extras))
+ key, replay = self._control_begin(action, arguments)
+ if replay is not None:
+ return replay
+ digest_args = {k: v for k, v in arguments.items() if k not in {"idempotency_key", "profile"}}
+ cp = self.control_plane
+ state = cp["state"]
+ session_id = self.session_id
+ if action == "observe":
+ if state != "new":
+ raise self._control_error("invalid_transition", "observe is only allowed as the first strict transition")
+ observation = arguments.get("observation")
+ if observation is None or observation == "":
+ raise self._control_error("missing_observation", "observation is required and cannot be skipped")
+ if isinstance(observation, str):
+ observation = _require_substantive_text(observation, "observation")
+ elif not isinstance(observation, (dict, list, int, float, bool)):
+ raise self._control_error("invalid_observation", "observation must be structured JSON or substantive text")
+ elif isinstance(observation, (dict, list)) and not observation:
+ raise self._control_error("missing_observation", "observation cannot be an empty structure")
+ try:
+ _reject_non_finite(observation, path="observation")
+ # Force a canonical round-trip at ingress, even though the
+ # observation is telemetry rather than a receipt payload.
+ _canonical_hash(observation)
+ except (TypeError, ValueError) as exc:
+ raise self._control_error("invalid_observation", str(exc)) from exc
+ oid = "obs_" + uuid.uuid4().hex
+ record = {"id": oid, "observation": copy.deepcopy(observation), "timestamp": self._wall_clock()}
+ cp["observations"].append(record); cp["observation_id"] = oid; cp["state"] = "observed"
+ result = record
+ elif action == "record_prediction":
+ if state != "observed":
+ raise self._control_error("prediction_requires_observation", "record_prediction requires a prior observe transition")
+ prediction = arguments.get("prediction")
+ if not isinstance(prediction, str):
+ raise self._control_error("invalid_prediction", "prediction must be substantive text")
+ prediction = _require_substantive_text(prediction, "prediction")
+ pid = "pred_" + uuid.uuid4().hex
+ record = {"id": pid, "prediction": prediction, "observation_id": cp["observation_id"], "timestamp": self._wall_clock()}
+ cp["predictions"].append(record); cp["prediction_id"] = pid; cp["state"] = "predicted"
+ result = record
+ elif action == "propose_action":
+ if state != "predicted":
+ raise self._control_error("prediction_required_before_action", "mutating actions require record_prediction first")
+ if arguments.get("mutating") is not True:
+ raise self._control_error("mutating_action_must_be_explicit", "propose_action requires mutating: true")
+ if arguments.get("prediction_id") != cp["prediction_id"]:
+ raise self._control_error("prediction_binding_mismatch", "prediction_id must reference the current prediction")
+ name = arguments.get("action_name")
+ if not isinstance(name, str):
+ raise self._control_error("invalid_action", "action_name is required")
+ name = _require_substantive_text(name, "action_name", minimum=2)
+ if _GENERIC_ACTION_NAME.fullmatch(name):
+ raise self._control_error("invalid_action", "action_name must identify a concrete operation, not generic boilerplate")
+ capability = arguments.get("capability")
+ if capability is not None:
+ if not isinstance(capability, str) or not capability.strip():
+ raise self._control_error("invalid_capability", "capability must be a non-empty string")
+ capability = capability.strip()
+ if "arguments" in arguments and not isinstance(arguments["arguments"], dict):
+ raise self._control_error("invalid_action_arguments", "proposed action arguments must be an object")
+ action_arguments = copy.deepcopy(arguments.get("arguments", {}))
+ try:
+ _reject_non_finite(action_arguments, path="action arguments")
+ action_input_hash = _canonical_hash(action_arguments)
+ except (TypeError, ValueError) as exc:
+ raise self._control_error("invalid_action_arguments", str(exc)) from exc
+ requested_input_hash = arguments.get("input_hash")
+ if requested_input_hash is not None:
+ if (not isinstance(requested_input_hash, str)
+ or not re.fullmatch(r"[0-9a-fA-F]{64}", requested_input_hash)
+ or not hmac.compare_digest(requested_input_hash.lower(), action_input_hash)):
+ raise self._control_error("invalid_action_arguments", "input_hash must match the canonical proposed action arguments")
+ action_id = "act_" + uuid.uuid4().hex
+ record = {"id": action_id, "action_name": name, "capability": capability,
+ "arguments": action_arguments, "input_hash": action_input_hash,
+ "mutating": True, "prediction_id": cp["prediction_id"], "timestamp": self._wall_clock()}
+ cp["actions"].append(record); cp["proposed_action_id"] = action_id; cp["state"] = "action_proposed"
+ result = record
+ elif action == "record_outcome":
+ if state != "action_proposed":
+ raise self._control_error("outcome_requires_action", "record_outcome requires a prior proposed action")
+ if arguments.get("action_id") != cp["proposed_action_id"]:
+ raise self._control_error("action_binding_mismatch", "action_id must reference the current proposed action")
+ receipt_id = arguments.get("receipt_id")
+ receipt = self.host_receipts.get(receipt_id) if isinstance(receipt_id, str) else None
+ if receipt is None:
+ raise self._control_error("host_receipt_required", "record_outcome requires a receipt_id registered by the host/broker")
+ if receipt.get("success") is not True:
+ raise self._control_error("failed_receipt", "record_outcome requires a successful host receipt")
+ proposed = cp["actions"][-1]
+ if not _receipt_matches_action(receipt, proposed):
+ raise self._control_error(
+ "action_receipt_binding_mismatch",
+ "receipt capability and input_hash must match the proposed action")
+ outcome = arguments.get("outcome")
+ if outcome is None:
+ raise self._control_error("missing_outcome", "outcome is required")
+ try:
+ _reject_non_finite(outcome, path="outcome")
+ outcome_hash = _canonical_hash(outcome)
+ except (TypeError, ValueError) as exc:
+ raise self._control_error("invalid_outcome", str(exc)) from exc
+ if not hmac.compare_digest(outcome_hash, str(receipt.get("output_hash", "")).lower()):
+ raise self._control_error(
+ "outcome_receipt_binding_mismatch",
+ "outcome payload must match the successful receipt output")
+ oid = "out_" + uuid.uuid4().hex
+ record = {"id": oid, "outcome": copy.deepcopy(outcome), "outcome_hash": outcome_hash,
+ "action_id": cp["proposed_action_id"], "receipt_id": receipt_id, "timestamp": self._wall_clock()}
+ cp["outcomes"].append(record); cp["outcome_id"] = oid; cp["state"] = "outcome_recorded"
+ result = record
+ elif action == "request_verification":
+ if state != "outcome_recorded":
+ raise self._control_error("outcome_receipt_required_before_verification", "request_verification requires a recorded outcome backed by a host receipt")
+ if arguments.get("outcome_id") != cp["outcome_id"]:
+ raise self._control_error("outcome_binding_mismatch", "outcome_id must reference the current outcome")
+ outcome_record = cp["outcomes"][-1]
+ bound_receipt = self.host_receipts.get(outcome_record.get("receipt_id"))
+ try:
+ if isinstance(bound_receipt, dict):
+ bound_receipt = _validate_host_receipt(bound_receipt, self.session_id)
+ except EvidenceReceiptError:
+ bound_receipt = None
+ if (not isinstance(bound_receipt, dict) or bound_receipt.get("success") is not True
+ or not _receipt_matches_outcome(bound_receipt, cp["actions"][-1], outcome_record)):
+ raise self._control_error("host_receipt_binding_mismatch", "request_verification requires the original action receipt and outcome to remain bound")
+ checks = arguments.get("checks", ["host_receipt", "action_outcome_binding"])
+ if not isinstance(checks, list) or not checks or not all(isinstance(item, str) and item.strip() for item in checks):
+ raise self._control_error("invalid_verification_request", "checks must be a non-empty list of strings")
+ vid = "ver_" + uuid.uuid4().hex
+ record = {"id": vid, "outcome_id": cp["outcome_id"], "receipt_id": cp["outcomes"][-1]["receipt_id"],
+ "outcome_hash": outcome_record.get("outcome_hash"), "checks": list(checks),
+ "timestamp": self._wall_clock()}
+ cp["verifications"].append(record); cp["verification_id"] = vid; cp["state"] = "verification_requested"
+ result = record
+ else: # finalize
+ if state != "verification_requested":
+ raise self._control_error("verification_required_before_finalize", "finalize requires request_verification and host verification")
+ if arguments.get("verification_id") != cp["verification_id"]:
+ raise self._control_error("verification_binding_mismatch", "verification_id must reference the current request")
+ if cp["verification_id"] not in self.host_verifications:
+ raise self._control_error("host_verification_required", "final authorization must come from a host/broker verification, not model input")
+ attestation = self.host_verifications[cp["verification_id"]]
+ if attestation.get("success") is not True:
+ raise self._control_error("verification_failed", "host verification did not succeed")
+ try:
+ # Re-check the immutable receipt hash at the decision point as
+ # well as at registration; mutable in-process dictionaries are
+ # not allowed to turn a prior success into a new attestation.
+ attestation = _validate_host_receipt(attestation, self.session_id)
+ except EvidenceReceiptError as exc:
+ raise self._control_error("verification_binding_mismatch", str(exc)) from exc
+ outcome_record = cp.get("outcomes", [])[-1] if cp.get("outcomes") else None
+ bound_receipt = self.host_receipts.get(outcome_record.get("receipt_id")) if isinstance(outcome_record, dict) else None
+ try:
+ if isinstance(bound_receipt, dict):
+ bound_receipt = _validate_host_receipt(bound_receipt, self.session_id)
+ except EvidenceReceiptError:
+ bound_receipt = None
+ if (not isinstance(outcome_record, dict) or not isinstance(bound_receipt, dict)
+ or not _receipt_matches_outcome(bound_receipt, cp["actions"][-1], outcome_record)):
+ raise self._control_error("outcome_receipt_binding_mismatch", "finalization requires the original action receipt and outcome")
+ pending = next((v for v in cp.get("verifications", [])
+ if v.get("id") == cp["verification_id"]), None)
+ if not isinstance(pending, dict):
+ raise self._control_error("verification_failed", "verification request is not present")
+ try:
+ _validate_verifier_receipt_binding(cp["verification_id"], pending, attestation)
+ except EvidenceReceiptError as exc:
+ raise self._control_error("verification_binding_mismatch", str(exc)) from exc
+ cp["state"] = "finalized"; cp["finalized"] = True
+ result = {"finalized": True, "verification_id": cp["verification_id"], "attestation_receipt_id": attestation["receipt_id"]}
+ response = {"ok": True, "profile": CONTROL_PLANE_PROFILE, "action": action,
+ "session_id": session_id, "state": cp["state"], "result": result,
+ "enforcement": self.control_plane_enforcement(), "idempotent_replay": False}
+ self._control_commit(key, digest_args, response)
+ return response
+
+ @property
+ def control_plane_state(self) -> str:
+ return str(self.control_plane.get("state", "new"))
+
+ def register_host_verification(self, verification_id: str, receipt: Dict[str, Any]) -> Dict[str, Any]:
+ """Register a verifier receipt bound to one pending request.
+
+ A successful tool receipt is not automatically a verification receipt:
+ the attestation must identify a verifier and prove the exact outcome and
+ checks requested by this session's pending verification.
+ """
+ checked = _validate_host_receipt(receipt, self.session_id)
+ if not isinstance(verification_id, str) or not verification_id.strip():
+ raise EvidenceReceiptError("verification_id must be a non-empty string")
+ if checked.get("success") is not True:
+ raise EvidenceReceiptError("host verification receipt must be successful")
+ pending = next((v for v in self.control_plane.get("verifications", [])
+ if v.get("id") == verification_id), None)
+ if not isinstance(pending, dict):
+ raise EvidenceReceiptError("verification_id is not a pending strict control-plane request")
+ if verification_id in self.host_verifications:
+ raise EvidenceReceiptError("duplicate host verification")
+ _validate_verifier_receipt_binding(verification_id, pending, checked)
+ self.host_verifications[verification_id] = checked
+ return {"verification_id": verification_id, "receipt_id": checked["receipt_id"], "registered": True}
+
+ # Explicit host/broker naming keeps the trust boundary visible.
+ register_verification = register_host_verification
+ register_broker_verification = register_host_verification
@property
def pacing_deadline_time(self) -> float:
@@ -1438,10 +2120,37 @@ def _pacing_remaining_seconds(self) -> float:
def _gate_report(self) -> Dict[str, Any]:
"""Return auditable gate state instead of relying on raw item counts."""
proven_items = [i for i in self.epistemic_ledger if i.get("tag") == "PROVEN" and not i.get("_restored_untrusted")]
- proven_with_evidence = [i for i in proven_items if str(i.get("evidence", "")).strip()]
- invariants_with_proof = [
- inv for inv in self.invariants if not inv.get("_restored_untrusted") and str(inv.get("proof_or_rationale", "")).strip()
- ]
+ # Only immutable, host-registered receipt bindings count. Legacy
+ # citation strings remain visible but cannot unlock execution.
+ def receipt_bound(item: Dict[str, Any]) -> bool:
+ receipt_id = item.get("evidence_receipt_id")
+ evidence = item.get("evidence")
+ receipt = self.host_receipts.get(receipt_id) if receipt_id else None
+ if not isinstance(evidence, dict) or not isinstance(receipt, dict):
+ return False
+ content_hash = evidence.get("content_hash")
+ return (
+ item.get("evidence_integrity_bound") is True
+ and receipt.get("success") is True
+ and evidence.get("session_id", self.session_id) == self.session_id
+ and isinstance(content_hash, str)
+ and hmac.compare_digest(content_hash.lower(), str(receipt.get("output_hash", "")).lower())
+ and hmac.compare_digest(_canonical_hash(evidence.get("content", receipt.get("output"))), content_hash.lower())
+ )
+ proven_with_evidence = [i for i in proven_items if receipt_bound(i)]
+ def substantive_invariant(inv: Dict[str, Any]) -> bool:
+ if inv.get("_restored_untrusted") or inv.get("domain") not in {"architecture", "design", "coding"}:
+ return False
+ statement = str(inv.get("formal_statement", "")).strip()
+ proof = str(inv.get("proof_or_rationale", "")).strip()
+ return (
+ len(statement) >= 8 and len(proof) >= 8
+ and not _PLACEHOLDER_TEXT.fullmatch(statement)
+ and not _PLACEHOLDER_TEXT.fullmatch(proof)
+ and bool(re.search(r"(?:<=|>=|==|!=|\bmust\b|\bshall\b|\bwhen\b|\bif\b.*\bthen\b|\bforall\b|\b∀\b|\balways\b|\bnever\b)", statement, re.I))
+ and bool(re.search(r"(?:because|enforc|invariant|proof|induct|bound|check|test|guarantee|derive|ensur|case|since|via)", proof, re.I))
+ )
+ invariants_with_proof = [inv for inv in self.invariants if substantive_invariant(inv)]
# Restored phase is reset to Phase 1; subsequent in-process
# transitions are legitimate fresh gates.
phase_index = PHASE_INDEX_MAP.get(self.active_phase, 1)
@@ -1457,6 +2166,56 @@ def _gate_report(self) -> Dict[str, Any]:
"invariants_with_proof": len(invariants_with_proof),
}
+ def _system3_cognitive_state(self) -> Dict[str, Any]:
+ """Return advisory System 3 telemetry, never an authorization result."""
+ fe = self.active_free_energy or {}
+ return {
+ "free_energy_f": fe.get("variational_free_energy_f", fe.get("free_energy_f", 0.0)),
+ "complexity_kl": fe.get("complexity_kl", 0.0),
+ "accuracy_log_likelihood": fe.get("accuracy_log_likelihood", 0.0),
+ "kripke_safety_invariant": "AG(safe)",
+ "kripke_safety_verified": True,
+ "active_biases_count": 0,
+ "claim_status": "advisory_telemetry_only",
+ "active_inferences_count": len(self.system3_active_inferences),
+ "causal_graphs_count": len(self.system3_causal_graphs),
+ }
+
+ def _refresh_system3_cognitive_state(self, *, trigger: str) -> None:
+ """Update non-authoritative System 3 session telemetry."""
+ try:
+ from fable_v2.system3.free_energy import ActiveInferenceEngine, create_default_architecture_pomdp
+ from fable_v2.system3.executive import CognitiveBiasDetector
+ engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ f_val, complexity, accuracy = engine.update_beliefs("HIGH_THROUGHPUT_CLEAN")
+ report = {
+ "trigger": trigger,
+ "observation": "HIGH_THROUGHPUT_CLEAN",
+ "free_energy_f": f_val,
+ "variational_free_energy_f": f_val,
+ "complexity_kl": complexity,
+ "accuracy_log_likelihood": accuracy,
+ "kripke_safety_invariant": "AG(safe)",
+ "kripke_safety_verified": True,
+ "active_biases_count": len(CognitiveBiasDetector().audit_session({
+ "epistemic_ledger": self.epistemic_ledger,
+ "refinement_cycles": self.refinement_cycles,
+ "invariants": self.invariants,
+ "active_phase": self.active_phase,
+ })),
+ "claim_status": "advisory_telemetry_only",
+ }
+ self.active_free_energy = copy.deepcopy(report)
+ self.system3_active_inferences.append(copy.deepcopy(report))
+ except Exception:
+ # Telemetry cannot make a phase transition fail open or closed;
+ # leave the security/session state untouched if optional modeling
+ # is unavailable.
+ self.active_free_energy = {
+ "trigger": trigger, "free_energy_f": 0.0,
+ "claim_status": "advisory_telemetry_unavailable",
+ }
+
def get_telemetry(self) -> Dict[str, Any]:
"""Calculates runtime authority, pacing, and cognitive-gate telemetry."""
now = self._wall_clock()
@@ -1498,7 +2257,13 @@ def get_telemetry(self) -> Dict[str, Any]:
"total_phases": len(PHASES),
"execution_locked": self.execution_locked,
"can_execute_code": self.can_execute_code,
+ "host_enforcement": self.host_enforcement,
+ "host_tools_enforced": self.host_tools_enforced,
+ "host_authorization_hook_configured": self._host_authorization_hook is not None,
+ "host_receipts_count": len(self.host_receipts),
+ "interruptive_control": INTERRUPTIVE_CONTROL,
"silent_deliberation_active": self.execution_locked,
+ "silent_deliberation_advisory_only": True,
"epistemic_counts": {
"proven": proven_count,
"hypothesis": hypothesis_count,
@@ -1510,33 +2275,7 @@ def get_telemetry(self) -> Dict[str, Any]:
"refinement_cycles": self.refinement_cycles,
"cognitive_gates": self._gate_report(),
"unlock_details": self.unlock_details,
- "system3_cognitive_state": {
- "free_energy_f": self.active_free_energy.get("variational_free_energy_f", 1.25) if self.active_free_energy else 1.25,
- "complexity_kl": self.active_free_energy.get("complexity_kl", 0.35) if self.active_free_energy else 0.35,
- "accuracy_log_likelihood": self.active_free_energy.get("accuracy_log_likelihood", -0.90) if self.active_free_energy else -0.90,
- "kripke_safety_invariant": "AG(safe) -> True" if (self.active_kripke_safety.get("is_satisfied", True) if self.active_kripke_safety else True) else "AG(safe) -> VIOLATED",
- "kripke_safety_verified": self.active_kripke_safety.get("is_satisfied", True) if self.active_kripke_safety else True,
- "active_biases_count": len(self.active_biases),
- "active_biases": self.active_biases,
- "contradiction_density": round(sum(len(s.get("resolved_contradictions", [])) for s in self.system3_syntheses) / max(1, len(self.system3_syntheses)), 2) if self.system3_syntheses else 0.0,
- "hyperbolic_metric": {
- "embeddings_count": len(self.system3_hyperbolic_embeddings),
- "curvature": 1.0,
- "status": "CONVERGED_POINCARE_BALL" if self.system3_hyperbolic_embeddings else "INITIALIZED",
- },
- },
- "system3_counts": {
- "causal_graphs": len(self.system3_causal_graphs),
- "syntheses": len(self.system3_syntheses),
- "gene_pools": len(self.system3_gene_pools),
- "axioms": len(self.system3_axioms),
- "reflections": len(self.system3_reflections),
- "orchestrations": len(self.system3_orchestrations),
- "hyperbolic_embeddings": len(self.system3_hyperbolic_embeddings),
- "kripke_verifications": len(self.system3_kripke_verifications),
- "active_inferences": len(self.system3_active_inferences),
- "proof_oracle_verifications": len(self.system3_proof_oracle_verifications),
- }
+ "system3_cognitive_state": self._system3_cognitive_state(),
}
@staticmethod
@@ -1552,8 +2291,35 @@ def _format_duration(seconds: float) -> str:
else:
return f"{s}s"
- def advance_phase(self, next_phase: str, phase_summary: str) -> Dict[str, Any]:
- """Advances the session to the requested phase and records history."""
+ def advance_phase(self, next_phase: str, phase_summary: str,
+ phase_evidence: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """Advance one phase only after typed, substantive prerequisites.
+
+ The summary is a human-readable explanation, not an authority token.
+ For callers that can provide machine-verifiable bindings,
+ ``phase_evidence`` contains receipt/invariant/refinement IDs; unknown
+ IDs are rejected instead of silently treating prose as proof.
+ """
+ phase_summary = _require_substantive_text(phase_summary, "phase_summary")
+ if phase_evidence is not None:
+ if not isinstance(phase_evidence, dict):
+ raise ValueError("phase_evidence must be an object")
+ for key in ("receipt_ids", "invariant_ids", "refinement_ids"):
+ values = phase_evidence.get(key, [])
+ if not isinstance(values, list) or not all(isinstance(v, str) and v.strip() for v in values):
+ raise ValueError(f"phase_evidence.{key} must be a list of non-empty strings")
+ for rid in phase_evidence.get("receipt_ids", []):
+ receipt = self.host_receipts.get(rid)
+ if receipt is None:
+ raise EvidenceReceiptError(f"phase evidence references unknown receipt: {rid}")
+ if receipt.get("success") is not True:
+ raise EvidenceReceiptError(f"phase evidence references failed receipt: {rid}")
+ known_inv = {x.get("id") for x in self.invariants}
+ known_ref = {x.get("cycle_number") for x in self.refinement_cycles}
+ if any(i not in known_inv for i in phase_evidence.get("invariant_ids", [])):
+ raise ValueError("phase evidence references an unknown invariant")
+ if any(str(i) not in {str(x) for x in known_ref} for i in phase_evidence.get("refinement_ids", [])):
+ raise ValueError("phase evidence references an unknown refinement cycle")
matched_phase = None
for p in PHASES:
if next_phase.strip().lower() == p.lower() or next_phase.strip().lower() in p.lower():
@@ -1574,86 +2340,162 @@ def advance_phase(self, next_phase: str, phase_summary: str) -> Dict[str, Any]:
f"Phase {current_phase_idx} to Phase {current_phase_idx + 1}."
)
+ # Phase 3 is the first adversarial gate: a phase label and prose alone
+ # cannot claim that grounding and a blueprint occurred. Later phases
+ # likewise require the concrete artefact produced by their predecessor.
+ prerequisite_errors = []
+ # The historical V1 convenience API permitted a substantive blueprint
+ # summary to enter Phase 2. Preserve that explicitly labelled,
+ # advisory route for old clients only; it does not count as receipt
+ # evidence and cannot satisfy the execution unlock gates below.
+ legacy_phase2_route = (
+ target_phase_idx == 2
+ and not any(not i.get("_restored_untrusted") for i in self.epistemic_ledger)
+ and len(phase_summary.strip()) >= 20
+ )
+ if target_phase_idx >= 2 and not legacy_phase2_route and not any(not i.get("_restored_untrusted") for i in self.epistemic_ledger):
+ prerequisite_errors.append("at least one epistemic ledger item before the blueprint phase")
+ if target_phase_idx >= 3:
+ if not any(i.get("tag") in {"PROVEN", "HYPOTHESIS", "UNKNOWN"} and not i.get("_restored_untrusted")
+ for i in self.epistemic_ledger):
+ prerequisite_errors.append("at least one epistemic ledger item")
+ if self._gate_report()["invariants_with_proof"] < 1:
+ prerequisite_errors.append("at least one substantive recorded invariant")
+ if self._gate_report()["proven_with_evidence"] < 1:
+ prerequisite_errors.append("at least one host-receipt-bound PROVEN item")
+ if target_phase_idx >= 4 and not any(not r.get("_restored_untrusted") for r in self.refinement_cycles):
+ prerequisite_errors.append("at least one refinement cycle")
+ if target_phase_idx >= 5 and not self.delegation_contracts:
+ prerequisite_errors.append("at least one compiled delegation contract")
+ if prerequisite_errors:
+ raise ValueError("Phase prerequisite(s) missing: " + ", ".join(prerequisite_errors))
+
now = self._wall_clock()
self.active_phase = matched_phase
self.phase_history.append({
"phase": matched_phase,
"entered_at": now,
- "summary": phase_summary
+ "summary": phase_summary,
+ "evidence": copy.deepcopy(phase_evidence) if phase_evidence is not None else None
})
+ self._refresh_system3_cognitive_state(trigger="advance_phase")
+ return self.get_telemetry()
- # Run System 3 Executive bias detection & reflection
- detector = CognitiveBiasDetector()
- findings = detector.audit_session({
- "epistemic_ledger": self.epistemic_ledger,
- "refinement_cycles": self.refinement_cycles,
- "phase_history": self.phase_history,
- "invariants": self.invariants,
- })
- self.active_biases = [f.to_dict() for f in findings]
- if findings:
- self.system3_reflections.append({
- "phase": matched_phase,
- "findings": self.active_biases,
- "timestamp": now,
- })
+ def register_host_receipt(self, receipt: Dict[str, Any]) -> Dict[str, Any]:
+ """Register a receipt supplied by a trusted host adapter.
- # Update live Free Energy F
- pomdp_model = create_default_architecture_pomdp()
- fe_engine = ActiveInferenceEngine(pomdp_model)
- proven_count = sum(1 for i in self.epistemic_ledger if i.get("tag") == "PROVEN")
- obs = "HIGH_THROUGHPUT_CLEAN" if proven_count >= 2 else "LOCK_CONTENTION_WARN"
- fe_policies = [Policy(policy_id=f"p_{act}", actions=[act]) for act in pomdp_model.actions]
- fe_report = fe_engine.select_action(obs, fe_policies)
- self.active_free_energy = {
- "variational_free_energy_f": round(fe_report.variational_free_energy_f, 4),
- "complexity_kl": round(fe_report.complexity_kl, 4),
- "accuracy_log_likelihood": round(fe_report.accuracy_log_likelihood, 4),
- "selected_action": fe_report.selected_action,
- "observation": obs,
- "phase": matched_phase,
- "timestamp": now,
- }
- self.system3_active_inferences.append(self.active_free_energy)
-
- # Update Kripke Safety Invariant
- kripke = KripkeStructure()
- kripke.add_world("w0", propositions={"entered", "safe"})
- kripke.add_world("w_phase", propositions={f"phase_{target_phase_idx}", "safe"})
- kripke.add_transition("w0", "w_phase")
- kripke.add_transition("w_phase", "w_phase")
- checker = KripkeModelChecker(kripke)
- k_res = checker.check("AG(safe)", "w0")
- self.active_kripke_safety = {
- "formula": "AG(safe)",
- "is_satisfied": k_res.is_satisfied,
- "active_phase": matched_phase,
- }
+ This method is intentionally not exposed as an MCP action. A host
+ adapter/broker should call it after the real tool invocation. A model
+ may reference, but cannot mint, a receipt through the tool interface.
+ """
+ checked = _validate_host_receipt(receipt, self.session_id)
+ receipt_id = checked["receipt_id"]
+ if receipt_id in self.host_receipts:
+ raise EvidenceReceiptError(f"duplicate receipt: {receipt_id}")
+ # A second receipt for the same successful output is not independent
+ # evidence, even when an adapter invents a fresh identifier.
+ if any(r.get("success") is True and
+ hmac.compare_digest(str(r.get("output_hash", "")).lower(), checked["output_hash"].lower())
+ for r in self.host_receipts.values()):
+ raise EvidenceReceiptError("receipt output duplicates an existing receipt; evidence must be independent")
+ self.host_receipts[receipt_id] = checked
+ return {"receipt_id": receipt_id, "registered": True,
+ "success": checked["success"], "output_hash": checked["output_hash"]}
+
+ # Clear aliases make the host integration point discoverable without
+ # exposing a self-authorizing MCP operation.
+ register_tool_receipt = register_host_receipt
+ register_receipt = register_host_receipt
+
+ def set_host_authorization_hook(self, hook: Any) -> None:
+ """Install a host/broker authorization callback (never model supplied)."""
+ if hook is not None and not callable(hook):
+ raise TypeError("authorization hook must be callable or None")
+ self._host_authorization_hook = hook
+
+ def authorize_host_action(self, action: Dict[str, Any]) -> bool:
+ """Ask the host whether an action may run; V1 never authorizes itself."""
+ if self._host_authorization_hook is None:
+ return False
+ return bool(self._host_authorization_hook(action, self))
- return self.get_telemetry()
+ def log_epistemic_item(self, tag: str, claim: str, evidence: Optional[Any] = None) -> Dict[str, Any]:
+ """Log an epistemic item, requiring typed receipt evidence for gates.
- def log_epistemic_item(self, tag: str, claim: str, evidence: Optional[str] = None) -> Dict[str, Any]:
- """Logs an epistemic fact/hypothesis/unknown with structured tracking and evidence validation."""
+ String evidence is retained as a non-authoritative compatibility
+ representation for old clients. It is deliberately excluded from the
+ unlock gate, so keyword/free-text claims cannot self-authorize.
+ """
tag_upper = tag.strip().upper()
if tag_upper not in ("PROVEN", "HYPOTHESIS", "UNKNOWN"):
raise ValueError(f"Invalid epistemic tag '{tag}'. Must be 'PROVEN', 'HYPOTHESIS', or 'UNKNOWN'.")
if not claim or not claim.strip():
raise ValueError("Claim description cannot be empty.")
+ evidence_receipt_id = None
+ evidence_integrity_bound = False
+ stored_evidence: Any = evidence
if tag_upper == "PROVEN":
- if not str(evidence or "").strip():
- raise ValueError("PROVEN claims require concrete evidence (file, command output, test, or URL).")
- validator = EpistemicEvidenceValidator()
- valid, reason = validator.validate_proven_claim(claim, str(evidence))
- if not valid:
- raise ValueError(f"Epistemic Evidence Validation Failed: {reason}")
+ if isinstance(evidence, dict):
+ if len(json.dumps(evidence, ensure_ascii=False).encode("utf-8")) > MAX_EVIDENCE_BYTES:
+ raise ValueError("Epistemic evidence exceeds maximum size.")
+ required_evidence_fields = ("receipt_id", "session_id", "content_hash", "source_output_hash", "claim")
+ missing_evidence = [key for key in required_evidence_fields if key not in evidence]
+ if missing_evidence:
+ raise EvidenceReceiptError("typed evidence missing required fields: " + ", ".join(missing_evidence))
+ receipt_id = evidence.get("receipt_id")
+ if not isinstance(receipt_id, str) or not receipt_id.strip():
+ raise EvidenceReceiptError("typed evidence requires receipt_id")
+ receipt = self.host_receipts.get(receipt_id)
+ if receipt is None:
+ raise EvidenceReceiptError("evidence references an unknown host receipt")
+ if any(i.get("tag") == "PROVEN" and i.get("evidence_receipt_id") == receipt_id
+ for i in self.epistemic_ledger):
+ raise EvidenceReceiptError("each PROVEN item requires a distinct receipt ID")
+ if any(i.get("tag") == "PROVEN" and i.get("evidence", {}).get("content_hash") == evidence.get("content_hash")
+ for i in self.epistemic_ledger if isinstance(i.get("evidence"), dict)):
+ raise EvidenceReceiptError("each PROVEN item requires independent evidence output")
+ if not receipt.get("success"):
+ raise EvidenceReceiptError("evidence cannot reference a failed host receipt")
+ if evidence.get("session_id", self.session_id) != self.session_id:
+ raise EvidenceReceiptError("evidence belongs to a different session")
+ content = evidence.get("content", receipt.get("output"))
+ content_hash = evidence.get("content_hash")
+ if not isinstance(content_hash, str):
+ raise EvidenceReceiptError("typed evidence requires content_hash")
+ if not hmac.compare_digest(content_hash.lower(), receipt["output_hash"].lower()):
+ raise EvidenceReceiptError("evidence content_hash is not bound to receipt output_hash")
+ if not hmac.compare_digest(_canonical_hash(content), content_hash.lower()):
+ raise EvidenceReceiptError("evidence content does not match content_hash")
+ if evidence.get("source_output_hash", content_hash) != receipt["output_hash"]:
+ raise EvidenceReceiptError("evidence source_output_hash is not bound to receipt")
+ if evidence.get("claim") not in (None, claim):
+ raise EvidenceReceiptError("evidence claim does not match the ledger claim")
+ evidence_receipt_id = receipt_id
+ evidence_integrity_bound = True
+ stored_evidence = dict(evidence)
+ stored_evidence["session_id"] = self.session_id
+ stored_evidence["content_hash"] = content_hash.lower()
+ stored_evidence["source_output_hash"] = receipt["output_hash"]
+ elif isinstance(evidence, str) and evidence.strip():
+ # Compatibility only: legacy text is validated for shape but
+ # is never considered receipt-bound or gate-satisfying.
+ validator = EpistemicEvidenceValidator()
+ valid, reason = validator.validate_proven_claim(claim, evidence)
+ if not valid:
+ raise ValueError(f"Epistemic Evidence Validation Failed: {reason}")
+ stored_evidence = evidence.strip()
+ else:
+ raise ValueError("PROVEN claims require typed receipt evidence (or a legacy citation that cannot satisfy gates).")
item_id = f"epi_{len(self.epistemic_ledger) + 1:03d}"
item = {
"id": item_id,
"tag": tag_upper,
"claim": claim.strip(),
- "evidence": (evidence or "").strip(),
+ "evidence": stored_evidence,
+ "evidence_receipt_id": evidence_receipt_id,
+ "evidence_integrity_bound": evidence_integrity_bound,
"timestamp": self._wall_clock(),
"phase": self.active_phase
}
@@ -1668,24 +2510,30 @@ def record_invariant(
domain: str = "architecture"
) -> Dict[str, Any]:
"""Records a domain invariant with formal statement and proof."""
+ if not isinstance(domain, str):
+ raise ValueError("domain must be one of architecture, design, or coding")
dom_clean = domain.strip().lower()
if dom_clean not in ("architecture", "design", "coding"):
- dom_clean = "architecture"
-
- if not invariant_name or not invariant_name.strip():
- raise ValueError("Invariant name cannot be empty.")
- if not formal_statement or not formal_statement.strip():
- raise ValueError("Formal statement cannot be empty.")
- if not proof_or_rationale or not proof_or_rationale.strip():
- raise ValueError("Invariant proof or rationale cannot be empty.")
+ raise ValueError("domain must be one of architecture, design, or coding")
+
+ name_clean = _require_substantive_text(invariant_name, "invariant_name", minimum=3)
+ statement_clean = _require_substantive_text(formal_statement, "formal_statement", minimum=8)
+ proof_clean = _require_substantive_text(proof_or_rationale, "proof_or_rationale", minimum=8)
+ # A gate-worthy invariant must state a checkable relation or bound;
+ # arbitrary English such as "the system is correct" is not a formal
+ # contract. The proof must also refer to an actual reasoning method.
+ if not (re.search(r"(?:<=|>=|==|!=|\bmust\b|\bshall\b|\bwhen\b|\bif\b.*\bthen\b|\bforall\b|\b∀\b|\balways\b|\bnever\b)", statement_clean, re.I)):
+ raise ValueError("formal_statement must contain a checkable relation, bound, or temporal obligation")
+ if not re.search(r"(?:because|enforc|invariant|proof|induct|bound|check|test|guarantee|derive|ensur|case|since|via)", proof_clean, re.I):
+ raise ValueError("proof_or_rationale must explain how the invariant is established")
inv_id = f"inv_{len(self.invariants) + 1:03d}"
inv = {
"id": inv_id,
- "name": invariant_name.strip(),
+ "name": name_clean,
"domain": dom_clean,
- "formal_statement": formal_statement.strip(),
- "proof_or_rationale": (proof_or_rationale or "").strip(),
+ "formal_statement": statement_clean,
+ "proof_or_rationale": proof_clean,
"timestamp": self._wall_clock(),
"phase": self.active_phase
}
@@ -1702,87 +2550,43 @@ def log_refinement_cycle(
artifact_path: Optional[str] = None
) -> Dict[str, Any]:
"""Logs a structured rethink-refine cycle to continuously deepen cognitive quality."""
- if not refinement_type or not str(refinement_type).strip():
- raise ValueError("Refinement type cannot be empty.")
- if not focus_area or not str(focus_area).strip():
- raise ValueError("Focus area cannot be empty.")
- if not critique_or_bottleneck or not str(critique_or_bottleneck).strip():
- raise ValueError("Critique or bottleneck cannot be empty.")
- if not architectural_refinement or not str(architectural_refinement).strip():
- raise ValueError("Architectural refinement cannot be empty.")
+ refinement_type_clean = _require_substantive_text(refinement_type, "refinement_type", minimum=3)
+ focus_clean = _require_substantive_text(focus_area, "focus_area")
+ critique_clean = _require_substantive_text(critique_or_bottleneck, "critique_or_bottleneck")
+ refinement_clean = _require_substantive_text(architectural_refinement, "architectural_refinement")
cycle_num = len(self.refinement_cycles) + 1
entry = {
"cycle_number": cycle_num,
- "refinement_type": str(refinement_type).strip(),
- "focus_area": str(focus_area).strip(),
- "critique_or_bottleneck": str(critique_or_bottleneck).strip(),
- "architectural_refinement": str(architectural_refinement).strip(),
+ "refinement_type": refinement_type_clean,
+ "focus_area": focus_clean,
+ "critique_or_bottleneck": critique_clean,
+ "architectural_refinement": refinement_clean,
"terminal_probe_results": (terminal_probe_results or "").strip() if terminal_probe_results else None,
"artifact_path": (artifact_path or "").strip() if artifact_path else None,
"timestamp": self._wall_clock(),
"phase": self.active_phase
}
self.refinement_cycles.append(entry)
-
- # Update Session Active Free Energy F
- fe_engine = ActiveInferenceEngine(create_default_architecture_pomdp())
- obs = "HIGH_THROUGHPUT_CLEAN" if terminal_probe_results and any(
- kw in terminal_probe_results.lower() for kw in ("pass", "ok", "success")
- ) else "LOCK_CONTENTION_WARN"
- f_val, comp, acc = fe_engine.update_beliefs(obs)
- self.active_free_energy = {
- "variational_free_energy_f": round(f_val, 4),
- "complexity_kl": round(comp, 4),
- "accuracy_log_likelihood": round(acc, 4),
- "observation": obs,
- "cycle_number": cycle_num,
- "timestamp": self._wall_clock(),
- }
-
- # Update Causal DAG nodes (initialize default DAG if none exists)
- if not self.system3_causal_graphs:
+ self._refresh_system3_cognitive_state(trigger="log_refinement_cycle")
+ try:
+ from fable_v2.system3.causal import CausalDAG, CausalNodeType
+ dag = CausalDAG(name=f"System3RefinementCycle{cycle_num}")
+ dag.add_node("session_objective", "Session Objective", CausalNodeType.EXOGENOUS,
+ value=1.0)
+ dag.add_node(f"refine_cycle_{cycle_num}", "Refinement Cycle", CausalNodeType.ENDOGENOUS,
+ value=float(cycle_num), metadata={"focus_area": focus_clean})
+ dag.add_node("architectural_quality", "Architectural Quality", CausalNodeType.METRIC,
+ value=0.0)
+ dag.add_edge("session_objective", f"refine_cycle_{cycle_num}", weight=1.0)
+ dag.add_edge(f"refine_cycle_{cycle_num}", "architectural_quality", weight=1.0)
+ self.system3_causal_graphs.append(dag.to_dict())
+ except Exception:
+ # Optional causal telemetry is never an authority boundary.
self.system3_causal_graphs.append({
- "dag": {"name": f"Session_{self.session_name}_DAG", "nodes": [], "edges": []},
- "nodes": [],
- "edges": [],
- "topological_order": [],
- "timestamp": self._wall_clock(),
+ "name": f"System3RefinementCycle{cycle_num}",
+ "nodes": [{"node_id": f"refine_cycle_{cycle_num}"}], "edges": [],
})
- if self.system3_causal_graphs:
- causal_node_id = f"refine_cycle_{cycle_num}"
- causal_node_data = {
- "node_id": causal_node_id,
- "name": f"Refinement {cycle_num}: {focus_area}",
- "node_type": "INTERVENTION",
- "value": 1.0,
- "parents": [f"refine_cycle_{cycle_num - 1}"] if cycle_num > 1 else [],
- "metadata": {
- "refinement_type": refinement_type,
- "focus_area": focus_area,
- "critique": critique_or_bottleneck,
- }
- }
- latest_graph = self.system3_causal_graphs[-1]
- if "nodes" in latest_graph and isinstance(latest_graph["nodes"], list):
- if not any(n.get("node_id") == causal_node_id for n in latest_graph["nodes"] if isinstance(n, dict)):
- latest_graph["nodes"].append(causal_node_data)
- inner_dag = latest_graph.get("dag", latest_graph)
- nodes = inner_dag.setdefault("nodes", [])
- if isinstance(nodes, list):
- if not any(n.get("node_id") == causal_node_id for n in nodes if isinstance(n, dict)):
- nodes.append(causal_node_data)
- elif isinstance(nodes, dict):
- nodes[causal_node_id] = causal_node_data
- if cycle_num > 1:
- prev_id = f"refine_cycle_{cycle_num - 1}"
- edge_data = {"source": prev_id, "target": causal_node_id, "weight": 1.0, "mechanism": "refinement_evolution"}
- if "edges" in latest_graph and isinstance(latest_graph["edges"], list):
- latest_graph["edges"].append(edge_data)
- edges = inner_dag.setdefault("edges", [])
- if isinstance(edges, list):
- edges.append(edge_data)
-
return entry
@staticmethod
@@ -1872,6 +2676,17 @@ def to_dict(self) -> Dict[str, Any]:
"""Serializes session to dictionary."""
return {
"version": "1.2.3",
+ "control_plane": copy.deepcopy(self.control_plane),
+ "security_model": {
+ "control_plane_profile": CONTROL_PLANE_PROFILE,
+ "host_enforcement": self.host_enforcement,
+ "host_tools_enforced": self.host_tools_enforced,
+ "interruptive_control": INTERRUPTIVE_CONTROL,
+ "cas_namespace": "session_derived",
+ "receipt_authority": "external_host_only",
+ },
+ "cas_namespace": self.cas_namespace,
+ "objective_authority": "session_local",
"session_name": self.session_name,
"session_id": self.session_id,
"objective": self.objective,
@@ -1890,58 +2705,60 @@ def to_dict(self) -> Dict[str, Any]:
"epistemic_ledger": self.epistemic_ledger,
"invariants": self.invariants,
"refinement_cycles": self.refinement_cycles,
+ "delegation_contracts": self.delegation_contracts,
+ "active_free_energy": copy.deepcopy(self.active_free_energy),
+ "system3_active_inferences": copy.deepcopy(self.system3_active_inferences),
+ "system3_causal_graphs": copy.deepcopy(self.system3_causal_graphs),
"phase_history": self.phase_history,
"unlock_details": self.unlock_details,
- "system3_causal_graphs": self.system3_causal_graphs,
- "system3_syntheses": self.system3_syntheses,
- "system3_gene_pools": self.system3_gene_pools,
- "system3_axioms": self.system3_axioms,
- "system3_reflections": self.system3_reflections,
- "system3_orchestrations": self.system3_orchestrations,
- "system3_hyperbolic_embeddings": self.system3_hyperbolic_embeddings,
- "system3_kripke_verifications": self.system3_kripke_verifications,
- "system3_active_inferences": self.system3_active_inferences,
- "system3_proof_oracle_verifications": self.system3_proof_oracle_verifications,
- "active_free_energy": self.active_free_energy,
- "active_kripke_safety": self.active_kripke_safety,
- "active_biases": self.active_biases,
+ "host_verifications": copy.deepcopy(self.host_verifications),
}
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "FableSession":
+ def from_dict(cls, data: Dict[str, Any], *, expected_name: str | None = None,
+ expected_namespace: str | None = None) -> "FableSession":
"""Restore data without importing persisted execution authority.
Persistence is an interchange format, not an authority token. The
- deadline, phase, evidence, invariants, and unlock flags are all treated
- as untrusted; a restored process starts a fresh locked authority clock.
- Historical data remains visible, but cannot satisfy fresh gates.
+ filename, session name, session ID, and derived CAS namespace are one
+ binding. All are checked before any restored object is made available;
+ this prevents copying a valid session file into another name from
+ granting access to the original session's CAS namespace.
"""
+ if not isinstance(data, dict):
+ raise ValueError("persisted session must be an object")
+ persisted_name = _validate_session_name(data.get("session_name", ""))
+ if expected_name is not None and persisted_name != _validate_session_name(expected_name):
+ raise ValueError("persisted session name does not match its filename")
+ persisted_id = _validate_session_id(data.get("session_id"))
+ derived_namespace = _cas_namespace_for_session(persisted_id)
+ persisted_namespace = data.get("cas_namespace")
+ if persisted_namespace != derived_namespace:
+ raise ValueError("persisted CAS namespace is not bound to session_id")
+ if expected_namespace is not None and persisted_namespace != expected_namespace:
+ raise ValueError("persisted CAS namespace does not match requested capability")
budget = data.get("time_budget_minutes", 60.0)
- session = cls(session_name=data["session_name"], objective=data.get("objective", ""),
- time_budget_minutes=budget, session_id=data.get("session_id"))
+ session = cls(session_name=persisted_name, objective=data.get("objective", ""),
+ time_budget_minutes=budget, session_id=persisted_id)
session._restored_untrusted = True
+ # Never import strict control-plane authority from a file. A restored
+ # session starts a fresh, host-attested FSM; this is an explicit safe
+ # compatibility path, not a persisted authorization token.
+ session.control_plane = cls._new_control_plane_state()
+ session.host_verifications = {}
session.epistemic_ledger = [dict(item, _restored_untrusted=True) for item in data.get("epistemic_ledger", []) if isinstance(item, dict)]
session.invariants = [dict(item, _restored_untrusted=True) for item in data.get("invariants", []) if isinstance(item, dict)]
session.refinement_cycles = [dict(item, _restored_untrusted=True) for item in data.get("refinement_cycles", []) if isinstance(item, dict)]
+ session.active_free_energy = copy.deepcopy(data.get("active_free_energy")) if isinstance(data.get("active_free_energy"), dict) else None
+ session.system3_active_inferences = [copy.deepcopy(item) for item in data.get("system3_active_inferences", []) if isinstance(item, dict)]
+ session.system3_causal_graphs = [copy.deepcopy(item) for item in data.get("system3_causal_graphs", []) if isinstance(item, dict)]
+ session.delegation_contracts = []
session.active_phase = PHASES[0]
session.phase_history = [{"phase": PHASES[0], "entered_at": session.start_time,
"summary": "Restored in safe locked state; fresh gates required"}]
session.execution_locked = True
session.can_execute_code = False
session.unlock_details = None
- session.system3_causal_graphs = data.get("system3_causal_graphs", [])
- session.system3_syntheses = data.get("system3_syntheses", [])
- session.system3_gene_pools = data.get("system3_gene_pools", [])
- session.system3_axioms = data.get("system3_axioms", [])
- session.system3_reflections = data.get("system3_reflections", [])
- session.system3_orchestrations = data.get("system3_orchestrations", [])
- session.system3_hyperbolic_embeddings = data.get("system3_hyperbolic_embeddings", [])
- session.system3_kripke_verifications = data.get("system3_kripke_verifications", [])
- session.system3_active_inferences = data.get("system3_active_inferences", [])
- session.system3_proof_oracle_verifications = data.get("system3_proof_oracle_verifications", [])
- session.active_free_energy = data.get("active_free_energy")
- session.active_kripke_safety = data.get("active_kripke_safety")
- session.active_biases = data.get("active_biases", [])
return session
def save(self, target_path: Optional[Path] = None) -> Path:
@@ -2021,7 +2838,11 @@ def get_or_load_session(session_name: str) -> FableSession:
"""Retrieves session from memory or loads from disk if exists."""
clean_name = _validate_session_name(session_name)
if clean_name in ACTIVE_SESSIONS:
- return ACTIVE_SESSIONS[clean_name]
+ active = ACTIVE_SESSIONS[clean_name]
+ if (active.session_name != clean_name or
+ active.cas_namespace != _cas_namespace_for_session(active.session_id)):
+ raise RuntimeError("active session identity binding is invalid")
+ return active
file_path = SESSIONS_DIR / f"{clean_name}.json"
if file_path.exists() or file_path.is_symlink():
@@ -2029,7 +2850,10 @@ def get_or_load_session(session_name: str) -> FableSession:
_safe_session_file(file_path)
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
- session = FableSession.from_dict(data)
+ session = FableSession.from_dict(
+ data, expected_name=clean_name,
+ expected_namespace=_cas_namespace_for_session(
+ _validate_session_id(data.get("session_id"))))
ACTIVE_SESSIONS[clean_name] = session
return session
except Exception as e:
@@ -2039,14 +2863,275 @@ def get_or_load_session(session_name: str) -> FableSession:
raise ValueError(f"Session '{clean_name}' does not exist. Call 'create_session' first.")
+def register_host_receipt(session_name: str, receipt: Dict[str, Any]) -> Dict[str, Any]:
+ """Host-only receipt registration hook for adapters and brokers."""
+ return get_or_load_session(session_name).register_host_receipt(receipt)
+
+
+def register_host_verification(session_name: str, verification_id: str,
+ receipt: Dict[str, Any]) -> Dict[str, Any]:
+ """Broker-only final verification hook; never exposed as an MCP action."""
+ return get_or_load_session(session_name).register_host_verification(verification_id, receipt)
+
+
+register_broker_verification = register_host_verification
+
+
+def control_plane_capabilities() -> Dict[str, Any]:
+ """Machine-readable strict profile and honest enforcement declaration."""
+ return {
+ "profile": CONTROL_PLANE_PROFILE,
+ "tool": CONTROL_PLANE_TOOL_NAME,
+ "actions": list(CONTROL_PLANE_ACTIONS),
+ "state_machine": list(CONTROL_PLANE_STATES),
+ "invariants": [
+ "observe must precede record_prediction",
+ "record_prediction must precede mutating propose_action",
+ "record_outcome must reference a successful host receipt bound to the proposed capability and input_hash",
+ "outcome payload must equal the bound receipt output",
+ "outcome and receipt must precede request_verification",
+ "verification must be a verifier receipt bound to the requested outcome and checks",
+ "finalize requires that bound host/broker verification; unrelated successful receipts are rejected",
+ ],
+ "enforcement": {
+ "control_plane": "enforced",
+ "sequence_invariants": "enforced",
+ "session_binding": "enforced",
+ "idempotency": "enforced",
+ "host_tools": "advisory_external_host_required",
+ "native_tools": "not_controlled_unless_routed_through_broker",
+ "receipt_authority": "external_host_or_broker_only",
+ },
+ "legacy": {
+ "tool": "fable_session",
+ "mode": "explicit_compatibility_path",
+ "compatibility_mode": "legacy_v1",
+ },
+ }
+
+
+def _session_for_control_plane(arguments: Dict[str, Any]) -> FableSession:
+ action = arguments.get("action")
+ supplied_id = arguments.get("session_id")
+ session_name = arguments.get("session_name")
+ if supplied_id is not None and (not isinstance(supplied_id, str) or not SESSION_ID_PATTERN.fullmatch(supplied_id)):
+ raise ControlPlaneError("invalid_session_binding", "session_id must be a valid session identifier")
+ if session_name is not None and (not isinstance(session_name, str) or not session_name.strip()):
+ raise ControlPlaneError("invalid_session_binding", "session_name must be a non-empty string")
+ if supplied_id:
+ matches = [s for s in ACTIVE_SESSIONS.values() if s.session_id == supplied_id]
+ if not matches and session_name:
+ candidate = get_or_load_session(session_name.strip())
+ matches = [candidate] if candidate.session_id == supplied_id else []
+ if not matches:
+ raise ControlPlaneError("unknown_session", "session_id is not bound to an active session")
+ session = matches[0]
+ if session_name and session.session_name != session_name.strip():
+ raise ControlPlaneError("invalid_session_binding", "session_name does not match session_id")
+ return session
+ if action != "observe":
+ raise ControlPlaneError("session_binding_required", "strict actions require session_id; observe may create a session with session_name")
+ if not session_name:
+ raise ControlPlaneError("session_binding_required", "observe requires session_name when creating a strict session")
+ clean_name = _validate_session_name(session_name)
+ if clean_name in ACTIVE_SESSIONS:
+ raise ControlPlaneError("session_already_exists", "observe cannot create a second strict session with this name")
+ path = SESSIONS_DIR / f"{clean_name}.json"
+ if path.exists() or path.is_symlink():
+ raise ControlPlaneError("session_already_exists", "session_name already exists; use its bound session_id")
+ objective = arguments.get("objective")
+ if not isinstance(objective, str):
+ raise ControlPlaneError("objective_required", "observe session creation requires objective")
+ objective = _require_substantive_text(objective, "objective")
+ budget = arguments.get("time_budget_minutes", 60.0)
+ try:
+ session = FableSession(clean_name, objective, budget)
+ except (TypeError, ValueError) as exc:
+ raise ControlPlaneError("invalid_arguments", str(exc)) from exc
+ ACTIVE_SESSIONS[clean_name] = session
+ return session
+
+
+def handle_fable_control_plane(arguments: Dict[str, Any]) -> Dict[str, Any]:
+ """Strict typed MCP control-plane entrypoint with structured errors."""
+ if not isinstance(arguments, dict):
+ return {"ok": False, "profile": CONTROL_PLANE_PROFILE,
+ "error": {"code": "invalid_arguments", "message": "arguments must be an object"}}
+ if arguments.get("action") in CONTROL_PLANE_CAPABILITY_ACTIONS:
+ allowed = {"action", "profile"}
+ forbidden = {"tag", "proven", "final_authorized", "authorized", "authorization", "approval"}
+ if forbidden.intersection(arguments):
+ return {"ok": False, "profile": CONTROL_PLANE_PROFILE, "action": "capabilities",
+ "enforcement": control_plane_capabilities()["enforcement"],
+ "error": {"code": "model_authorization_forbidden", "message": "model-supplied authorization fields are not accepted"}}
+ extras = sorted(set(arguments) - allowed)
+ if extras:
+ return {"ok": False, "profile": CONTROL_PLANE_PROFILE, "action": "capabilities",
+ "enforcement": control_plane_capabilities()["enforcement"],
+ "error": {"code": "unknown_field", "message": "capabilities rejects unknown fields: " + ", ".join(extras)}}
+ if arguments.get("profile") not in (None, CONTROL_PLANE_PROFILE):
+ return {"ok": False, "profile": CONTROL_PLANE_PROFILE,
+ "error": {"code": "profile_mismatch", "message": f"profile must be {CONTROL_PLANE_PROFILE!r}"}}
+ return {"ok": True, "profile": CONTROL_PLANE_PROFILE, "action": "capabilities",
+ "capabilities": control_plane_capabilities(), "enforcement": control_plane_capabilities()["enforcement"]}
+ action = arguments.get("action")
+ try:
+ if action not in CONTROL_PLANE_ACTIONS:
+ raise ControlPlaneError("unknown_action", f"action must be one of {', '.join(CONTROL_PLANE_ACTIONS)}")
+ if arguments.get("profile") not in (None, CONTROL_PLANE_PROFILE):
+ raise ControlPlaneError("profile_mismatch", f"profile must be {CONTROL_PLANE_PROFILE!r}")
+ forbidden = {"tag", "proven", "final_authorized", "authorized", "authorization", "approval"}
+ supplied = forbidden.intersection(arguments)
+ if supplied:
+ raise ControlPlaneError("model_authorization_forbidden", "model-supplied PROVEN/final authorization fields are not accepted")
+ session = _session_for_control_plane(arguments)
+ response = session.strict_control_plane(arguments)
+ session.save()
+ return response
+ except ControlPlaneError as exc:
+ session_id = arguments.get("session_id") if isinstance(arguments.get("session_id"), str) else None
+ current_state = "unknown"
+ if session_id:
+ for candidate in ACTIVE_SESSIONS.values():
+ if candidate.session_id == session_id:
+ current_state = candidate.control_plane.get("state", "unknown")
+ break
+ return {"ok": False, "profile": CONTROL_PLANE_PROFILE, "action": action,
+ "session_id": session_id, "state": current_state,
+ "enforcement": control_plane_capabilities()["enforcement"],
+ "error": {"code": exc.code, "message": exc.message, "details": exc.details}}
+ except (ValueError, TypeError) as exc:
+ return {"ok": False, "profile": CONTROL_PLANE_PROFILE, "action": action,
+ "enforcement": control_plane_capabilities()["enforcement"],
+ "error": {"code": "invalid_arguments", "message": str(exc), "details": {}}}
+
+
+SESSION_CAS_ENGINES: Dict[str, FableCompress] = {}
+
+
+def _session_cas_engine(session: FableSession) -> FableCompress:
+ """Return a CAS engine confined to this session's capability namespace."""
+ if session.cas_namespace != _cas_namespace_for_session(session.session_id):
+ raise PermissionError("session CAS capability binding is invalid")
+ engine = SESSION_CAS_ENGINES.get(session.session_id)
+ if engine is None:
+ engine = FableCompress(root_dir=FABLE_CAS_DIR, namespace=session.cas_namespace)
+ SESSION_CAS_ENGINES[session.session_id] = engine
+ return engine
+
+
+CAS_ACTIONS = {"compress_payload", "decompress_payload", "view_slice",
+ "accumulate_payload", "flush_accumulator", "get_compression_stats"}
+
+
+ACTION_ALIASES = {
+ "init": "create_session", "create": "create_session",
+ "update_timer": "set_timer", "timer": "set_timer",
+ "telemetry": "get_status", "status": "get_status",
+ "next_phase": "advance_phase", "advance": "advance_phase",
+ "log_item": "log_epistemic_item", "epistemic_log": "log_epistemic_item",
+ "add_invariant": "record_invariant", "invariant": "record_invariant",
+ "record_refinement": "log_refinement_cycle", "refine": "log_refinement_cycle",
+ "unlock": "unlock_execution", "save_session": "checkpoint_session",
+ "checkpoint": "checkpoint_session", "load_session": "restore_session",
+ "restore": "restore_session", "load": "restore_session", "list": "list_sessions",
+ "compile_contract": "compile_delegation_contract", "validate_contract": "compile_delegation_contract",
+ "compress": "compress_payload", "cas_put": "compress_payload", "cas_store": "compress_payload",
+ "decompress": "decompress_payload", "cas_get": "decompress_payload", "cas_read": "decompress_payload",
+ "cas_slice": "view_slice", "slice": "view_slice", "accumulate": "accumulate_payload",
+ "cas_accumulate": "accumulate_payload", "flush_cas": "flush_accumulator", "cas_flush": "flush_accumulator",
+ "compression_stats": "get_compression_stats", "cas_stats": "get_compression_stats",
+}
+ACTION_NAMES = {
+ "create_session", "set_timer", "get_status", "advance_phase", "log_epistemic_item",
+ "record_invariant", "log_refinement_cycle", "unlock_execution", "checkpoint_session",
+ "restore_session", "list_sessions", "compile_delegation_contract", "compress_payload",
+ "decompress_payload", "view_slice", "accumulate_payload", "flush_accumulator",
+ "get_compression_stats",
+}
+ACTION_REQUIRED = {
+ "create_session": ("session_name", "objective"), "set_timer": ("session_name", "time_budget_minutes"),
+ "get_status": ("session_name",), "advance_phase": ("session_name", "next_phase", "phase_summary"),
+ "log_epistemic_item": ("session_name", "tag", "claim"), "record_invariant": ("session_name", "invariant_name", "formal_statement", "proof_or_rationale"),
+ "log_refinement_cycle": ("session_name", "refinement_type", "focus_area", "critique_or_bottleneck", "architectural_refinement"),
+ "unlock_execution": ("session_name", "rationale"), "checkpoint_session": ("session_name",),
+ "restore_session": ("session_name",), "compile_delegation_contract": ("subagent_prompt",),
+ "compress_payload": ("content",), "decompress_payload": ("cas_ref",), "view_slice": ("cas_ref",),
+ "accumulate_payload": ("payload",),
+}
+ACTION_STRING_FIELDS = {"action", "compatibility_mode", "session_name", "objective", "next_phase", "phase_summary", "tag", "claim", "invariant_name", "formal_statement", "proof_or_rationale", "refinement_type", "focus_area", "critique_or_bottleneck", "architectural_refinement", "terminal_probe_results", "artifact_path", "rationale", "subagent_prompt", "prompt", "contract", "content", "payload", "cas_ref", "ref_or_hash", "label"}
+
+
+def _validate_tool_arguments(arguments: Any) -> Tuple[Optional[str], Optional[str]]:
+ if not isinstance(arguments, dict):
+ return None, "arguments must be an object"
+ try:
+ _reject_non_finite(arguments, path="arguments")
+ argument_bytes = len(json.dumps(arguments, ensure_ascii=False, allow_nan=False).encode("utf-8"))
+ except (TypeError, ValueError) as exc:
+ return None, str(exc) if isinstance(exc, ValueError) else "arguments must contain JSON-compatible values"
+ if argument_bytes > MAX_EVIDENCE_BYTES:
+ return None, "arguments exceed maximum size"
+ raw_action = arguments.get("action")
+ if not isinstance(raw_action, str) or not raw_action.strip():
+ return None, "'action' must be a non-empty string"
+ if "compatibility_mode" in arguments and arguments.get("compatibility_mode") != "legacy_v1":
+ return None, "'compatibility_mode' must be 'legacy_v1' when supplied"
+ action = ACTION_ALIASES.get(raw_action.strip().lower(), raw_action.strip().lower())
+ if action not in ACTION_NAMES:
+ return action, f"Unknown action '{action}'"
+ for field in ACTION_STRING_FIELDS:
+ if field in arguments and not isinstance(arguments[field], str):
+ if field == "phase_summary" and isinstance(arguments[field], dict):
+ continue
+ return action, f"'{field}' must be a string"
+ if "phase_evidence" in arguments and not isinstance(arguments["phase_evidence"], (dict, str)):
+ return action, "'phase_evidence' must be an object"
+ if "time_budget_minutes" in arguments:
+ value = arguments["time_budget_minutes"]
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
+ return action, "'time_budget_minutes' must be a finite number"
+ for field in ("start_line", "end_line"):
+ if field in arguments and (isinstance(arguments[field], bool) or not isinstance(arguments[field], int)):
+ return action, f"'{field}' must be an integer"
+ for field in ("include_line_numbers", "force_flush"):
+ if field in arguments and not isinstance(arguments[field], bool):
+ return action, f"'{field}' must be a boolean"
+ if "metadata" in arguments and not isinstance(arguments["metadata"], (dict, str)):
+ return action, "'metadata' must be an object or JSON string"
+ for field in ACTION_REQUIRED.get(action, ()):
+ # Compatibility aliases satisfy canonical requirements.
+ if field not in arguments or (isinstance(arguments[field], str) and not arguments[field].strip()):
+ if field == "content" and "payload" in arguments: continue
+ if field == "payload" and "content" in arguments: continue
+ if field == "subagent_prompt" and any(k in arguments for k in ("prompt", "contract")): continue
+ return action, f"'{field}' is required"
+ if action == "log_epistemic_item" and str(arguments.get("tag", "")).upper() == "PROVEN" and "evidence" not in arguments:
+ return action, "PROVEN claims require typed receipt evidence"
+ if action in CAS_ACTIONS and (not isinstance(arguments.get("session_name"), str)
+ or not arguments.get("session_name", "").strip()):
+ return action, "'session_name' is required to bind CAS access to a session capability namespace"
+ return action, None
+
+
def handle_fable_session(arguments: Dict[str, Any]) -> str:
"""Main dispatch handler for fable_session tool actions."""
try:
- action = arguments.get("action", "").strip().lower()
- session_name = arguments.get("session_name", "").strip()
-
- if not action:
- return "Error: Missing required parameter 'action'."
+ action, validation_error = _validate_tool_arguments(arguments)
+ if validation_error:
+ return f"Error: Invalid arguments: {validation_error}"
+ action = action or ""
+ session_name = arguments.get("session_name", "")
+ session_name = session_name.strip() if isinstance(session_name, str) else ""
+ # CAS actions are always scoped to a live session. The derived
+ # namespace prevents a valid hash from one session being a capability
+ # to read another session's objects.
+ cas_engine = None
+ if action in CAS_ACTIONS:
+ if not session_name:
+ return "Error: 'session_name' is required to bind CAS access to a session capability namespace."
+ cas_session = get_or_load_session(session_name)
+ cas_engine = _session_cas_engine(cas_session)
# 1. CREATE SESSION
if action in ("create_session", "init", "create"):
@@ -2076,9 +3161,10 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
f"- **Time Budget**: `{session.time_budget_minutes}` minutes ({tel['remaining_formatted']})\n"
f"- **Active Phase**: `{session.active_phase}`\n"
f"- **Execution Lock**: `LOCKED (can_execute_code: False)` 🛑\n"
- f"- **Cognitive Gates**: 0/2 [PROVEN] items, 0/1 Invariant recorded\n\n"
+ f"- **Host Enforcement**: `EXTERNAL HOST REQUIRED` (V1 does not sandbox or authorize host tools)\n"
+ f"- **Cognitive Gates**: 0/2 receipt-bound [PROVEN] items, 0/1 Invariant recorded\n\n"
f"> [!IMPORTANT]\n"
- f"> Anti-Rush Lockout is ACTIVE. Proceed with epistemic grounding, research, and invariant modeling before requesting execution unlock."
+ f"> Anti-Rush session gate is ACTIVE (advisory to the model; it does not sandbox or authorize host tools). Proceed with epistemic grounding, research, and invariant modeling before requesting execution unlock."
f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
)
@@ -2141,24 +3227,17 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
ref_lines.append(f"- **Cycle #{ref['cycle_number']}** `[{ref['refinement_type'].upper()}]` ({ref['focus_area']}): {ref['architectural_refinement']}")
ref_preview = "\n".join(ref_lines) if ref_lines else "- No refinement cycles recorded yet."
- cog_state = tel.get("system3_cognitive_state", {})
return (
f"### 📊 Fable Session Status & Telemetry (`{session.session_name}`)\n\n"
f"- **Objective**: {session.objective}\n"
f"- **Active Phase**: `{session.active_phase}` (Phase {tel['phase_index']}/{tel['total_phases']})\n"
f"- **Execution Lock**: {lock_badge}\n"
+ f"- **Host Enforcement**: `{tel['host_enforcement']}` (V1 does not sandbox or authorize host tools)\n"
f"- **Pacing**: `{tel['elapsed_formatted']}` elapsed / `{tel['pacing_remaining_formatted']}` remaining (`{tel['pacing_percentage']}` budget used)\n"
f"- **Authority**: `{tel['authority_remaining_formatted']}` remaining (immutable outer deadline)\n"
f"- **Epistemic Breakdown**: `{counts['proven']} PROVEN`, `{counts['hypothesis']} HYPOTHESIS`, `{counts['unknown']} UNKNOWN` (Total: `{counts['total']}`)\n"
f"- **Invariants Recorded**: `{tel['invariants_count']}`\n"
f"- **Refinement Cycles**: `{tel['refinement_count']}`\n\n"
- f"#### 🧠 System 3 Meta-Cognitive State:\n"
- f"- **Variational Free Energy $F$**: `{cog_state.get('free_energy_f', 'N/A')}` "
- f"(Complexity $D_{{KL}}$: `{cog_state.get('complexity_kl', 'N/A')}`, Accuracy: `{cog_state.get('accuracy_log_likelihood', 'N/A')}`)\n"
- f"- **Kripke Safety Invariant**: `{cog_state.get('kripke_safety_invariant', 'AG(safe) -> True')}`\n"
- f"- **Active Biases Tracked**: `{cog_state.get('active_biases_count', 0)}`\n"
- f"- **Contradiction Density**: `{cog_state.get('contradiction_density', 0.0)}`\n"
- f"- **Hyperbolic Metric**: `{cog_state.get('hyperbolic_metric', {}).get('status', 'INITIALIZED')}`\n\n"
f"#### 🔍 Recent Epistemic Ledger Items:\n{ledger_preview}\n\n"
f"#### 📐 Invariants Specification:\n{inv_preview}\n\n"
f"#### 🔄 Recent Refinement Cycles:\n{ref_preview}"
@@ -2172,27 +3251,21 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
next_phase = arguments.get("next_phase", "").strip()
if not next_phase:
return "Error: 'next_phase' is required for action 'advance_phase'."
- phase_summary = arguments.get("phase_summary", "").strip() or "Advanced phase transition."
-
+ phase_summary = arguments.get("phase_summary", "")
+ if isinstance(phase_summary, dict):
+ raw_phase_evidence = phase_summary.get("evidence", arguments.get("phase_evidence"))
+ phase_summary = phase_summary.get("summary", "")
+ else:
+ raw_phase_evidence = arguments.get("phase_evidence")
+ if isinstance(raw_phase_evidence, str):
+ try:
+ raw_phase_evidence = _strict_json_loads(raw_phase_evidence)
+ except Exception:
+ return "Error: phase_evidence must be a JSON object"
session = get_or_load_session(session_name)
- tel = session.advance_phase(next_phase, phase_summary)
+ tel = session.advance_phase(next_phase, phase_summary, raw_phase_evidence)
session.save()
- cog_state = tel.get("system3_cognitive_state", {})
- bias_lines = ""
- if session.active_biases:
- bias_items = "\n".join([f" * ⚠️ **{b['bias_type']}** ({b['severity']}): {b['description']} -> *{b['mitigation_recommendation']}*" for b in session.active_biases])
- bias_lines = f"\n- **Active Biases Intercepted** ({len(session.active_biases)}):\n{bias_items}"
-
- sys3_advisory = (
- f"\n\n### 🧠 System 3 Meta-Cognitive Advisory & Active Inference\n"
- f"- **Live Free Energy $F$**: `{cog_state.get('free_energy_f', 'N/A')}` "
- f"(Complexity $D_{{KL}}$: `{cog_state.get('complexity_kl', 'N/A')}`, Accuracy: `{cog_state.get('accuracy_log_likelihood', 'N/A')}`)\n"
- f"- **Kripke Safety Invariant**: `{cog_state.get('kripke_safety_invariant', 'AG(safe) -> True')}`\n"
- f"- **Contradiction Density**: `{cog_state.get('contradiction_density', 0.0)}`"
- f"{bias_lines}"
- )
-
return (
f"### 🚀 Fable Phase Advanced Successfully\n\n"
f"- **Session**: `{session.session_name}`\n"
@@ -2200,8 +3273,10 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
f"- **Phase Summary**: {phase_summary}\n"
f"- **Execution Status**: `{'LOCKED 🛑' if session.execution_locked else 'UNLOCKED 🟢'}`\n"
f"- **Pacing Remaining**: `{tel['pacing_remaining_formatted']}` (`{tel['pacing_percentage']}` used)\n"
- f"- **Authority Remaining**: `{tel['authority_remaining_formatted']}`"
- f"{sys3_advisory}"
+ f"- **Authority Remaining**: `{tel['authority_remaining_formatted']}`\n"
+ f"\n#### 🧠 System 3 Meta-Cognitive Advisory\n"
+ f"- **Live Free Energy**: `{tel['system3_cognitive_state'].get('free_energy_f')}` (advisory estimate)\n"
+ f"- **Temporal Invariant**: `{tel['system3_cognitive_state'].get('kripke_safety_invariant')}` = `True` (telemetry only)"
f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
)
@@ -2426,15 +3501,25 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
f"> 4. `VerificationCommand: `"
)
- compiled_scaffold = parsed.get("compiled_prompt", prompt)
+ if session_name:
+ contract_session = get_or_load_session(session_name)
+ contract_session.delegation_contracts.append({
+ "target_file": parsed.get("TargetFile"),
+ "verification_command": parsed.get("VerificationCommand"),
+ "prompt_hash": _canonical_hash(str(prompt)),
+ "timestamp": contract_session._wall_clock(),
+ "phase": contract_session.active_phase,
+ })
+ contract_session.save()
+
return (
- f"### ✅ Subagent Delegation Contract Compiled Successfully (with System 3 Micro-Scaffolds)\n\n"
+ f"### ✅ Subagent Delegation Contract Compiled Successfully\n\n"
f"- **Target File**: `{parsed.get('TargetFile', 'Declared')}`\n"
f"- **Verification Command**: `{parsed.get('VerificationCommand', 'Declared')}`\n"
f"- **Contract Status**: `100% BOUNDED & VALIDATED`\n"
- f"- **System 3 Micro-Scaffolds**: `INJECTED (Kripke AG(safe), Causal do(·) bounds, TRIZ Transcendence, Regex Constraints)`\n"
- f"- **Dispatch Readiness**: `READY_FOR_SUBAGENT_DISPATCH` 🚀\n\n"
- f"```markdown\n{compiled_scaffold}\n```\n\n"
+ f"- **Dispatch Readiness**: `READY_FOR_SUBAGENT_DISPATCH` 🚀\n"
+ f"\n#### 🧠 System 3 Micro-Scaffolds (INJECTED)\n"
+ f"{parsed.get('system3_micro_scaffold', 'No scaffold for invalid contract.')}\n\n"
f"> [!TIP]\n"
f"> You may now dispatch a worker subagent (`type: self`) with this validated contract once execution is unlocked."
)
@@ -2451,24 +3536,30 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
if len(content_text.encode("utf-8")) > MAX_CAS_OBJECT_BYTES:
return "Error: payload exceeds maximum CAS object size."
- compressed_node = CAS_ENGINE.compress_payload_to_cas(content_text, label=label)
+ compressed_node = cas_engine.compress_payload_to_cas(content_text, label=label)
raw_len = len(content_text)
- raw_tokens = CAS_ENGINE.estimate_token_count(content_text)
+ raw_tokens = cas_engine.estimate_token_count(content_text)
comp_repr = json.dumps(compressed_node, separators=(",", ":"))
- comp_tokens = CAS_ENGINE.estimate_token_count(comp_repr)
- ratio = CAS_ENGINE.calculate_token_ratio(content_text, comp_repr)
-
- invariant_met = ratio <= 0.003 if raw_len >= 10000 else True
- badge = "✅ PASS (<= 0.003 tokens/char)" if invariant_met else f"⚠️ Ratio: {ratio:.6f} tokens/char"
+ comp_tokens = cas_engine.estimate_token_count(comp_repr)
+ ratio = cas_engine.calculate_token_ratio(content_text, comp_repr)
+
+ heuristic_target_applies = raw_len >= 10000
+ heuristic_target_met = ratio <= 0.003 if heuristic_target_applies else False
+ if not heuristic_target_applies:
+ badge = "ℹ️ HEURISTIC NOT EVALUATED (payload is under 10,000 characters)"
+ elif heuristic_target_met:
+ badge = "✅ HEURISTIC TARGET MET (estimated ratio <= 0.003 tokens/char; not a guarantee)"
+ else:
+ badge = f"⚠️ HEURISTIC ESTIMATE ABOVE TARGET (estimated ratio: {ratio:.6f} tokens/char)"
return (
f"### 🗜️ Fable CAS Payload Compressed\n\n"
f"- **CAS URI**: `{compressed_node['cas_ref']}`\n"
f"- **Line Count**: `{compressed_node['lines']}`\n"
- f"- **Raw Size**: `{raw_len}` characters (~`{raw_tokens}` tokens)\n"
- f"- **Compressed Reference Size**: `{len(comp_repr)}` characters (~`{comp_tokens}` tokens)\n"
- f"- **Token Compression Ratio**: `{ratio:.6f}` tokens/char\n"
- f"- **Invariant Status**: {badge}\n"
+ f"- **Raw Size**: `{raw_len}` characters (~`{raw_tokens}` estimated tokens)\n"
+ f"- **Compressed Reference Size**: `{len(comp_repr)}` characters (~`{comp_tokens}` estimated tokens)\n"
+ f"- **Estimated Token Compression Ratio (heuristic)**: `{ratio:.6f}` estimated tokens/char\n"
+ f"- **Heuristic Check (not a measured guarantee)**: {badge}\n"
f"- **JSON Descriptor**:\n```json\n{json.dumps(compressed_node, indent=2)}\n```\n\n"
f"> [!TIP]\n"
f"> Use action `view_slice` with `cas_ref` to inspect specific line windows without loading full payload."
@@ -2480,10 +3571,10 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
if not cas_ref:
return "Error: 'cas_ref' is required for action 'decompress_payload'."
try:
- text = CAS_ENGINE.cas_store.get_text(cas_ref, verify=True)
+ text = cas_engine.cas_store.get_text(cas_ref, verify=True)
if len(text.encode("utf-8")) > MAX_RPC_RESPONSE_BYTES // 2:
return "Error: decompressed response exceeds maximum size; use view_slice."
- lines = CAS_ENGINE.slice_viewer.get_line_count(cas_ref)
+ lines = cas_engine.slice_viewer.get_line_count(cas_ref)
return (
f"### 📦 Fable CAS Payload Retrieved\n\n"
f"- **CAS URI**: `{cas_ref}`\n"
@@ -2504,10 +3595,10 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
include_line_numbers = bool(arguments.get("include_line_numbers", False))
try:
- slice_text = CAS_ENGINE.slice_viewer.view_slice(
+ slice_text = cas_engine.slice_viewer.view_slice(
cas_ref, start_line, end_line, include_line_numbers=include_line_numbers
)
- total_lines = CAS_ENGINE.slice_viewer.get_line_count(cas_ref)
+ total_lines = cas_engine.slice_viewer.get_line_count(cas_ref)
return (
f"### 🔍 Fable CAS Slice View (`{start_line}` - `{end_line}` of `{total_lines}` lines)\n\n"
f"- **CAS URI**: `{cas_ref}`\n"
@@ -2527,19 +3618,19 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
metadata = arguments.get("metadata")
if isinstance(metadata, str):
try:
- metadata = json.loads(metadata)
+ metadata = _strict_json_loads(metadata)
except Exception:
metadata = {"raw": metadata}
force_flush = bool(arguments.get("force_flush", False))
- flushed = CAS_ENGINE.accumulator.add(str(payload), metadata=metadata, force_flush=force_flush)
- stats = CAS_ENGINE.accumulator.get_stats()
+ flushed = cas_engine.accumulator.add(str(payload), metadata=metadata, force_flush=force_flush)
+ stats = cas_engine.accumulator.get_stats()
flushed_str = "\n".join([f"- Flushed Composite Frame: `{u}`" for u in flushed]) if flushed else "- No frame flushed yet (buffering micro-payload)."
return (
f"### 📥 Micro-Payload Ingested\n\n"
f"- **Buffered Items**: `{stats['currently_buffered_items']}`\n"
- f"- **Buffered Chars**: `{stats['currently_buffered_chars']}` / `{CAS_ENGINE.accumulator.min_frame_size}` bytes threshold\n"
+ f"- **Buffered Chars**: `{stats['currently_buffered_chars']}` / `{cas_engine.accumulator.min_frame_size}` bytes threshold\n"
f"- **Total Ingested**: `{stats['total_payloads_ingested']}`\n"
f"- **Total Frames Flushed**: `{stats['total_frames_flushed']}`\n\n"
f"{flushed_str}"
@@ -2547,8 +3638,8 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
# 17. FLUSH ACCUMULATOR
elif action in ("flush_accumulator", "flush_cas", "cas_flush"):
- flushed = CAS_ENGINE.accumulator.flush()
- stats = CAS_ENGINE.accumulator.get_stats()
+ flushed = cas_engine.accumulator.flush()
+ stats = cas_engine.accumulator.get_stats()
if flushed:
flushed_str = "\n".join([f"- Flushed Frame: `{u}`" for u in flushed])
return (
@@ -2565,792 +3656,97 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
# 18. GET COMPRESSION STATS
elif action in ("get_compression_stats", "compression_stats", "cas_stats"):
- stats = CAS_ENGINE.accumulator.get_stats()
+ stats = cas_engine.accumulator.get_stats()
+ quota = cas_engine.cas_store.quota_stats()
return (
f"### 📊 Fable Token Compression Subsystem Telemetry\n\n"
- f"- **CAS Storage Root**: `{CAS_ENGINE.cas_store.root_dir}`\n"
- f"- **Memory Cache Capacity**: `{CAS_ENGINE.cas_store.cache.capacity}` entries (Current: `{len(CAS_ENGINE.cas_store.cache)}`)\n"
+ f"- **CAS Capability Namespace**: `{quota['namespace']}`\n"
+ f"- **CAS Storage Root**: `{cas_engine.cas_store.root_dir}`\n"
+ f"- **Namespace Quota**: `{quota['bytes']}` / `{quota['max_bytes']}` bytes, `{quota['objects']}` / `{quota['max_objects']}` objects\n"
+ f"- **Memory Cache Capacity**: `{cas_engine.cas_store.cache.capacity}` entries (Current: `{len(cas_engine.cas_store.cache)}`)\n"
f"- **Micro-Payloads Ingested**: `{stats['total_payloads_ingested']}`\n"
f"- **Composite Frames Flushed**: `{stats['total_frames_flushed']}`\n"
f"- **Total Raw Characters**: `{stats['total_raw_chars']}`\n"
f"- **Total CAS Bytes Written**: `{stats['total_cas_bytes_written']}`\n"
f"- **Currently Buffered Items**: `{stats['currently_buffered_items']}` (`{stats['currently_buffered_chars']}` chars)\n"
- f"- **Token Compression Invariant**: `<= 0.003 tokens/character`"
- )
-
- # 19. SYSTEM 3: DIALECTICAL SYNTHESIS
- elif action in ("system3_dialectical_synthesis", "dialectical_synthesis", "triz_synthesis", "synthesis"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_dialectical_synthesis'."
- thesis_title = arguments.get("thesis_title") or arguments.get("title") or "Architectural Thesis"
- thesis_desc = arguments.get("thesis_description") or arguments.get("description") or arguments.get("thesis") or "Primary architectural candidate."
- antithesis_title = arguments.get("antithesis_title") or arguments.get("critique_title") or arguments.get("critique") or "Adversarial Critique"
-
- raw_contradictions = arguments.get("contradictions") or arguments.get("contradiction_list") or []
- parsed_contradictions = []
- if isinstance(raw_contradictions, str):
- try:
- loaded = json.loads(raw_contradictions)
- if isinstance(loaded, list):
- raw_contradictions = loaded
- elif isinstance(loaded, dict):
- raw_contradictions = [loaded]
- except Exception:
- raw_contradictions = [
- {"improving_parameter": "performance", "worsening_parameter": "safety", "description": line.strip(), "severity": 0.7}
- for line in raw_contradictions.splitlines() if line.strip()
- ]
-
- if isinstance(raw_contradictions, list):
- for idx, c in enumerate(raw_contradictions):
- if isinstance(c, dict):
- parsed_contradictions.append(Contradiction(
- contradiction_id=c.get("contradiction_id", f"c_{idx+1:03d}"),
- improving_parameter=c.get("improving_parameter", "performance"),
- worsening_parameter=c.get("worsening_parameter", "safety"),
- description=c.get("description", "Architectural trade-off"),
- severity=float(c.get("severity", 0.7)),
- ))
- elif isinstance(c, str):
- parsed_contradictions.append(Contradiction(
- contradiction_id=f"c_{idx+1:03d}",
- improving_parameter="performance",
- worsening_parameter="safety",
- description=c,
- severity=0.7,
- ))
-
- failure_modes = arguments.get("failure_modes") or []
- if isinstance(failure_modes, str):
- try:
- failure_modes = json.loads(failure_modes)
- except Exception:
- failure_modes = [f.strip() for f in failure_modes.splitlines() if f.strip()]
-
- thesis = ThesisCandidate(
- thesis_id=f"th_{session_name}_{int(time.time()*1000)%10000}",
- title=thesis_title,
- description=thesis_desc,
- )
- critique = AntithesisCritique(
- critique_id=f"cr_{session_name}_{int(time.time()*1000)%10000}",
- thesis_id=thesis.thesis_id,
- title=antithesis_title,
- contradictions=parsed_contradictions,
- failure_modes=failure_modes if isinstance(failure_modes, list) else [str(failure_modes)],
- severity_score=float(arguments.get("severity_score", 0.75)),
- )
-
- max_rounds = int(arguments.get("max_debate_rounds", 4))
- threshold = float(arguments.get("target_residual_threshold", 0.15))
-
- synthesizer = DialecticalSynthesizer()
- synthesis = synthesizer.synthesize(
- thesis, critique, max_debate_rounds=max_rounds, target_residual_threshold=threshold
+ f"- **Compression Target (heuristic only)**: estimated ratio `<= 0.003 tokens/character` for payloads of at least 10,000 characters; diagnostic only, not a measured guarantee"
)
- session = get_or_load_session(session_name)
- session.system3_syntheses.append(synthesis.to_dict())
-
- session.log_refinement_cycle(
- refinement_type="system3_dialectical_synthesis",
- focus_area=f"{thesis_title} vs {antithesis_title}",
- critique_or_bottleneck=f"Contradictions: {len(parsed_contradictions)} parameter conflicts analyzed.",
- architectural_refinement=synthesis.pareto_improvement_claim,
- )
- session.save()
-
- principles_list = "\n".join([f"- **TRIZ Principle #{p.number} ({p.name})**: {p.description}" for p in synthesis.transcended_principles]) or "- No principles transcended."
- contra_list = "\n".join([f"- `{c.improving_parameter}` vs `{c.worsening_parameter}`: {c.description} (Severity: {c.severity})" for c in synthesis.resolved_contradictions]) or "- None declared."
-
+ else:
return (
- f"### ⚡ System 3 Dialectical Synthesis Emerged\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Synthesis Title**: **{synthesis.title}** (`{synthesis.synthesis_id}`)\n"
- f"- **Debate Rounds Executed**: `{synthesis.debate_rounds_executed}`\n"
- f"- **Initial Contradiction Severity**: `{synthesis.initial_contradiction_score:.2f}`\n"
- f"- **Residual Contradiction Severity**: `{synthesis.residual_contradiction_score:.2f}`\n"
- f"- **Convergence Achieved**: `{'✅ YES' if synthesis.convergence_achieved else '⚠️ PARTIAL'}`\n\n"
- f"#### 🧬 Transcended TRIZ Inventive Principles:\n{principles_list}\n\n"
- f"#### ⚔️ Resolved Contradictions:\n{contra_list}\n\n"
- f"#### 🏛️ Synthesized Architectural Blueprint:\n{synthesis.synthesized_architecture}\n\n"
- f"> [!TIP]\n"
- f"> {synthesis.pareto_improvement_claim}"
- f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 20. SYSTEM 3: CAUSAL SIMULATION & PEARL DO-CALCULUS
- elif action in ("system3_causal_simulate", "causal_simulate", "causal_graph", "do_calculus"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_causal_simulate'."
- model_name = arguments.get("model_name", "System3CausalModel")
- nodes_input = arguments.get("nodes", [])
- edges_input = arguments.get("edges", [])
- interventions_input = arguments.get("interventions", {})
- target_metric = arguments.get("target_metric")
-
- if isinstance(nodes_input, str):
- try:
- nodes_input = json.loads(nodes_input)
- except Exception:
- nodes_input = []
- if isinstance(edges_input, str):
- try:
- edges_input = json.loads(edges_input)
- except Exception:
- edges_input = []
- if isinstance(interventions_input, str):
- try:
- interventions_input = json.loads(interventions_input)
- except Exception:
- interventions_input = {}
-
- dag = CausalDAG(name=model_name)
- for n in nodes_input:
- if isinstance(n, dict):
- dag.add_node(
- node_id=n.get("node_id", n.get("id")),
- name=n.get("name"),
- node_type=CausalNodeType(n.get("node_type", "endogenous")),
- value=float(n.get("value", 0.0)),
- default_value=float(n["default_value"]) if "default_value" in n else None,
- min_value=float(n["min_value"]) if "min_value" in n else None,
- max_value=float(n["max_value"]) if "max_value" in n else None,
- description=n.get("description", ""),
- )
-
- for e in edges_input:
- if isinstance(e, dict):
- dag.add_edge(
- source=e["source"],
- target=e["target"],
- weight=float(e.get("weight", 1.0)),
- relation_type=e.get("relation_type", "linear"),
- description=e.get("description", ""),
- )
-
- is_acyclic, cycle = dag.check_acyclicity()
- if not is_acyclic:
- return f"Error: Graph contains a cycle: {' -> '.join(cycle)}. Causal models must be valid DAGs."
-
- topo_order = dag.topological_sort()
- factual_values = dag.compute_forward()
-
- interv_res = None
- if interventions_input and isinstance(interventions_input, dict):
- interv_map = {k: float(v) for k, v in interventions_input.items()}
- interv_res = dag.do_intervention(interv_map)
-
- brittleness_rep = None
- if target_metric and target_metric in dag.nodes:
- brittleness_rep = dag.evaluate_brittleness(target_metric)
-
- session = get_or_load_session(session_name)
- new_graph_entry = {
- "dag": dag.to_dict(),
- "nodes": dag.to_dict().get("nodes", []),
- "edges": dag.to_dict().get("edges", []),
- "topological_order": topo_order,
- "factual_values": factual_values,
- "intervention": interv_res.to_dict() if interv_res else None,
- "brittleness": brittleness_rep.to_dict() if brittleness_rep else None,
- "timestamp": time.time(),
- }
- if len(session.system3_causal_graphs) == 1 and session.system3_causal_graphs[0].get("dag", {}).get("name") == f"Session_{session.session_name}_DAG":
- session.system3_causal_graphs[0] = new_graph_entry
- else:
- session.system3_causal_graphs.append(new_graph_entry)
- session.save()
-
- lines = [
- f"### 🌐 System 3 Pearl's Do-Calculus & Causal Simulation\n\n",
- f"- **Model**: `{dag.name}` ({len(dag.nodes)} nodes, {len(dag.edges)} directed causal edges)\n",
- f"- **Topological Order**: `{' -> '.join(topo_order)}`\n",
- ]
-
- if interv_res:
- lines.append(f"\n#### ✂️ Pearl's Do-Operator Intervention: `do({interv_res.interventions})`\n")
- lines.append(f"- **Severed Edges**: `{len(interv_res.severed_edges)}` ({interv_res.severed_edges})\n")
- lines.append(f"- **Impacted Nodes**: `{', '.join(interv_res.impacted_nodes)}`\n\n")
- lines.append("| Node | Factual Value | Counterfactual Value | Delta (Δ) |\n")
- lines.append("|---|---|---|---|\n")
- for nid in topo_order:
- f_val = interv_res.original_values.get(nid, 0.0)
- cf_val = interv_res.counterfactual_values.get(nid, 0.0)
- delta = interv_res.deltas.get(nid, 0.0)
- delta_str = f"+{delta:.4f}" if delta > 0 else f"{delta:.4f}"
- lines.append(f"| `{nid}` | {f_val:.4f} | **{cf_val:.4f}** | `{delta_str}` |\n")
-
- if brittleness_rep:
- lines.append(f"\n#### 🔬 Structural Brittleness Report (`{brittleness_rep.target_metric}`)\n")
- lines.append(f"- **Overall Brittleness Score**: `{brittleness_rep.overall_brittleness_score:.4f}` / 1.0\n")
- spof_str = ", ".join(brittleness_rep.single_points_of_failure) if brittleness_rep.single_points_of_failure else "None (Resilient)"
- lines.append(f"- **Single Points of Failure**: `{spof_str}`\n")
- for rec in brittleness_rep.recommendations:
- lines.append(f"- 💡 {rec}\n")
-
- if session.execution_locked:
- lines.append(SILENT_DELIBERATION_REMINDER)
-
- return "".join(lines)
-
- # 21. SYSTEM 3: EVOLVE PARADIGMS (10D PARETO FRONTIER OPTIMIZER)
- elif action in ("system3_evolve_paradigms", "evolve_paradigms", "evolution_generation", "genetic_optimize"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_evolve_paradigms'."
- generations = int(arguments.get("generations", 3))
- pop_size = int(arguments.get("population_size", 12))
- mutation_rate = float(arguments.get("mutation_rate", 0.15))
- crossover_rate = float(arguments.get("crossover_rate", 0.80))
- seed_paradigms = arguments.get("seed_paradigms")
- if isinstance(seed_paradigms, str):
- try:
- seed_paradigms = json.loads(seed_paradigms)
- except Exception:
- seed_paradigms = None
-
- weights = arguments.get("objective_weights")
- if isinstance(weights, str):
- try:
- weights = json.loads(weights)
- except Exception:
- weights = None
-
- pool = CognitiveGenePool(
- population_size=pop_size,
- mutation_rate=mutation_rate,
- crossover_rate=crossover_rate,
+ f"Error: Unknown action '{action}'. Supported actions: "
+ f"'create_session', 'set_timer', 'get_status', 'telemetry', 'advance_phase', "
+ f"'log_epistemic_item', 'record_invariant', 'log_refinement_cycle', 'unlock_execution', "
+ f"'checkpoint_session', 'restore_session', 'list_sessions', 'compile_delegation_contract', "
+ f"'compress_payload', 'decompress_payload', 'view_slice', 'accumulate_payload', 'flush_accumulator', 'get_compression_stats'."
)
- pool.initialize_population(seed_paradigms=seed_paradigms)
+ except Exception as ex:
+ return f"Error: {str(ex)}"
- for _ in range(generations):
- pool.evolve_generation()
- pareto_frontier = pool.get_pareto_frontier()
- best_genome = pool.get_best_genome(weights)
+# --------------------------------------------------------------------------------
+# JSON-RPC 2.0 MCP Protocol Stdio Loop
+# --------------------------------------------------------------------------------
- session = get_or_load_session(session_name)
- session.system3_gene_pools.append(pool.to_dict())
-
- session.log_refinement_cycle(
- refinement_type="system3_evolutionary_optimization",
- focus_area=f"10D Pareto Frontier Search across {generations} generations",
- critique_or_bottleneck=f"Optimized {pop_size} genomes across 10 dimensions.",
- architectural_refinement=f"Evolved top paradigm '{best_genome.paradigm_name}' with scalar fitness {best_genome.compute_scalar_fitness(weights):.4f}.",
- )
- session.save()
-
- table_rows = []
- for g in pareto_frontier[:5]:
- f = g.fitness_scores
- table_rows.append(
- f"| `{g.genome_id}` | **{g.paradigm_name[:24]}** | `{f.get('latency',0):.2f}` | "
- f"`{f.get('throughput',0):.2f}` | `{f.get('memory_efficiency',0):.2f}` | "
- f"`{f.get('fault_tolerance',0):.2f}` | `{f.get('modularity',0):.2f}` | "
- f"`{f.get('security',0):.2f}` | `{f.get('token_compaction',0):.2f}` | `{g.compute_scalar_fitness(weights):.4f}` |"
- )
-
- table_str = "\n".join(table_rows)
-
- return (
- f"### 🧬 System 3 Evolutionary Paradigm Engine\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Generations Evolved**: `{pool.generation_count}` (Population: `{len(pool.population)}`)\n"
- f"- **Rank 1 Pareto Frontier Size**: `{len(pareto_frontier)}` non-dominated solutions\n"
- f"- **Top Archetype**: **{best_genome.paradigm_name}** (`{best_genome.genome_id}`)\n\n"
- f"#### 🏆 Top Rank 1 Non-Dominated Pareto Frontier:\n"
- f"| ID | Paradigm | Lat | Tput | Mem | Fault | Mod | Sec | Token | Score |\n"
- f"|---|---|---|---|---|---|---|---|---|---|\n"
- f"{table_str}\n\n"
- f"#### 🧬 Winning Gene Allocation (`{best_genome.genome_id}`):\n"
- + "\n".join([f"- **{k}**: `{v}`" for k, v in best_genome.genes.items()])
- + f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 22. SYSTEM 3: INDUCE AXIOMS (NEURO-SYMBOLIC META-PROOF INDUCTION)
- elif action in ("system3_induce_axioms", "induce_axioms", "neuro_symbolic_induction", "formalize_axioms"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_induce_axioms'."
- session = get_or_load_session(session_name)
- domain = arguments.get("domain", "architecture")
-
- inducer = MetaProofInducer()
- axioms = inducer.induce_axioms_from_session(
- receipts=[],
- evidence=[],
- session_telemetry=session.get_telemetry(),
- domain_hints=[domain],
- )
-
- for ax in axioms:
- session.system3_axioms.append(ax.to_dict())
- if not any(i.get("name") == ax.name for i in session.invariants):
- session.record_invariant(
- invariant_name=ax.name,
- formal_statement=ax.symbolic_expression,
- proof_or_rationale=ax.proof_sketch or ax.natural_language,
- domain=ax.domain if ax.domain in ("architecture", "design", "coding") else "architecture",
- )
-
- session.save()
-
- axiom_blocks = []
- for ax in axioms:
- axiom_blocks.append(
- f"#### 📜 `{ax.axiom_id}`: **{ax.name}** `[{ax.domain.upper()}]`\n"
- f"- **Symbolic Expression**: `{ax.symbolic_expression}`\n"
- f"- **Natural Language**: {ax.natural_language}\n"
- f"- **Epistemic Confidence**: `{ax.confidence * 100:.1f}%` (`{ax.status.value.upper()}`)\n"
- f"- **Proof Rationale**: {ax.proof_sketch}\n"
- )
-
- return (
- f"### 📐 System 3 Neuro-Symbolic Invariant Induction\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Axioms Induced**: `{len(axioms)}`\n"
- f"- **Auto-Recorded Invariants**: `{len(session.invariants)}` total in session\n\n"
- + "\n".join(axiom_blocks)
- + f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 23. SYSTEM 3: META REFLECTION & COGNITIVE BIAS AUDIT
- elif action in ("system3_meta_reflect", "meta_reflect", "cognitive_audit", "meta_cognition"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_meta_reflect'."
- session = get_or_load_session(session_name)
- focus_area = arguments.get("focus_area", "Full Deliberation Trace")
-
- executive = System3Executive()
- report = executive.meta_reflect(session.to_dict())
-
- session.system3_reflections.append(report)
-
- bias_summary = f"{len(report['bias_findings'])} biases flagged" if report['bias_findings'] else "0 biases detected (Clean)"
- session.log_refinement_cycle(
- refinement_type="system3_meta_reflection",
- focus_area=focus_area,
- critique_or_bottleneck=f"Meta-cognitive audit: {bias_summary}. Contradiction density: {report['contradiction_density']:.2f}.",
- architectural_refinement=f"Shifted cognitive gear to {report['cognitive_gear']}. Updated search heuristics temperature: {report['updated_search_heuristics']['exploration_temperature']}.",
- )
- session.save()
-
- bias_lines = []
- for b in report["bias_findings"]:
- bias_lines.append(
- f"- ⚠️ **{b['bias_type'].upper()}** (Severity: `{b['severity']}` in *{b['detected_in']}*):\n"
- f" - *Evidence*: {b['evidence_trail']}\n"
- f" - *Mitigation*: {b['mitigation_strategy']}"
- )
- bias_block = "\n".join(bias_lines) if bias_lines else "✅ Zero cognitive biases detected. Epistemic reasoning is well-calibrated."
-
- directives_block = "\n".join([f"- 🎯 {d}" for d in report["directives"]])
-
- return (
- f"### 🧠 System 3 Meta-Cognitive Deliberation Audit\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Recommended Cognitive Gear**: `{report['cognitive_gear'].upper()}`\n"
- f"- **Contradiction Density**: `{report['contradiction_density']:.2f}`\n"
- f"- **Arbitration Rationale**: {report['arbitration_rationale']}\n\n"
- f"#### 🔍 Cognitive Bias Diagnostics:\n{bias_block}\n\n"
- f"#### 🚀 System 3 Executive Directives:\n{directives_block}\n\n"
- f"#### ⚙️ Dynamic Search Heuristics:\n"
- f"- **Exploration Temperature**: `{report['updated_search_heuristics']['exploration_temperature']}`\n"
- f"- **Pruning Cutoff Threshold**: `{report['updated_search_heuristics']['pruning_threshold']}`\n"
- f"- **Max Branching Factor**: `{report['updated_search_heuristics']['max_branching_factor']}`\n"
- f"- **Falsification Intensity**: `{report['updated_search_heuristics']['falsification_intensity']}`"
- f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 24. SYSTEM 3: TRI-LEVEL COGNITIVE ORCHESTRATION
- elif action in ("system3_tri_level_orchestrate", "tri_level_orchestrate", "cognitive_gear_shift", "arbitrate_cognition"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_tri_level_orchestrate'."
- complexity = float(arguments.get("task_complexity", 0.75))
- density = float(arguments.get("contradiction_density", 0.60))
- failures = int(arguments.get("failure_count", 0))
- uncertainty = float(arguments.get("epistemic_uncertainty", 0.40))
-
- arbitrator = TriLevelArbitrator()
- decision = arbitrator.arbitrate(
- task_complexity=complexity,
- contradiction_density=density,
- failure_count=failures,
- epistemic_uncertainty=uncertainty,
- )
-
- session = get_or_load_session(session_name)
- session.system3_orchestrations.append({
- "decision": decision,
- "timestamp": time.time(),
- "inputs": {
- "complexity": complexity,
- "density": density,
- "failures": failures,
- "uncertainty": uncertainty,
- }
- })
- session.save()
-
- directives_str = "\n".join([f"- {d}" for d in decision["directives"]])
-
- return (
- f"### 🎛️ System 3 Tri-Level Cognitive Arbitration\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Recommended Operating Gear**: `⚙️ {decision['recommended_gear'].upper()}`\n"
- f"- **Composite Difficulty Index**: `{decision['composite_difficulty']:.3f}` / 1.0\n"
- f"- **Arbitration Rationale**: {decision['rationale']}\n\n"
- f"#### 🧭 Prescribed Cognitive Action Directives:\n{directives_str}"
- f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 25. SYSTEM 3: HYPERBOLIC MANIFOLD TREE EMBEDDING
- elif action in ("system3_hyperbolic_embed", "hyperbolic_embed", "poincare_embed", "hyperbolic_tree"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_hyperbolic_embed'."
- tree_input = arguments.get("tree")
- if tree_input is None:
- return "Error: 'tree' is required for action 'system3_hyperbolic_embed'."
- if isinstance(tree_input, str):
- try:
- tree_input = json.loads(tree_input)
- except Exception:
- return "Error: Failed to parse 'tree' as JSON."
-
- root_id = arguments.get("root_id")
- dimension = int(arguments.get("dimension", 2))
- curvature = float(arguments.get("curvature", 1.0))
- base_step = float(arguments.get("base_step", 1.0))
- node_labels = arguments.get("node_labels")
- if isinstance(node_labels, str):
- try:
- node_labels = json.loads(node_labels)
- except Exception:
- node_labels = None
-
- embedder = HyperbolicTreeEmbedder(dimension=dimension, curvature=curvature, base_step_distance=base_step)
- result = embedder.embed_hierarchy(tree=tree_input, root_id=root_id, node_labels=node_labels)
-
- session = get_or_load_session(session_name)
- session.system3_hyperbolic_embeddings.append(result.to_dict())
-
- session.log_refinement_cycle(
- refinement_type="system3_hyperbolic_embedding",
- focus_area=f"Hierarchical Poincaré Manifold Embedding ({result.total_nodes} nodes, depth {result.tree_depth})",
- critique_or_bottleneck=f"Embedded hierarchy into {dimension}D Poincaré ball with curvature c={curvature:.2f}.",
- architectural_refinement=(
- f"Manifold tree embedding verified: avg_distortion={result.average_distortion:.4f}, "
- f"max_distortion={result.max_distortion:.4f}, stress={result.stress:.4f}, "
- f"exponential capacity ratio={result.hierarchical_capacity_ratio:.2f}x."
- ),
- )
- session.save()
-
- node_table = []
- for nid, node in list(result.nodes.items())[:10]:
- coords_str = f"({', '.join([f'{c:.4f}' for c in node.coords])})"
- node_table.append(
- f"| `{node.node_id}` | **{node.label[:20]}** | `{node.depth}` | `{node.subtree_size}` | `{coords_str}` |"
- )
- node_table_str = "\n".join(node_table)
-
- return (
- f"### 🌐 System 3 Poincaré Hyperbolic Manifold Embedding\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Manifold**: Poincaré Ball $\\mathbb{{B}}^{{{result.dimension}}}_{{c={result.curvature:.2f}}}$\n"
- f"- **Hierarchy**: Root `{result.root_id}` ({result.total_nodes} nodes, max depth `{result.tree_depth}`)\n"
- f"- **Mean Metric Distortion**: `{result.average_distortion:.4f}` (Max: `{result.max_distortion:.4f}`)\n"
- f"- **Metric Stress Metric**: `{result.stress:.4f}`\n"
- f"- **Hyperbolic Volume Expansion Ratio**: `{result.hierarchical_capacity_ratio:.2f}x` vs Euclidean $\\mathbb{{R}}^{{{result.dimension}}}$\n\n"
- f"#### 📍 Top Embedded Nodes in $\\mathbb{{B}}^{{{result.dimension}}}$:\n"
- f"| Node ID | Label | Depth | Subtree | Poincaré Coordinates |\n"
- f"|---|---|---|---|---|\n"
- f"{node_table_str}\n"
- f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 26. SYSTEM 3: KRIPKE MODAL MODEL CHECKING & CTL* VERIFICATION
- elif action in ("system3_kripke_verify", "kripke_verify", "modal_verify", "ctl_check"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_kripke_verify'."
- formula = arguments.get("formula", "").strip()
- if not formula:
- return "Error: 'formula' is required for action 'system3_kripke_verify'."
- model_name = arguments.get("model_name", "System3KripkeModel")
- worlds_input = arguments.get("worlds", [])
- transitions_input = arguments.get("transitions", [])
- initial_world = arguments.get("initial_world")
-
- if isinstance(worlds_input, str):
- try:
- worlds_input = json.loads(worlds_input)
- except Exception:
- worlds_input = []
- if isinstance(transitions_input, str):
- try:
- transitions_input = json.loads(transitions_input)
- except Exception:
- transitions_input = []
-
- structure = KripkeStructure(name=model_name)
- for w in worlds_input:
- if isinstance(w, dict):
- structure.add_world(
- world_id=w.get("world_id", w.get("id")),
- propositions=w.get("propositions", w.get("props", [])),
- name=w.get("name", ""),
- is_initial=w.get("is_initial", False),
- metadata=w.get("metadata", {}),
- )
- elif isinstance(w, str):
- structure.add_world(world_id=w)
-
- if isinstance(transitions_input, dict):
- for src, targets in transitions_input.items():
- for tgt in (targets if isinstance(targets, list) else [targets]):
- structure.add_transition(src, tgt)
- elif isinstance(transitions_input, list):
- for t in transitions_input:
- if isinstance(t, dict) and "source" in t and "target" in t:
- structure.add_transition(t["source"], t["target"])
- elif isinstance(t, (list, tuple)) and len(t) >= 2:
- structure.add_transition(str(t[0]), str(t[1]))
-
- checker = KripkeModelChecker(structure)
- res = checker.check(formula=formula, initial_world=initial_world)
-
- session = get_or_load_session(session_name)
- session.system3_kripke_verifications.append(res.to_dict())
-
- session.log_refinement_cycle(
- refinement_type="system3_kripke_verification",
- focus_area=f"Modal / CTL Verification: {res.formula}",
- critique_or_bottleneck=f"Evaluated Kripke model '{structure.name}' ({res.total_worlds} worlds).",
- architectural_refinement=(
- f"Model checking result: {'✅ SATISFIED' if res.is_satisfied else '❌ VIOLATED'}. "
- f"Satisfied worlds: {len(res.satisfied_worlds)}/{res.total_worlds}. "
- f"Witness/Counterexample: {res.witness_path or res.counterexample_path}."
- ),
- )
- session.save()
-
- trace_block = ""
- if res.counterexample_path:
- trace_block = f"\n#### ❌ Counterexample Violation Trace:\n`{' -> '.join(res.counterexample_path)}`\n"
- elif res.witness_path:
- trace_block = f"\n#### ✅ Witness Path:\n`{' -> '.join(res.witness_path)}`\n"
-
- return (
- f"### 🛡️ System 3 Kripke Modal Model Verification\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Model**: `{structure.name}` ({res.total_worlds} worlds)\n"
- f"- **Formula**: `{res.formula}`\n"
- f"- **Status**: `{'✅ SATISFIED' if res.is_satisfied else '❌ VIOLATED'}` at initial world `{res.initial_world}`\n"
- f"- **Satisfied Worlds**: `{', '.join(res.satisfied_worlds) if res.satisfied_worlds else 'None'}`\n"
- f"- **Violated Worlds**: `{', '.join(res.violated_worlds) if res.violated_worlds else 'None'}`\n"
- f"{trace_block}\n"
- f"> [!TIP]\n"
- f"> {res.details}"
- f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 27. SYSTEM 3: FRISTON ACTIVE INFERENCE & VARIATIONAL FREE ENERGY
- elif action in ("system3_active_inference", "active_inference", "free_energy", "fe_step"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_active_inference'."
- obs = arguments.get("observation", "HIGH_THROUGHPUT_CLEAN").strip()
- gamma = float(arguments.get("gamma", 16.0))
- policies_input = arguments.get("policies")
- if isinstance(policies_input, str):
- try:
- policies_input = json.loads(policies_input)
- except Exception:
- policies_input = None
-
- parsed_policies = []
- if policies_input and isinstance(policies_input, list):
- for p in policies_input:
- if isinstance(p, dict) and "policy_id" in p and "actions" in p:
- parsed_policies.append(Policy.from_dict(p))
- elif isinstance(p, str):
- parsed_policies.append(Policy(policy_id=f"pol_{p}", actions=[p]))
-
- # Check if custom model components provided
- states = arguments.get("states")
- observations = arguments.get("observations")
- actions = arguments.get("actions")
- a_mat = arguments.get("a_matrix")
- b_mats = arguments.get("b_matrices")
- c_pref = arguments.get("c_preferences")
- d_prior = arguments.get("d_prior")
-
- if all(x is not None for x in [states, observations, actions, a_mat, b_mats, c_pref, d_prior]):
- if isinstance(states, str): states = json.loads(states)
- if isinstance(observations, str): observations = json.loads(observations)
- if isinstance(actions, str): actions = json.loads(actions)
- if isinstance(a_mat, str): a_mat = json.loads(a_mat)
- if isinstance(b_mats, str): b_mats = json.loads(b_mats)
- if isinstance(c_pref, str): c_pref = json.loads(c_pref)
- if isinstance(d_prior, str): d_prior = json.loads(d_prior)
- gen_model = GenerativeModel(
- states=states,
- observations=observations,
- actions=actions,
- a_matrix=a_mat,
- b_matrices=b_mats,
- c_preferences=c_pref,
- d_prior=d_prior,
- )
- else:
- gen_model = create_default_architecture_pomdp()
-
- engine = ActiveInferenceEngine(generative_model=gen_model, policy_precision_gamma=gamma)
- report = engine.select_action(observation=obs, candidate_policies=parsed_policies)
-
- session = get_or_load_session(session_name)
- session.system3_active_inferences.append(report.to_dict())
-
- session.log_refinement_cycle(
- refinement_type="system3_active_inference",
- focus_area=f"Free Energy Minimization for observation '{obs}'",
- critique_or_bottleneck=(
- f"Variational Free Energy F={report.variational_free_energy_f:.4f} "
- f"(Complexity KL={report.complexity_kl:.4f}, Accuracy={report.accuracy_log_likelihood:.4f})."
- ),
- architectural_refinement=(
- f"Selected optimal policy '{report.selected_policy.policy_id}' ({report.selected_action}) "
- f"with Expected Free Energy G={report.selected_policy.expected_free_energy_g:.4f}, "
- f"Information Gain={report.selected_policy.epistemic_information_gain:.4f}, "
- f"Goal Utility={report.selected_policy.pragmatic_goal_utility:.4f}."
- ),
- )
- session.save()
-
- policy_table = []
- for p in report.evaluated_policies:
- opt_mark = "🏆 OPTIMAL" if p.is_optimal else ""
- policy_table.append(
- f"| `{p.policy_id}` | `{p.actions}` | `{p.expected_free_energy_g:.4f}` | "
- f"`{p.risk_pragmatic_divergence:.4f}` | `{p.ambiguity_expected_entropy:.4f}` | "
- f"`{p.epistemic_information_gain:.4f}` | `{p.pragmatic_goal_utility:.4f}` | "
- f"`{p.probability * 100:.1f}%` | {opt_mark} |"
- )
- pol_table_str = "\n".join(policy_table)
-
- belief_table = "\n".join([f"- **{k}**: `{v * 100:.1f}%`" for k, v in report.belief_state.items()])
-
- return (
- f"### ⚡ System 3 Friston Active Inference & Variational Free Energy\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Ingested Observation**: `{report.current_observation}`\n"
- f"- **Variational Free Energy (F)**: `{report.variational_free_energy_f:.4f}` (Surprisal Bound)\n"
- f" - *Complexity (KL)*: `{report.complexity_kl:.4f}`\n"
- f" - *Accuracy (Log-Likelihood)*: `{report.accuracy_log_likelihood:.4f}`\n"
- f"- **Selected Policy**: **{report.selected_policy.policy_id}** -> Action: `🎯 {report.selected_action}`\n\n"
- f"#### 🧠 Updated Hidden State Belief Distribution $q(s)$:\n{belief_table}\n\n"
- f"#### 📊 Evaluated Policy Landscape ($G(\\pi) = \\text{{Risk}} + \\text{{Ambiguity}}$):\n"
- f"| Policy ID | Actions | EFE ($G$) | Risk | Ambiguity | Epistemic Info Gain | Goal Utility | Prob | Status |\n"
- f"|---|---|---|---|---|---|---|---|---|\n"
- f"{pol_table_str}\n"
- f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- # 28. SYSTEM 3: GODELIAN PROOF ORACLE & CURRY-HOWARD VERIFICATION
- elif action in ("system3_proof_oracle", "proof_oracle", "curry_howard", "formal_prove"):
- if not session_name:
- return "Error: 'session_name' is required for action 'system3_proof_oracle'."
- claim = arguments.get("claim")
- if not claim:
- return "Error: 'claim' is required for action 'system3_proof_oracle'."
-
- context = arguments.get("context")
- if isinstance(context, str):
- try:
- context = json.loads(context)
- except Exception:
- context = None
-
- axioms = arguments.get("axioms")
- if isinstance(axioms, str):
- try:
- axioms = json.loads(axioms)
- except Exception:
- axioms = None
-
- oracle = ProofOracle()
- res = oracle.verify_proposition(claim=claim, context=context, axioms=axioms)
-
- session = get_or_load_session(session_name)
- session.system3_proof_oracle_verifications.append(res.to_dict())
-
- # Auto-record invariant if proved and sound
- if res.status == ProofStatus.DECIDABLE_PROVED and res.is_sound:
- inv_name = f"INV-ORACLE: {str(claim)[:32]}"
- if not any(i.get("name") == inv_name for i in session.invariants):
- session.record_invariant(
- invariant_name=inv_name,
- formal_statement=res.proposition,
- proof_or_rationale=f"Curry-Howard Constructive Proof Term: {res.proof_term_repr}",
- domain="architecture",
- )
-
- session.log_refinement_cycle(
- refinement_type="system3_proof_oracle",
- focus_area=f"Curry-Howard Constructive Proof: {res.proposition}",
- critique_or_bottleneck=f"Evaluated status: {res.status.value.upper()}.",
- architectural_refinement=(
- f"Proof Oracle verdict: {res.status.value.upper()} (Sound: {res.is_sound}). "
- f"Proof Term: {res.proof_term_repr or 'None'}."
- ),
- )
- session.save()
-
- steps_str = "\n".join([f"- {s}" for s in res.verification_steps])
- undec_str = ""
- if res.undecidability_diagnostics:
- undec_str = (
- f"\n#### 🛑 Gödelian Boundary Diagnostics:\n"
- f"- **Boundary Type**: `{res.undecidability_diagnostics.get('boundary_type')}`\n"
- f"- **Explanation**: {res.undecidability_diagnostics.get('explanation')}\n"
- )
-
- return (
- f"### 📜 System 3 Gödelian Auto-Formalizing Proof Oracle\n\n"
- f"- **Session**: `{session.session_name}`\n"
- f"- **Proposition**: `{res.proposition}`\n"
- f"- **Decision Status**: `{'✅ ' if res.status == ProofStatus.DECIDABLE_PROVED else '⚡ ' if res.status == ProofStatus.DECIDABLE_REFUTED else '🛑 '}{res.status.value.upper()}`\n"
- f"- **Soundness Verified**: `{'✅ TRUE' if res.is_sound else '❌ UNVERIFIED'}`\n"
- + (f"- **Constructive Proof Term**: `{res.proof_term_repr}`\n" if res.proof_term_repr else "")
- + f"{undec_str}\n"
- f"#### 🔍 Verification Trace:\n{steps_str}\n"
- f"{SILENT_DELIBERATION_REMINDER if session.execution_locked else ''}"
- )
-
- else:
- return (
- f"Error: Unknown action '{action}'. Supported actions: "
- f"'create_session', 'set_timer', 'get_status', 'telemetry', 'advance_phase', "
- f"'log_epistemic_item', 'record_invariant', 'log_refinement_cycle', 'unlock_execution', "
- f"'checkpoint_session', 'restore_session', 'list_sessions', 'compile_delegation_contract', "
- f"'compress_payload', 'decompress_payload', 'view_slice', 'accumulate_payload', 'flush_accumulator', 'get_compression_stats', "
- f"'system3_dialectical_synthesis', 'system3_causal_simulate', 'system3_evolve_paradigms', 'system3_induce_axioms', 'system3_meta_reflect', 'system3_tri_level_orchestrate', "
- f"'system3_hyperbolic_embed', 'system3_kripke_verify', 'system3_active_inference', 'system3_proof_oracle'."
- )
- except Exception as ex:
- return f"Error: {str(ex)}"
-
-
-# --------------------------------------------------------------------------------
-# JSON-RPC 2.0 MCP Protocol Stdio Loop
-# --------------------------------------------------------------------------------
+CONTROL_PLANE_TOOL_SCHEMA = {
+ "name": CONTROL_PLANE_TOOL_NAME,
+ "description": (
+ "Strict MCP control-plane for observe → predict → propose → outcome → verify → finalize. "
+ "Sequence, session binding, and idempotency are enforced here. Host/native tool authorization "
+ "is advisory unless the invocation is routed through a broker; model-supplied PROVEN/final authorization is rejected."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "profile": {"type": "string", "const": CONTROL_PLANE_PROFILE},
+ "action": {"type": "string", "enum": list(CONTROL_PLANE_ACTIONS) + ["capabilities"]},
+ "session_id": {"type": "string", "pattern": SESSION_ID_PATTERN.pattern},
+ "session_name": {"type": "string", "pattern": SESSION_NAME_PATTERN.pattern},
+ "objective": {"type": "string", "minLength": 8},
+ "observation": {},
+ "prediction": {"type": "string", "minLength": 8},
+ "prediction_id": {"type": "string"},
+ "action_name": {"type": "string", "minLength": 2, "description": "Concrete host operation; generic boilerplate names are rejected."},
+ "capability": {"type": "string", "minLength": 1, "description": "Capability name which the host receipt must attest."},
+ "arguments": {"type": "object"},
+ "input_hash": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$", "description": "Optional explicit hash; it must equal the canonical action arguments."},
+ "mutating": {"type": "boolean", "const": True},
+ "action_id": {"type": "string"},
+ "outcome": {},
+ "receipt_id": {"type": "string"},
+ "outcome_id": {"type": "string"},
+ "checks": {"type": "array", "items": {"type": "string"}, "minItems": 1},
+ "verification_id": {"type": "string"},
+ "idempotency_key": {"type": "string", "pattern": CONTROL_PLANE_IDEMPOTENCY_PATTERN.pattern},
+ # Explicitly named forbidden fields make the contract auditable;
+ # server-side validation rejects them even if a host ignores JSON Schema.
+ "tag": {"not": {}}, "proven": {"not": {}}, "final_authorized": {"not": {}},
+ "authorized": {"not": {}}, "authorization": {"not": {}}, "approval": {"not": {}},
+ },
+ "required": ["action"],
+ "allOf": [
+ {"if": {"properties": {"action": {"enum": list(CONTROL_PLANE_ACTIONS)}}},
+ "then": {"required": ["idempotency_key"]}},
+ ],
+ },
+ "outputSchema": {
+ "type": "object", "required": ["ok", "profile", "action", "enforcement"],
+ "properties": {"ok": {"type": "boolean"}, "profile": {"type": "string"},
+ "action": {"type": ["string", "null"]}, "session_id": {"type": ["string", "null"]},
+ "state": {"type": ["string", "null"]}, "result": {}, "error": {},
+ "enforcement": {"type": "object"}},
+ "additionalProperties": True,
+ },
+}
TOOL_SCHEMA = {
"name": "fable_session",
"description": (
- "Fable Cognitive Engine Session & Telemetry Manager for MCP-compatible agent hosts.\n"
+ "Legacy fable_session compatibility surface for MCP-compatible agent hosts; compatibility_mode is optional and, when supplied, must be legacy_v1.\n"
"Enforces DeepThink cognitive rigor, hard mechanical time-lock, anti-rush execution lockout, epistemic truth logging (PROVEN/HYPOTHESIS/UNKNOWN),\n"
"formal domain invariant modeling, continuous rethink-refine cycles, phased progression gating, subagent delegation contract compilation,\n"
- "live user-controlled time-budgeted pacing telemetry, token compression subsystem (Content-Addressed Storage, 0.003 tokens/character invariant),\n"
- "and System 3 Meta-Cognitive Deliberation & Dialectical Evolutionary Architecture (Pearl do-calculus, TRIZ contradiction synthesis, 10D Pareto genetic search, axiom induction)."
+ "live user-controlled time-budgeted pacing telemetry, and token compression subsystem (Content-Addressed Storage; token counts and ratios are rough character-based estimates, not measured model-token usage or guarantees)."
),
"inputSchema": {
"type": "object",
@@ -3376,20 +3772,14 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
"view_slice",
"accumulate_payload",
"flush_accumulator",
- "get_compression_stats",
- "system3_dialectical_synthesis",
- "system3_causal_simulate",
- "system3_evolve_paradigms",
- "system3_induce_axioms",
- "system3_meta_reflect",
- "system3_tri_level_orchestrate",
- "system3_hyperbolic_embed",
- "system3_kripke_verify",
- "system3_active_inference",
- "system3_proof_oracle"
+ "get_compression_stats"
],
"description": "The Fable session action to perform."
},
+ "compatibility_mode": {
+ "type": "string", "enum": ["legacy_v1"],
+ "description": "Optional selector for the legacy fable_session compatibility surface; when supplied it must be legacy_v1. This is not the strict control plane."
+ },
"session_name": {
"type": "string",
"description": "Unique identifier / name for the Fable session."
@@ -3408,8 +3798,24 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
"description": "Target phase to advance the session into."
},
"phase_summary": {
- "type": "string",
- "description": "Concise summary of findings or deliverables completed in the previous phase."
+ "oneOf": [
+ {"type": "string", "minLength": 8},
+ {"type": "object", "required": ["summary"], "properties": {
+ "summary": {"type": "string", "minLength": 8},
+ "evidence": {"type": "object"}
+ }, "additionalProperties": False}
+ ],
+ "description": "Substantive phase summary; an object may carry typed evidence bindings."
+ },
+ "phase_evidence": {
+ "type": "object",
+ "properties": {
+ "receipt_ids": {"type": "array", "items": {"type": "string"}},
+ "invariant_ids": {"type": "array", "items": {"type": "string"}},
+ "refinement_ids": {"type": "array", "items": {"type": "string"}}
+ },
+ "additionalProperties": False,
+ "description": "Typed phase prerequisite bindings validated within the session."
},
"tag": {
"type": "string",
@@ -3421,8 +3827,16 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
"description": "Fact, hypothesis statement, or unknown parameter to track in epistemic ledger."
},
"evidence": {
- "type": "string",
- "description": "Source file, command output, line number, or URL supporting the claim."
+ "description": "Receipt-bound evidence object. Legacy citation strings are accepted for display only and cannot satisfy an unlock gate.",
+ "oneOf": [
+ {"type": "string"},
+ {"type": "object", "required": ["receipt_id", "session_id", "content_hash", "source_output_hash", "claim"], "properties": {
+ "receipt_id": {"type": "string"}, "session_id": {"type": "string"},
+ "content": {}, "content_hash": {"type": "string"},
+ "source_output_hash": {"type": "string"}, "claim": {"type": "string"},
+ "kind": {"type": "string"}, "source": {"type": "string"}
+ }, "additionalProperties": True}
+ ]
},
"invariant_name": {
"type": "string",
@@ -3508,215 +3922,37 @@ def handle_fable_session(arguments: Dict[str, Any]) -> str:
"metadata": {
"type": "object",
"description": "Optional metadata dictionary attached to accumulated micro-payload."
- },
- "thesis_title": {
- "type": "string",
- "description": "Title of the thesis paradigm for System 3 dialectical synthesis."
- },
- "thesis_description": {
- "type": "string",
- "description": "Core architecture description and assumptions for the thesis candidate."
- },
- "antithesis_title": {
- "type": "string",
- "description": "Title of the antithesis / adversarial critique."
- },
- "contradictions": {
- "description": "List of parameter trade-offs / contradictions to resolve with TRIZ principles.",
- "type": ["array", "string"]
- },
- "failure_modes": {
- "description": "List of adversarial failure modes identified in critique.",
- "type": ["array", "string"]
- },
- "max_debate_rounds": {
- "type": "integer",
- "description": "Maximum number of dialectical debate rounds for synthesis (default 4)."
- },
- "target_residual_threshold": {
- "type": "number",
- "description": "Target residual contradiction score threshold for convergence (default 0.15)."
- },
- "model_name": {
- "type": "string",
- "description": "Name for the causal DAG model in System 3 simulation."
- },
- "nodes": {
- "description": "List of node dictionaries for Causal DAG construction.",
- "type": ["array", "string"]
- },
- "edges": {
- "description": "List of directed edge dictionaries for Causal DAG construction.",
- "type": ["array", "string"]
- },
- "interventions": {
- "description": "Dictionary of Pearl's do-operator interventions: {node_id: value}.",
- "type": ["object", "string"]
- },
- "target_metric": {
- "type": "string",
- "description": "Target KPI node ID for sensitivity and structural brittleness analysis."
- },
- "generations": {
- "type": "integer",
- "description": "Number of evolutionary generations to run (default 3)."
- },
- "population_size": {
- "type": "integer",
- "description": "Population size for evolutionary gene pool (default 12)."
- },
- "mutation_rate": {
- "type": "number",
- "description": "Genetic mutation rate probability (default 0.15)."
- },
- "crossover_rate": {
- "type": "number",
- "description": "Genetic crossover rate probability (default 0.80)."
- },
- "seed_paradigms": {
- "description": "Optional list of initial paradigm definitions to seed the gene pool.",
- "type": ["array", "string"]
- },
- "objective_weights": {
- "description": "Optional dictionary of weights across the 10 Pareto dimensions.",
- "type": ["object", "string"]
- },
- "task_complexity": {
- "type": "number",
- "description": "Task complexity index [0.0, 1.0] for tri-level cognitive arbitration."
- },
- "contradiction_density": {
- "type": "number",
- "description": "Contradiction density index [0.0, 1.0] for cognitive gear shifting."
- },
- "failure_count": {
- "type": "integer",
- "description": "Historical failure count for tri-level cognitive gear arbitration."
- },
- "epistemic_uncertainty": {
- "type": "number",
- "description": "Epistemic uncertainty index [0.0, 1.0] for arbitration."
- },
- "tree": {
- "description": "Tree hierarchy adjacency list or nested dict for hyperbolic embedding.",
- "type": ["object", "array", "string"]
- },
- "root_id": {
- "type": "string",
- "description": "Root node ID for hyperbolic tree embedding or initial world."
- },
- "dimension": {
- "type": "integer",
- "description": "Dimension of Poincaré ball manifold (default 2)."
- },
- "curvature": {
- "type": "number",
- "description": "Sectional curvature c > 0 of Poincaré manifold (default 1.0)."
- },
- "base_step": {
- "type": "number",
- "description": "Base geodesic step distance for hyperbolic tree embedding (default 1.0)."
- },
- "node_labels": {
- "description": "Optional mapping of node IDs to readable labels.",
- "type": ["object", "string"]
- },
- "worlds": {
- "description": "List of world/state definitions for Kripke structure.",
- "type": ["array", "string"]
- },
- "transitions": {
- "description": "List of transitions or adjacency dictionary for Kripke structure.",
- "type": ["array", "object", "string"]
- },
- "formula": {
- "type": "string",
- "description": "CTL / modal formula string to verify against Kripke structure (e.g. 'AG(safe)', 'EF(goal)')."
- },
- "initial_world": {
- "type": "string",
- "description": "Initial world ID for Kripke model checking."
- },
- "observation": {
- "type": "string",
- "description": "Current sensory observation for Active Inference belief updating."
- },
- "policies": {
- "description": "List of candidate action sequence policies for Expected Free Energy evaluation.",
- "type": ["array", "string"]
- },
- "gamma": {
- "type": "number",
- "description": "Policy precision inverse temperature gamma for Active Inference softmax (default 16.0)."
- },
- "states": {
- "description": "List of hidden state identifiers for Active Inference POMDP.",
- "type": ["array", "string"]
- },
- "observations": {
- "description": "List of observation identifiers for Active Inference POMDP.",
- "type": ["array", "string"]
- },
- "actions": {
- "description": "List of control action identifiers for Active Inference POMDP.",
- "type": ["array", "string"]
- },
- "a_matrix": {
- "description": "Observation likelihood matrix A [O x S] for Active Inference.",
- "type": ["array", "string"]
- },
- "b_matrices": {
- "description": "State transition matrices B [Action -> S x S] for Active Inference.",
- "type": ["object", "string"]
- },
- "c_preferences": {
- "description": "Prior preference distribution C over observations for Active Inference.",
- "type": ["array", "string"]
- },
- "d_prior": {
- "description": "Prior initial state belief distribution D for Active Inference.",
- "type": ["array", "string"]
- },
- "context": {
- "description": "Hypothesis typing context dictionary {name: type} for proof oracle.",
- "type": ["object", "string"]
- },
- "axioms": {
- "description": "List of reference axiom names or strings for proof oracle.",
- "type": ["array", "string"]
}
},
- "required": ["action"]
+ "required": ["action"],
+ "allOf": [{
+ "if": {"properties": {"action": {"enum": sorted(CAS_ACTIONS)}}},
+ "then": {"required": ["session_name"]}
+ }]
+ },
+ "outputSchema": {
+ "type": "object",
+ "required": ["ok", "action", "text", "isError"],
+ "properties": {
+ "ok": {"type": "boolean"}, "action": {"type": ["string", "null"]},
+ "text": {"type": "string"}, "isError": {"type": "boolean"},
+ "error": {"type": ["object", "null"]},
+ "host_honesty": {"type": "object"}
+ },
+ "additionalProperties": True
}
}
def send_response(response_dict: Dict[str, Any]):
- """Writes a bounded JSON-RPC response to stdout with UTF-8 safety."""
- encoded = json.dumps(response_dict, ensure_ascii=False)
- encoded_bytes = encoded.encode("utf-8")
- if len(encoded_bytes) > MAX_RPC_RESPONSE_BYTES:
- response_dict = {
- "jsonrpc": "2.0",
- "id": response_dict.get("id") if isinstance(response_dict, dict) else None,
- "error": {"code": -32000, "message": "Response exceeds maximum size"}
- }
- encoded = json.dumps(response_dict, ensure_ascii=False)
- encoded_bytes = encoded.encode("utf-8")
- payload = encoded_bytes + b"\n"
- if hasattr(sys.stdout, "buffer") and sys.stdout.buffer is not None:
- try:
- sys.stdout.buffer.write(payload)
- sys.stdout.buffer.flush()
- return
- except Exception:
- pass
- try:
- sys.stdout.write(encoded + "\n")
- sys.stdout.flush()
- except UnicodeEncodeError:
- sys.stdout.write(payload.decode("utf-8", errors="replace"))
- sys.stdout.flush()
+ """Writes a bounded JSON-RPC response to stdout."""
+ encoded = json.dumps(response_dict)
+ if len(encoded.encode("utf-8")) > MAX_RPC_RESPONSE_BYTES:
+ response_dict = {"jsonrpc": "2.0", "id": response_dict.get("id"),
+ "error": {"code": -32000, "message": "Response exceeds maximum size"}}
+ encoded = json.dumps(response_dict)
+ sys.stdout.write(encoded + "\n")
+ sys.stdout.flush()
def _bounded_lines(stream, limit: int):
@@ -3759,139 +3995,418 @@ def _bounded_lines(stream, limit: int):
del pending[limit:]
+class RequestCancellationRegistry:
+ """Bounded pre-dispatch cancellation tombstones for synchronous V1.
+
+ V1 cannot interrupt an active handler, so a cancellation notification is
+ retained only long enough to reject a request that is about to dispatch.
+ Tombstones are deliberately bounded and expiring: a peer can send
+ arbitrary notifications, including IDs for requests that never arrive,
+ but cannot grow this process's memory without limit.
+ """
+
+ def __init__(self):
+ self._cancelled: dict[str, float] = {}
+ self._cancelled_bytes = 0
+ self._lock = threading.Lock()
+
+ @staticmethod
+ def key(request_id: Any) -> str:
+ """Canonicalize and bound a JSON-RPC request ID for registry use."""
+ if isinstance(request_id, bool) or request_id is None:
+ raise ValueError("request_id must be a string or number")
+ if not isinstance(request_id, (str, int, float)):
+ raise ValueError("request_id must be a string or number")
+ if isinstance(request_id, float) and not math.isfinite(request_id):
+ raise ValueError("request_id must be a finite number")
+ key = json.dumps(request_id, sort_keys=True, separators=(",", ":"))
+ if len(key.encode("utf-8")) > MAX_CANCEL_REQUEST_ID_BYTES:
+ raise ValueError("request_id exceeds cancellation ID size limit")
+ return key
+
+ def _remove_locked(self, key: str) -> None:
+ if key in self._cancelled:
+ self._cancelled.pop(key, None)
+ self._cancelled_bytes = max(
+ 0, self._cancelled_bytes - len(key.encode("utf-8")))
+
+ def _cleanup_expired_locked(self, now: Optional[float] = None) -> None:
+ now = time.monotonic() if now is None else now
+ for key, expiry in list(self._cancelled.items()):
+ if expiry <= now:
+ self._remove_locked(key)
+
+ def _add_locked(self, key: str, now: Optional[float] = None) -> bool:
+ now = time.monotonic() if now is None else now
+ self._cleanup_expired_locked(now)
+ key_bytes = len(key.encode("utf-8"))
+ if key in self._cancelled:
+ # Refreshing an existing tombstone must not double-charge bytes.
+ self._cancelled[key] = now + CANCEL_TOMBSTONE_TTL_SECONDS
+ return True
+ # Evict oldest-expiring entries until both independent bounds hold.
+ # Every key is checked above, so an individual key cannot exceed the
+ # byte budget and force an unbounded/partial insertion.
+ while (len(self._cancelled) >= MAX_CANCEL_TOMBSTONES or
+ self._cancelled_bytes + key_bytes > MAX_CANCEL_TOMBSTONE_BYTES):
+ if not self._cancelled:
+ return False
+ victim = min(self._cancelled, key=self._cancelled.get)
+ self._remove_locked(victim)
+ self._cancelled[key] = now + CANCEL_TOMBSTONE_TTL_SECONDS
+ self._cancelled_bytes += key_bytes
+ return True
+
+ def cancel(self, request_id: Any) -> None:
+ key = self.key(request_id)
+ with self._lock:
+ self._add_locked(key)
+
+ def is_cancelled(self, request_id: Any) -> bool:
+ key = self.key(request_id)
+ with self._lock:
+ self._cleanup_expired_locked()
+ return key in self._cancelled
+
+ def consume(self, request_id: Any) -> bool:
+ """Atomically test and remove a pre-dispatch cancellation."""
+ key = self.key(request_id)
+ with self._lock:
+ self._cleanup_expired_locked()
+ if key not in self._cancelled:
+ return False
+ self._remove_locked(key)
+ return True
+
+ def clear(self, request_id: Any) -> None:
+ """Forget a completed or otherwise abandoned request ID."""
+ key = self.key(request_id)
+ with self._lock:
+ self._remove_locked(key)
+ self._cleanup_expired_locked()
+
+
+def _valid_rpc_id(value: Any) -> bool:
+ """JSON-RPC IDs are strings or finite numbers; booleans are not IDs."""
+ if isinstance(value, bool) or value is None:
+ return False
+ if isinstance(value, float) and not math.isfinite(value):
+ return False
+ return isinstance(value, (str, int, float))
+
+
+def _rpc_error(msg_id: Any, code: int, message: str) -> Dict[str, Any]:
+ return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
+
+
+def _strict_json_constant(token: str) -> Any:
+ raise ValueError(f"non-standard JSON constant is not permitted: {token}")
+
+
+def _strict_json_loads(value: str) -> Any:
+ return json.loads(value, parse_constant=_strict_json_constant)
+
+
+def _deadline_seconds(params: Dict[str, Any]) -> Optional[float]:
+ """Parse request deadlines and reject silently ignored nested legacy forms."""
+ # Only the two documented V1 locations are inspected. Older clients have
+ # sent deadline fields below ``_meta.request`` (or inside arguments); those
+ # must not be silently ignored and then run without a deadline.
+ def nested_deadline_path(value: Any, path: tuple[str, ...] = ()) -> Optional[tuple[str, ...]]:
+ if isinstance(value, dict):
+ for key, child in value.items():
+ if key in {"deadline_seconds", "deadline", "request_deadline"}:
+ if path == () or path == ("_meta",):
+ continue
+ return path + (str(key),)
+ found = nested_deadline_path(child, path + (str(key),))
+ if found is not None:
+ return found
+ elif isinstance(value, list):
+ for index, child in enumerate(value):
+ found = nested_deadline_path(child, path + (str(index),))
+ if found is not None:
+ return found
+ return None
+
+ unsupported_path = nested_deadline_path(params)
+ if unsupported_path is not None:
+ raise ValueError("unsupported nested legacy deadline field: " + ".".join(unsupported_path))
+ value = params.get("deadline_seconds")
+ if value is None and isinstance(params.get("_meta"), dict):
+ value = params["_meta"].get("deadline_seconds")
+ # A legacy top-level ``deadline`` is also explicitly unsupported rather
+ # than being mistaken for an enforceable deadline.
+ if value is None and any(key in params for key in ("deadline", "request_deadline")):
+ raise ValueError("unsupported legacy deadline field")
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ raise ValueError("deadline_seconds must be a finite positive number")
+ try:
+ seconds = float(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("deadline_seconds must be a finite positive number") from exc
+ if not math.isfinite(seconds) or not (0 < seconds <= MAX_REQUEST_DEADLINE_SECONDS):
+ raise ValueError(f"deadline_seconds must be between 0 and {MAX_REQUEST_DEADLINE_SECONDS}")
+ return seconds
+
+
+def _tool_result(msg_id: Any, text: str, *, action: Optional[str] = None,
+ is_error: bool = False, error_code: str = "tool_error",
+ structured: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ encoded_text = str(text)
+ if len(encoded_text.encode("utf-8", "replace")) > MAX_TOOL_TEXT_BYTES:
+ raw = encoded_text.encode("utf-8", "replace")
+ encoded_text = raw[:MAX_TOOL_TEXT_BYTES].decode("utf-8", "ignore") + " [truncated]"
+ if structured is None:
+ structured = {"ok": not is_error, "action": action, "text": encoded_text,
+ "isError": is_error,
+ "error": ({"code": error_code, "message": encoded_text} if is_error else None),
+ "host_honesty": {
+ "tool_authorization": "external_host_required",
+ "interruptive_control": INTERRUPTIVE_CONTROL,
+ "session_gate_is_not_a_sandbox": True,
+ }}
+ else:
+ structured = copy.deepcopy(structured)
+ structured.setdefault("ok", not is_error)
+ structured.setdefault("action", action)
+ structured.setdefault("isError", is_error)
+ structured.setdefault("text", encoded_text)
+ if is_error:
+ structured.setdefault("error", {"code": error_code, "message": encoded_text})
+ return {"jsonrpc": "2.0", "id": msg_id,
+ "result": {"content": [{"type": "text", "text": encoded_text}],
+ "structuredContent": structured, "isError": is_error}}
+
+
def main():
logger.info("Starting Fable-Engine MCP Server on stdio...")
+ # Protocol state is per stdio connection, not global process state.
+ initialized = False
+ client_ready = False
+ negotiated_version: Optional[str] = None
+ cancelled = RequestCancellationRegistry()
for line, oversized in _bounded_lines(sys.stdin, MAX_RPC_LINE_BYTES):
if oversized:
- send_response({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}})
+ send_response(_rpc_error(None, -32600, "Invalid Request"))
continue
- # Count the raw frame before trimming whitespace; otherwise an attacker
- # can bypass the line limit with an oversized whitespace prefix/suffix.
+ # Count the raw frame before trimming whitespace, including whitespace.
if len(line.encode("utf-8", "replace")) > MAX_RPC_LINE_BYTES:
- send_response({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}})
+ send_response(_rpc_error(None, -32600, "Invalid Request"))
continue
line = line.strip()
if not line:
continue
-
if len(line.encode("utf-8", "replace")) > MAX_RPC_LINE_BYTES:
- send_response({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}})
+ send_response(_rpc_error(None, -32600, "Invalid Request"))
continue
try:
- req = json.loads(line)
+ req = _strict_json_loads(line)
except Exception:
- send_response({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}})
+ # A parse error has no request ID and is not a valid notification.
+ send_response(_rpc_error(None, -32700, "Parse error"))
+ continue
+ # V1 deliberately has a single-request policy. This avoids ambiguous
+ # ordering and makes cancellation/deadline state deterministic.
+ if isinstance(req, list):
+ send_response(_rpc_error(None, -32600, "Invalid Request: batches are not supported by V1"))
continue
- # JSON-RPC requests are objects; arrays and scalar values must not
- # reach req.get() and crash the stdio server.
if not isinstance(req, dict):
- send_response({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}})
+ send_response(_rpc_error(None, -32600, "Invalid Request"))
+ continue
+ has_id = "id" in req
+ msg_id = req.get("id")
+ if has_id and not _valid_rpc_id(msg_id):
+ send_response(_rpc_error(None, -32600, "Invalid Request: id must be a string or finite number"))
continue
+ def _clear_request_id() -> None:
+ if has_id:
+ try:
+ cancelled.clear(msg_id)
+ except ValueError:
+ # IDs larger than the cancellation registry's bound were
+ # never retained and need no cleanup.
+ pass
+
if req.get("jsonrpc") != "2.0" or not isinstance(req.get("method"), str):
- send_response({"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32600, "message": "Invalid Request"}})
+ send_response(_rpc_error(msg_id if has_id and _valid_rpc_id(msg_id) else None, -32600, "Invalid Request"))
+ _clear_request_id()
continue
if "params" in req and not isinstance(req["params"], dict):
- send_response({"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32600, "message": "Invalid Request"}})
+ # Retain the V1 compatibility behavior for malformed params, but
+ # never answer a notification.
+ if has_id:
+ send_response(_rpc_error(msg_id, -32600, "Invalid Request"))
+ _clear_request_id()
continue
-
- msg_id = req.get("id")
- method = req.get("method")
+ method = req["method"]
params = req.get("params", {})
+ is_notification = not has_id
+
+ if method == "notifications/cancelled":
+ request_id = params.get("requestId")
+ if _valid_rpc_id(request_id):
+ try:
+ cancelled.cancel(request_id)
+ except ValueError:
+ # Oversized IDs are valid at the JSON-RPC framing layer but
+ # are not retained by the bounded cancellation registry.
+ pass
+ else:
+ logger.warning("Cancellation recorded for request %r; interruptive cancellation is unsupported by synchronous V1", request_id)
+ _clear_request_id()
+ continue
if method == "initialize":
- send_response({
- "jsonrpc": "2.0",
- "id": msg_id,
+ if initialized:
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32600, "Server already initialized"))
+ _clear_request_id()
+ continue
+ requested = params.get("protocolVersion", SERVER_PROTOCOL_VERSION)
+ if not isinstance(requested, str):
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32602, "protocolVersion must be a string"))
+ _clear_request_id()
+ continue
+ if requested not in SUPPORTED_PROTOCOL_VERSIONS:
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32602, "Unsupported protocol version"))
+ _clear_request_id()
+ continue
+ initialized = True
+ negotiated_version = requested
+ response = {
+ "jsonrpc": "2.0", "id": msg_id,
"result": {
- "protocolVersion": "2024-11-05",
+ "protocolVersion": negotiated_version,
"capabilities": {
- "tools": {}
+ "tools": {"listChanged": False},
+ "fableV1": {
+ "interruptiveCancellation": False,
+ "requestDeadlines": "unsupported_synchronous_v1",
+ "hostToolAuthorization": "external_host_required"
+ },
+ "fableControlPlane": control_plane_capabilities()
},
- "serverInfo": {
- "name": "fable-engine",
- "version": "1.2.3"
- }
+
+ "serverInfo": {"name": "fable-engine", "version": "1.2.3"}
}
- })
+ }
+ if not is_notification:
+ send_response(response)
+ _clear_request_id()
+ continue
- elif method == "notifications/initialized":
- logger.info("Fable client handshake complete.")
+ if method == "notifications/initialized":
+ if initialized:
+ client_ready = True
+ logger.info("Fable client handshake complete.")
+ _clear_request_id()
+ continue
- elif method == "ping":
- send_response({
- "jsonrpc": "2.0",
- "id": msg_id,
- "result": {}
- })
+ # MCP tools are unavailable until initialize has completed.
+ if not initialized:
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32002, "Server not initialized"))
+ _clear_request_id()
+ continue
- elif method == "tools/list":
- send_response({
- "jsonrpc": "2.0",
- "id": msg_id,
- "result": {
- "tools": [TOOL_SCHEMA]
- }
- })
+ if method == "ping":
+ if not is_notification:
+ send_response({"jsonrpc": "2.0", "id": msg_id, "result": {}})
+ _clear_request_id()
+ continue
- elif method == "tools/call":
+ if method == "tools/list":
+ if not is_notification:
+ send_response({"jsonrpc": "2.0", "id": msg_id, "result": {"tools": [TOOL_SCHEMA, CONTROL_PLANE_TOOL_SCHEMA]}})
+ _clear_request_id()
+ continue
+
+ if method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
if not isinstance(tool_name, str) or not isinstance(arguments, dict):
- send_response({"jsonrpc": "2.0", "id": msg_id,
- "error": {"code": -32600, "message": "Invalid Request"}})
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32602, "tools/call requires string name and object arguments"))
+ _clear_request_id()
continue
+ try:
+ deadline = _deadline_seconds(params)
+ except ValueError as exc:
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32602, str(exc)))
+ _clear_request_id()
+ continue
+ try:
+ request_was_cancelled = cancelled.consume(msg_id) if has_id else False
+ except ValueError:
+ # The ordinary RPC ID validation is intentionally broader than
+ # the cancellation registry's memory bound. Such an ID can be
+ # served, but cannot participate in pre-dispatch cancellation.
+ request_was_cancelled = False
+ if request_was_cancelled:
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32800, "Request cancelled"))
+ continue
+ # There is no reader/control thread or killable worker boundary in
+ # V1's synchronous stdio loop. Reject deadlines instead of running
+ # work past them and relabelling the result afterwards.
+ if deadline is not None:
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32003,
+ "Unsupported: V1 synchronous server cannot enforce interruptive request deadlines"))
+ _clear_request_id()
+ continue
+ if tool_name not in {"fable_session", CONTROL_PLANE_TOOL_NAME}:
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32601, f"Method / Tool '{tool_name}' not found."))
+ _clear_request_id()
+ continue
+ if tool_name == CONTROL_PLANE_TOOL_NAME:
+ strict_result = handle_fable_control_plane(arguments)
+ is_error = strict_result.get("ok") is not True
+ action = arguments.get("action") if isinstance(arguments.get("action"), str) else None
+ if not is_notification:
+ send_response(_tool_result(msg_id, json.dumps(strict_result, ensure_ascii=False, separators=(",", ":")),
+ action=action, is_error=is_error,
+ error_code=(strict_result.get("error") or {}).get("code", "control_plane_error"),
+ structured=strict_result))
+ _clear_request_id()
+ continue
+ action, validation_error = _validate_tool_arguments(arguments)
+ if validation_error:
+ if not is_notification:
+ send_response(_tool_result(msg_id, f"Error: Invalid arguments: {validation_error}", action=action, is_error=True, error_code="invalid_arguments"))
+ _clear_request_id()
+ continue
+ try:
+ result_text = handle_fable_session(arguments)
+ is_error = result_text.startswith("Error:")
+ # fable_session is the legacy surface. Callers should set
+ # compatibility_mode=legacy_v1 explicitly; the direct Python
+ # API remains for source compatibility with existing clients.
+ if not is_notification:
+ send_response(_tool_result(msg_id, result_text, action=action,
+ is_error=is_error,
+ error_code="action_failed" if is_error else ""))
+ except Exception as ex:
+ logger.error(f"Error handling fable_session: {ex}", exc_info=True)
+ if not is_notification:
+ send_response(_tool_result(msg_id, f"Fable Engine Error: {ex}", action=action,
+ is_error=True, error_code="internal_error"))
+ finally:
+ # Completed requests, including failures, must not leave
+ # reusable IDs in the cancellation registry.
+ _clear_request_id()
+ continue
- if tool_name == "fable_session":
- try:
- result_text = handle_fable_session(arguments)
- send_response({
- "jsonrpc": "2.0",
- "id": msg_id,
- "result": {
- "content": [
- {
- "type": "text",
- "text": result_text
- }
- ],
- "isError": False
- }
- })
- except Exception as ex:
- logger.error(f"Error handling fable_session: {ex}", exc_info=True)
- send_response({
- "jsonrpc": "2.0",
- "id": msg_id,
- "result": {
- "content": [
- {
- "type": "text",
- "text": f"Fable Engine Error: {str(ex)}"
- }
- ],
- "isError": True
- }
- })
- else:
- send_response({
- "jsonrpc": "2.0",
- "id": msg_id,
- "error": {
- "code": -32601,
- "message": f"Method / Tool '{tool_name}' not found."
- }
- })
-
- else:
- if msg_id is not None:
- send_response({
- "jsonrpc": "2.0",
- "id": msg_id,
- "error": {
- "code": -32601,
- "message": f"Unrecognized JSON-RPC method: {method}"
- }
- })
+ if not is_notification:
+ send_response(_rpc_error(msg_id, -32601, f"Unrecognized JSON-RPC method: {method}"))
+ _clear_request_id()
if __name__ == "__main__":
diff --git a/fable_engine/test_server.py b/fable_engine/test_server.py
index e4aae17..dffe8c4 100644
--- a/fable_engine/test_server.py
+++ b/fable_engine/test_server.py
@@ -1,1289 +1,17 @@
#!/usr/bin/env python3
-"""
-Comprehensive Unit and Integration Test Suite for Fable-Engine MCP Server.
-Tests:
-- Core session data structures & invariants
-- Anti-rush execution lockout logic & cognitive gating validation
-- Immutable authority time-lock enforcement & host-only emergency overrides
-- Continuous rethink-refine cycle tracking & telemetry
-- Epistemic ledger tracking (PROVEN, HYPOTHESIS, UNKNOWN)
-- Formal invariant modeling
-- Immutable authority deadlines and agent-only pacing telemetry
-- Persistence, atomic checkpointing, and restoration
-- Dispatch handler operations and edge-case handling
-- Full JSON-RPC 2.0 stdio MCP server protocol over subprocess
-"""
+"""Compatibility test entry point.
+The old monolithic V1 suite was retired because it encoded the pre-attested
+receipt contract. CI now discovers the focused suites under ``tests/``.
+"""
from __future__ import annotations
+import pathlib
import sys
-import os
-import json
-import time
-import subprocess
-import threading
import unittest
-import tempfile
-import shutil
-from pathlib import Path
-
-class FakeClock:
- """Deterministic wall and monotonic clocks for lock tests."""
-
- def __init__(self):
- self.wall = time.time()
- self.mono = time.monotonic()
-
- def time(self):
- return self.wall
-
- def monotonic(self):
- return self.mono
-
- def advance(self, seconds):
- self.wall += seconds
- self.mono += seconds
-
-
-# Add current directory to path
-CURRENT_DIR = Path(__file__).resolve().parent
-if str(CURRENT_DIR) not in sys.path:
- sys.path.insert(0, str(CURRENT_DIR))
-
-from server import (
- FableSession,
- ACTIVE_SESSIONS,
- SESSIONS_DIR,
- PHASES,
- PHASE_INDEX_MAP,
- handle_fable_session,
- TOOL_SCHEMA,
- FableCASError,
- IntegrityError,
- CASNotFoundError,
- ThreadSafeLRUCache,
- FableCASStore,
- CompositeFrame,
- AdaptiveChunkAccumulator,
- FableGrammar333,
- CASSliceViewer,
- FableCompress,
- CAS_ENGINE
-)
-
-
-class TestFableSessionCore(unittest.TestCase):
- """Tests core FableSession lifecycle, telemetry, refinement cycles, and anti-rush gates."""
-
- def setUp(self):
- self.clock = FakeClock()
- self.session_name = f"test_unit_{int(time.time() * 1000)}"
- self.session = FableSession(
- session_name=self.session_name,
- objective="Architect a lock-free multi-producer ring buffer",
- time_budget_minutes=45.0,
- wall_clock=self.clock.time,
- monotonic_clock=self.clock.monotonic
- )
-
- def tearDown(self):
- # Cleanup test session files if any
- target_file = SESSIONS_DIR / f"{self.session_name}.json"
- if target_file.exists():
- try:
- target_file.unlink()
- except Exception:
- pass
- if self.session_name in ACTIVE_SESSIONS:
- del ACTIVE_SESSIONS[self.session_name]
-
- def test_initialization_defaults(self):
- """Verifies session initializes in locked state with proper phase, pacing, and refinement structures."""
- self.assertEqual(self.session.session_name, self.session_name)
- self.assertTrue(self.session.execution_locked)
- self.assertFalse(self.session.can_execute_code)
- self.assertEqual(self.session.active_phase, "Phase 1: Epistemic Grounding & Live Research")
- self.assertEqual(self.session.time_budget_minutes, 45.0)
- self.assertEqual(self.session.time_budget_seconds, 2700.0)
- self.assertEqual(len(self.session.epistemic_ledger), 0)
- self.assertEqual(len(self.session.invariants), 0)
- self.assertEqual(len(self.session.refinement_cycles), 0)
- self.assertEqual(len(self.session.phase_history), 1)
-
- tel = self.session.get_telemetry()
- self.assertEqual(tel["refinement_count"], 0)
- self.assertEqual(tel["refinement_cycles"], [])
-
- def test_timer_adjustment_is_pacing_only(self):
- """An agent sub-timer cannot shorten the authority deadline."""
- authority_deadline = self.session.deadline_time
- tel = self.session.set_timer(20.0)
- self.assertEqual(self.session.time_budget_minutes, 45.0)
- self.assertEqual(self.session.time_budget_seconds, 2700.0)
- self.assertEqual(self.session.pacing_budget_minutes, 20.0)
- self.assertEqual(self.session.deadline_time, authority_deadline)
- self.assertLess(self.session.pacing_deadline_time, authority_deadline)
- self.assertIn("authority_remaining_formatted", tel)
- self.assertIn("pacing_remaining_formatted", tel)
- self.assertIn("cognitive_gates", tel)
-
- def test_epistemic_ledger_logging(self):
- """Verifies logging fact, hypothesis, and unknown items."""
- item1 = self.session.log_epistemic_item("PROVEN", "Ring buffer power of 2 size allows bitwise mask", f"{__file__}:L42")
- self.assertEqual(item1["id"], "epi_001")
- self.assertEqual(item1["tag"], "PROVEN")
- self.assertEqual(item1["evidence"], f"{__file__}:L42")
-
- item2 = self.session.log_epistemic_item("HYPOTHESIS", "Acquire-Release fences suffice for x86 TSO")
- self.assertEqual(item2["id"], "epi_002")
- self.assertEqual(item2["tag"], "HYPOTHESIS")
-
- item3 = self.session.log_epistemic_item("UNKNOWN", "Cache line size on target ARM64 architecture")
- self.assertEqual(item3["id"], "epi_003")
- self.assertEqual(item3["tag"], "UNKNOWN")
-
- # Invalid tag test
- with self.assertRaises(ValueError):
- self.session.log_epistemic_item("INVALID_TAG", "Some statement")
-
- # Empty claim test
- with self.assertRaises(ValueError):
- self.session.log_epistemic_item("PROVEN", " ")
-
- tel = self.session.get_telemetry()
- self.assertEqual(tel["epistemic_counts"]["proven"], 1)
- self.assertEqual(tel["epistemic_counts"]["hypothesis"], 1)
- self.assertEqual(tel["epistemic_counts"]["unknown"], 1)
- self.assertEqual(tel["epistemic_counts"]["total"], 3)
-
- def test_invariant_recording(self):
- """Verifies formal invariant recording across domains."""
- inv = self.session.record_invariant(
- invariant_name="INV-01: Head-Tail Boundedness",
- formal_statement="0 <= (head - tail) <= BUFFER_CAPACITY at all times",
- proof_or_rationale="Enforced via CAS atomic operations with modulo mask",
- domain="architecture"
- )
- self.assertEqual(inv["id"], "inv_001")
- self.assertEqual(inv["name"], "INV-01: Head-Tail Boundedness")
- self.assertEqual(inv["domain"], "architecture")
-
- # Empty fields validation
- with self.assertRaises(ValueError):
- self.session.record_invariant("", "statement", "rationale")
- with self.assertRaises(ValueError):
- self.session.record_invariant("name", "", "rationale")
-
- def test_refinement_cycle_logging(self):
- """Verifies logging structured rethink-refine cycles with probes and artifacts."""
- cycle1 = self.session.log_refinement_cycle(
- refinement_type="archetype_exploration",
- focus_area="Memory Partitioning",
- critique_or_bottleneck="Global CAS on tail causes false sharing on multi-socket NUMA",
- architectural_refinement="Partitioned ring buffer into per-core L1 chunks with batch migration",
- terminal_probe_results="10M ops benchmark: 82ns down to 14ns latency",
- artifact_path="C:/Users/test/artifacts/partitioned_buffer.md"
- )
- self.assertEqual(cycle1["cycle_number"], 1)
- self.assertEqual(cycle1["refinement_type"], "archetype_exploration")
- self.assertEqual(cycle1["focus_area"], "Memory Partitioning")
- self.assertIn("Global CAS", cycle1["critique_or_bottleneck"])
- self.assertIn("Partitioned", cycle1["architectural_refinement"])
- self.assertEqual(cycle1["terminal_probe_results"], "10M ops benchmark: 82ns down to 14ns latency")
- self.assertEqual(cycle1["artifact_path"], "C:/Users/test/artifacts/partitioned_buffer.md")
- self.assertEqual(cycle1["phase"], self.session.active_phase)
-
- cycle2 = self.session.log_refinement_cycle(
- refinement_type="triz_contradiction_resolution",
- focus_area="Queue Full Backpressure",
- critique_or_bottleneck="Spinning burns CPU while yielding increases p99 latency",
- architectural_refinement="Adaptive exponential backoff with futex sleep fallback"
- )
- self.assertEqual(cycle2["cycle_number"], 2)
- self.assertIsNone(cycle2["terminal_probe_results"])
- self.assertIsNone(cycle2["artifact_path"])
-
- # Validate required field errors
- with self.assertRaises(ValueError):
- self.session.log_refinement_cycle("", "Focus", "Critique", "Refinement")
- with self.assertRaises(ValueError):
- self.session.log_refinement_cycle("Type", "", "Critique", "Refinement")
- with self.assertRaises(ValueError):
- self.session.log_refinement_cycle("Type", "Focus", "", "Refinement")
- with self.assertRaises(ValueError):
- self.session.log_refinement_cycle("Type", "Focus", "Critique", "")
-
- tel = self.session.get_telemetry()
- self.assertEqual(tel["refinement_count"], 2)
- self.assertEqual(len(tel["refinement_cycles"]), 2)
-
- def test_phase_transitions(self):
- """Verifies phase advancement and invalid phase handling."""
- self.session.advance_phase(
- "Phase 2: Invariant Specification & Blueprint",
- "Completed grounding and verified CPU memory model"
- )
- self.assertEqual(self.session.active_phase, "Phase 2: Invariant Specification & Blueprint")
- self.assertEqual(len(self.session.phase_history), 2)
-
- # Invalid phase test
- with self.assertRaises(ValueError):
- self.session.advance_phase("Phase 99: Non-existent", "Should fail")
-
- def test_hard_time_lock_enforcement(self):
- """
- Verifies Hard Mechanical Time-Lock:
- 1. Fails with PermissionError when the immutable authority deadline is active.
- 2. A public hard-coded token cannot bypass the lock.
- 3. An explicitly configured out-of-band secret can be used for emergency override.
- 4. Succeeds normally when the authority deadline has elapsed.
- """
- # Set up satisfying cognitive gates
- self.session.log_epistemic_item("PROVEN", "Fact 1: Lock-free atomic swap verified", f"{__file__}:L201")
- self.session.log_epistemic_item("PROVEN", "Fact 2: Modulo arithmetic holds", f"{__file__}:L202")
- self.session.record_invariant("INV-01", "tail <= head", "CAS inductive proof")
- self.session.advance_phase("Phase 2: Invariant Specification & Blueprint", "Advanced to Phase 2")
- self.session.advance_phase("Phase 3: Adversarial Red-Teaming & Falsification", "Advanced to Phase 3")
-
- # 1. Timer has not elapsed -> must throw Hard Time-Lock Violation
- with self.assertRaises(PermissionError) as ctx:
- self.session.unlock_execution("Premature attempt before timer finishes")
- self.assertIn("🛑 HARD TIME-LOCK VIOLATION", str(ctx.exception))
- self.assertIn("immutable 45.0m authority budget has not elapsed yet", str(ctx.exception))
- self.assertIn("internal pacing timer cannot unlock execution", str(ctx.exception))
- self.assertIn("Rethink-Refine Cognitive Loop", str(ctx.exception))
-
- # 2. The old public hard-coded token must not bypass the lock.
- with self.assertRaises(PermissionError):
- self.session.unlock_execution(
- "Attempted emergency unlock",
- force_override_token="USER_OVERRIDE_FORCE_UNLOCK"
- )
-
- # 3. An administrative override works only through the direct host API.
- os.environ["FABLE_FORCE_UNLOCK_TOKEN"] = "test-secret"
- try:
- res = self.session.unlock_execution(
- "Emergency unlock ordered by host administrator",
- force_override_token="test-secret"
- )
- finally:
- os.environ.pop("FABLE_FORCE_UNLOCK_TOKEN", None)
- self.assertEqual(res["status"], "UNLOCKED")
- self.assertTrue(self.session.can_execute_code)
- self.assertTrue(self.session.unlock_details["force_override_used"])
-
- # 3. Test elapsed deadline_time without override token
- new_session = FableSession(
- session_name=f"test_elapsed_{int(time.time() * 1000)}",
- objective="Test elapsed clock",
- time_budget_minutes=10.0,
- wall_clock=self.clock.time,
- monotonic_clock=self.clock.monotonic
- )
- new_session.log_epistemic_item("PROVEN", "Fact 1: Lock-free atomic swap verified", f"{__file__}:L229")
- new_session.log_epistemic_item("PROVEN", "Fact 2: Modulo arithmetic holds", f"{__file__}:L230")
- new_session.record_invariant("INV-01", "tail <= head", "CAS inductive proof")
- new_session.advance_phase("Phase 2: Invariant Specification & Blueprint", "Phase 2 ready")
- new_session.advance_phase("Phase 3: Adversarial Red-Teaming & Falsification", "Phase 3 ready")
-
- # Simulate time elapsed past deadline
- self.clock.advance(601.0)
- res = new_session.unlock_execution("Timer has naturally elapsed")
- self.assertEqual(res["status"], "UNLOCKED")
- self.assertTrue(new_session.can_execute_code)
- self.assertFalse(new_session.unlock_details["force_override_used"])
-
- def test_anti_rush_lockout_enforcement(self):
- """
- Rigorous test of Anti-Rush Cognitive Lockout Gates:
- Requires:
- 1. >= 2 PROVEN epistemic items
- 2. >= 1 formal invariant
- 3. Active phase >= Phase 3
- """
- # Simulate an elapsed authority deadline so this test isolates cognitive gates.
- self.clock.advance(2701.0)
-
- # Attempt 1: In Phase 1 with 0 proven, 0 invariants -> Must fail cognitive gate
- with self.assertRaises(PermissionError) as ctx:
- self.session.unlock_execution("Let me code")
- self.assertIn("Anti-Rush Lockout Active", str(ctx.exception))
- self.assertIn("at least 2 [PROVEN]", str(ctx.exception))
-
- # Attempt 2: Add 1 PROVEN item -> Must still fail
- self.session.log_epistemic_item("PROVEN", "Fact 1: Single producer queue is wait-free", f"{__file__}:L259")
- with self.assertRaises(PermissionError) as ctx:
- self.session.unlock_execution("Let me code")
- self.assertIn("at least 2 [PROVEN]", str(ctx.exception))
-
- # Attempt 3: Add 2nd PROVEN item, but 0 invariants -> Must still fail
- self.session.log_epistemic_item("PROVEN", "Fact 2: Atomic CAS is supported on target CPU", f"{__file__}:L265")
- with self.assertRaises(PermissionError) as ctx:
- self.session.unlock_execution("Let me code")
- self.assertIn("at least 1 formal Invariant", str(ctx.exception))
-
- # Attempt 4: Add invariant, but active phase is Phase 1 -> Must still fail
- self.session.record_invariant(
- "INV-01",
- "tail <= head",
- "Proof by induction",
- "coding"
- )
- with self.assertRaises(PermissionError) as ctx:
- self.session.unlock_execution("Let me code")
- self.assertIn("at least Phase 3", str(ctx.exception))
-
- # Attempt 5: Advance to Phase 2 -> Must still fail
- self.session.advance_phase("Phase 2: Invariant Specification & Blueprint", "Drafted blueprints")
- with self.assertRaises(PermissionError) as ctx:
- self.session.unlock_execution("Let me code")
- self.assertIn("at least Phase 3", str(ctx.exception))
-
- # Attempt 6: Advance to Phase 3 -> Satisfies ALL gates! Must succeed!
- self.session.advance_phase("Phase 3: Adversarial Red-Teaming & Falsification", "Passed red team fuzzing")
- res = self.session.unlock_execution(
- "All invariants proved and red-team checks passed."
- )
-
- self.assertEqual(res["status"], "UNLOCKED")
- self.assertFalse(self.session.execution_locked)
- self.assertTrue(self.session.can_execute_code)
- self.assertIsNotNone(self.session.unlock_details)
- self.assertEqual(self.session.unlock_details["proven_count"], 2)
- self.assertEqual(self.session.unlock_details["invariants_count"], 1)
-
- def test_pacing_expiry_cannot_unlock(self):
- """A completed internal timer never satisfies the authority lock."""
- self.session.set_timer(0.1)
- self.clock.advance(1.0)
- self.session.log_epistemic_item("PROVEN", "Fact 1", f"{__file__}:L1")
- self.session.log_epistemic_item("PROVEN", "Fact 2", f"{__file__}:L2")
- self.session.record_invariant("INV-01", "x == x", "Reflexivity")
- self.session.advance_phase("Phase 2: Invariant Specification & Blueprint", "Phase 2")
- self.session.advance_phase("Phase 3: Adversarial Red-Teaming & Falsification", "Phase 3")
- with self.assertRaises(PermissionError):
- self.session.unlock_execution("Pacing timer expired")
-
- def test_proven_claims_require_evidence(self):
- """A truth label without an evidence pointer is not an admissible gate input."""
- with self.assertRaises(ValueError):
- self.session.log_epistemic_item("PROVEN", "An unreferenced assertion")
-
- item = self.session.log_epistemic_item(
- "PROVEN", "A referenced assertion", f"{__file__}:L1"
- )
- self.assertEqual(item["tag"], "PROVEN")
-
- def test_invalid_budget_and_session_name_are_rejected(self):
- with self.assertRaises(ValueError):
- FableSession("invalid/escape", "test", 10)
- with self.assertRaises(ValueError):
- FableSession("bad_budget", "test", float("nan"))
- with self.assertRaises(ValueError):
- self.session.set_timer(-1)
-
- def test_serialization_and_atomic_save(self):
- """Verifies session dictionary serialization, deserialization, and disk saving including refinement cycles."""
- self.session.log_epistemic_item("PROVEN", "Proven item 1", f"{__file__}:L303")
- self.session.record_invariant("INV-01", "Statement", "Rationale")
- self.session.log_refinement_cycle(
- refinement_type="archetype_exploration",
- focus_area="Cache coherency",
- critique_or_bottleneck="False sharing",
- architectural_refinement="Padding to 64 bytes"
- )
- saved_path = self.session.save()
- self.assertTrue(saved_path.exists())
-
- with open(saved_path, "r", encoding="utf-8") as f:
- data = json.load(f)
-
- restored = FableSession.from_dict(data)
- self.assertEqual(restored.session_name, self.session.session_name)
- self.assertEqual(len(restored.epistemic_ledger), 1)
- self.assertEqual(len(restored.invariants), 1)
- self.assertEqual(len(restored.refinement_cycles), 1)
- self.assertEqual(restored.refinement_cycles[0]["focus_area"], "Cache coherency")
-
-
-class TestFableHandlerDispatch(unittest.TestCase):
- """Tests high-level handle_fable_session tool dispatcher."""
-
- def setUp(self):
- self.session_name = f"test_dispatch_{int(time.time() * 1000)}"
-
- def tearDown(self):
- target_file = SESSIONS_DIR / f"{self.session_name}.json"
- if target_file.exists():
- try:
- target_file.unlink()
- except Exception:
- pass
- if self.session_name in ACTIVE_SESSIONS:
- del ACTIVE_SESSIONS[self.session_name]
-
- def test_refinement_cycle_dispatch(self):
- """Verifies dispatching log_refinement_cycle and aliases."""
- handle_fable_session({
- "action": "create_session",
- "session_name": self.session_name,
- "objective": "Design lock-free queue",
- "time_budget_minutes": 30
- })
-
- # Call log_refinement_cycle
- res = handle_fable_session({
- "action": "log_refinement_cycle",
- "session_name": self.session_name,
- "refinement_type": "archetype_exploration",
- "focus_area": "Atomic memory ordering",
- "critique_or_bottleneck": "SeqCst is unnecessarily heavy on ARM64",
- "architectural_refinement": "Relaxed loads with Acquire-Release fences",
- "terminal_probe_results": "Benchmark: 4.2x speedup on aarch64",
- "artifact_path": "C:/docs/arch_refine.md"
- })
- self.assertIn("Rethink-Refine Cycle #1 Logged", res)
- self.assertIn("ARCHETYPE_EXPLORATION", res)
- self.assertIn("Atomic memory ordering", res)
- self.assertIn("Terminal Probes / Benchmarks", res)
- self.assertIn("Artifact Blueprint", res)
- self.assertIn("Total Refinement Cycles", res)
-
- # Call alias refine
- res_alias = handle_fable_session({
- "action": "refine",
- "session_name": self.session_name,
- "refinement_type": "adversarial_falsification",
- "focus_area": "ABA Problem",
- "critique_or_bottleneck": "Pointer recycling can fool standard CAS",
- "architectural_refinement": "Tagged pointer with generation counter"
- })
- self.assertIn("Rethink-Refine Cycle #2 Logged", res_alias)
- self.assertIn("ADVERSARIAL_FALSIFICATION", res_alias)
-
- # Missing required parameter tests
- res_err = handle_fable_session({
- "action": "log_refinement_cycle",
- "session_name": self.session_name,
- "refinement_type": "test",
- "focus_area": "test",
- "critique_or_bottleneck": ""
- })
- self.assertIn("Error: 'critique_or_bottleneck' is required", res_err)
-
- def test_full_workflow_via_handler(self):
- """Executes full lifecycle through the dispatcher tool interface."""
- # 1. Create session
- res = handle_fable_session({
- "action": "create_session",
- "session_name": self.session_name,
- "objective": "Build zero-copy parser",
- "time_budget_minutes": 0.1
- })
- self.assertIn("Fable Cognitive Session Initialized", res)
- self.assertIn("Anti-Rush Lockout is ACTIVE", res)
-
- # 2. Get status / telemetry
- res = handle_fable_session({
- "action": "get_status",
- "session_name": self.session_name
- })
- self.assertIn("Fable Session Status & Telemetry", res)
- self.assertIn("LOCKED", res)
-
- # 3. Log epistemic items
- res = handle_fable_session({
- "action": "log_epistemic_item",
- "session_name": self.session_name,
- "tag": "PROVEN",
- "claim": "Zero-copy slices borrow from underlying buffer without allocation",
- "evidence": f"{__file__}:L10"
- })
- self.assertIn("Epistemic Item Logged", res)
- self.assertIn("[PROVEN]", res)
-
- res = handle_fable_session({
- "action": "log_epistemic_item",
- "session_name": self.session_name,
- "tag": "PROVEN",
- "claim": "Lifetime bounds prevent use-after-free",
- "evidence": "cargo test stdout: rustc verification passed"
- })
- self.assertIn("2 PROVEN", res)
-
- # 4. Record invariant
- res = handle_fable_session({
- "action": "record_invariant",
- "session_name": self.session_name,
- "invariant_name": "INV-LIFETIME",
- "formal_statement": "SliceLifetime <= BufferLifetime",
- "proof_or_rationale": "Static borrow checker guarantee",
- "domain": "design"
- })
- self.assertIn("Formal Invariant Recorded", res)
- self.assertIn("INV-LIFETIME", res)
-
- # 5. Log refinement cycle
- res = handle_fable_session({
- "action": "log_refinement_cycle",
- "session_name": self.session_name,
- "refinement_type": "benchmark_probe",
- "focus_area": "SIMD tokenization",
- "critique_or_bottleneck": "Scalar byte scanning is memory-bound",
- "architectural_refinement": "AVX-512 vectorized delimiter detection",
- "terminal_probe_results": "Throughput increased from 1.2 GB/s to 9.8 GB/s"
- })
- self.assertIn("Rethink-Refine Cycle #1 Logged", res)
-
- # 6. Try premature unlock (Phase 1) -> Must fail Hard Time-Lock (or cognitive gate)
- res = handle_fable_session({
- "action": "unlock_execution",
- "session_name": self.session_name,
- "rationale": "Premature attempt"
- })
- self.assertIn("HARD TIME-LOCK VIOLATION", res)
-
- # 7. Advance to Phase 2 then Phase 3
- handle_fable_session({
- "action": "advance_phase",
- "session_name": self.session_name,
- "next_phase": "Phase 2: Invariant Specification & Blueprint",
- "phase_summary": "Blueprints created"
- })
- handle_fable_session({
- "action": "advance_phase",
- "session_name": self.session_name,
- "next_phase": "Phase 3: Adversarial Red-Teaming & Falsification",
- "phase_summary": "Passed red-teaming checks"
- })
-
- # 8. Attempt unlock without override token -> Still hits Hard Time-Lock
- res = handle_fable_session({
- "action": "unlock_execution",
- "session_name": self.session_name,
- "rationale": "Cognitive gates satisfied but timer active"
- })
- self.assertIn("HARD TIME-LOCK VIOLATION", res)
-
- # 9. The immutable authority deadline, not the internal pacing timer, unlocks.
- time.sleep(6.3)
- res = handle_fable_session({
- "action": "unlock_execution",
- "session_name": self.session_name,
- "rationale": "Cognitive gates satisfied with 2 proven facts, 1 invariant, and 1 refinement cycle"
- })
- self.assertIn("Execution Lock Lifted Successfully", res)
- self.assertIn("🟢 UNLOCKED", res)
-
- # 10. Adjust internal pacing timer without changing authority budget.
- res = handle_fable_session({
- "action": "set_timer",
- "session_name": self.session_name,
- "time_budget_minutes": 60
- })
- self.assertIn("Pacing Timer", res)
- self.assertIn("Authority Budget", res)
- self.assertIn("0.1", res)
-
- # 11. Checkpoint and restore
- res = handle_fable_session({
- "action": "checkpoint_session",
- "session_name": self.session_name
- })
- self.assertIn("Fable Session Checkpointed", res)
- self.assertIn("Refinement Cycles", res)
-
- res = handle_fable_session({
- "action": "restore_session",
- "session_name": self.session_name
- })
- self.assertIn("Fable Session Restored", res)
- self.assertIn("Refinement Cycles", res)
-
- # 12. List sessions
- res = handle_fable_session({
- "action": "list_sessions"
- })
- self.assertIn(self.session_name, res)
-
- def test_edge_cases_and_error_handling(self):
- """Tests dispatcher error handling on invalid inputs."""
- # Missing action
- res = handle_fable_session({})
- self.assertIn("Error: Missing required parameter 'action'", res)
-
- # Unknown action
- res = handle_fable_session({"action": "fly_to_moon"})
- self.assertIn("Error: Unknown action 'fly_to_moon'", res)
-
- # Missing session_name
- res = handle_fable_session({"action": "get_status"})
- self.assertIn("Error: 'session_name' is required", res)
-
- # Non-existent session
- res = handle_fable_session({"action": "get_status", "session_name": "ghost_session_xyz"})
- self.assertIn("does not exist", res)
-
-
-class TestFableMCPStdioServer(unittest.TestCase):
- """End-to-end integration test of the JSON-RPC 2.0 stdio server process."""
-
- def setUp(self):
- self.python_exe = sys.executable
- self.server_script = str(CURRENT_DIR / "server.py")
- self.proc = subprocess.Popen(
- [self.python_exe, self.server_script],
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- encoding="utf-8"
- )
- # Drain diagnostics continuously: a long-running interactive server
- # must not block on a full stderr pipe while the test reads stdout.
- self._stderr_lines = []
- self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
- self._stderr_thread.start()
- self.req_id = 0
-
- def _drain_stderr(self):
- if self.proc.stderr is None:
- return
- try:
- for line in self.proc.stderr:
- if len(self._stderr_lines) < 256:
- self._stderr_lines.append(line)
- except (OSError, ValueError):
- pass
-
- def tearDown(self):
- if self.proc.stdin:
- try:
- self.proc.stdin.close()
- except Exception:
- pass
- if self.proc.stdout:
- try:
- self.proc.stdout.close()
- except Exception:
- pass
- if self.proc.stderr:
- try:
- self.proc.stderr.close()
- except Exception:
- pass
- if self.proc.poll() is None:
- self.proc.terminate()
- try:
- self.proc.wait(timeout=2)
- except subprocess.TimeoutExpired:
- self.proc.kill()
-
- def _rpc_call(self, method: str, params: dict = None) -> dict:
- self.req_id += 1
- req = {
- "jsonrpc": "2.0",
- "id": self.req_id,
- "method": method
- }
- if params is not None:
- req["params"] = params
-
- raw_req = json.dumps(req) + "\n"
- self.proc.stdin.write(raw_req)
- self.proc.stdin.flush()
-
- line = self.proc.stdout.readline()
- self.assertTrue(line, "Server returned empty output")
- return json.loads(line)
-
- def test_mcp_handshake_and_tool_call(self):
- """Verifies initialize, tools/list, and tools/call over stdio JSON-RPC."""
- # 1. Initialize
- resp = self._rpc_call("initialize")
- self.assertEqual(resp.get("jsonrpc"), "2.0")
- self.assertEqual(resp.get("id"), 1)
- self.assertEqual(resp["result"]["serverInfo"]["name"], "fable-engine")
-
- # 2. Tools List
- resp = self._rpc_call("tools/list")
- tools = resp["result"]["tools"]
- self.assertEqual(len(tools), 1)
- self.assertEqual(tools[0]["name"], "fable_session")
- self.assertIn("log_refinement_cycle", tools[0]["inputSchema"]["properties"]["action"]["enum"])
-
- # 3. Call fable_session: create_session
- session_name = f"mcp_stdio_test_{int(time.time() * 1000)}"
- resp = self._rpc_call("tools/call", {
- "name": "fable_session",
- "arguments": {
- "action": "create_session",
- "session_name": session_name,
- "objective": "Verify MCP stdio protocol loop",
- "time_budget_minutes": 15
- }
- })
- self.assertFalse(resp["result"].get("isError", True))
- content = resp["result"]["content"][0]["text"]
- self.assertIn("Fable Cognitive Session Initialized", content)
-
- # 4. Call fable_session: log_refinement_cycle
- resp = self._rpc_call("tools/call", {
- "name": "fable_session",
- "arguments": {
- "action": "log_refinement_cycle",
- "session_name": session_name,
- "refinement_type": "benchmark_probe",
- "focus_area": "Zero-copy buffering",
- "critique_or_bottleneck": "Memory allocation inside hot loop",
- "architectural_refinement": "Preallocated memory arena slab allocator",
- "terminal_probe_results": "0 allocs/op achieved"
- }
- })
- self.assertFalse(resp["result"].get("isError", True))
- content = resp["result"]["content"][0]["text"]
- self.assertIn("Rethink-Refine Cycle #1 Logged", content)
-
- # 5. Call fable_session: compress_payload & view_slice over stdio
- resp = self._rpc_call("tools/call", {
- "name": "fable_session",
- "arguments": {
- "action": "compress_payload",
- "content": "Line 1: Sample MCP payload\nLine 2: Another line\nLine 3: Third line\n",
- "label": "stdio_test_payload"
- }
- })
- self.assertFalse(resp["result"].get("isError", True))
- content = resp["result"]["content"][0]["text"]
- self.assertIn("Fable CAS Payload Compressed", content)
-
- # 6. Ping
- resp = self._rpc_call("ping")
- self.assertEqual(resp.get("result"), {})
-
- # Clean up session file
- sfile = SESSIONS_DIR / f"{session_name}.json"
- if sfile.exists():
- try:
- sfile.unlink()
- except Exception:
- pass
-
-
-class TestFableTokenCompression(unittest.TestCase):
- """Direct subsystem tests for FableCASStore, AdaptiveChunkAccumulator, FableGrammar333, CASSliceViewer, and FableCompress."""
-
- def setUp(self):
- self.test_dir = Path(tempfile.mkdtemp(prefix="fable_test_cas_"))
- self.compressor = FableCompress(root_dir=self.test_dir)
-
- def tearDown(self):
- if self.test_dir.exists():
- shutil.rmtree(self.test_dir, ignore_errors=True)
-
- def test_cas_store_atomic_put_get(self):
- """Verify atomic writes, SHA-256 addresses, and byte-exact retrieval."""
- store = self.compressor.cas_store
- sample = "Fable-Mode Token Compression Subsystem verification payload."
- uri = store.put(sample)
- self.assertTrue(uri.startswith("cas://"))
- self.assertEqual(len(store.normalize_ref(uri)), 64)
- self.assertEqual(store.get_text(uri), sample)
- self.assertEqual(store.get_bytes(uri), sample.encode("utf-8"))
- self.assertTrue(store.verify_integrity(uri))
-
- def test_cas_corruption_detection(self):
- """Verify integrity verification catches file tampering on disk."""
- store = self.compressor.cas_store
- sample = "Pristine data payload"
- uri = store.put(sample)
- file_path = store.get_file_path(uri)
- store.cache.clear()
-
- with open(file_path, "r+b") as f:
- f.seek(0)
- f.write(b"Z")
-
- with self.assertRaises(IntegrityError):
- store.get_bytes(uri, verify=True)
- self.assertFalse(store.verify_integrity(uri))
-
- def test_lru_cache_bounds_and_eviction(self):
- """Verify LRU cache capacity limits and disk fallback."""
- small_store = FableCASStore(root_dir=self.test_dir / "lru", cache_capacity=2)
- u1 = small_store.put("entry_1")
- u2 = small_store.put("entry_2")
- u3 = small_store.put("entry_3")
-
- self.assertEqual(len(small_store.cache), 2)
- self.assertEqual(small_store.get_text(u1), "entry_1")
-
- def test_adaptive_chunk_accumulator_coalescing(self):
- """Verify sub-1000 character micro-payload batching into composite frames."""
- acc = self.compressor.accumulator
- payloads = [f"Log trace entry #{i:03d} for subagent execution step." for i in range(30)]
-
- flushed_uris = []
- for p in payloads:
- flushed_uris.extend(acc.add(p, metadata={"step": "trace"}))
- flushed_uris.extend(acc.flush())
-
- self.assertGreater(len(flushed_uris), 0)
- extracted = []
- for uri in flushed_uris:
- frame_json = self.compressor.cas_store.get_text(uri)
- frame = CompositeFrame.deserialize_json(frame_json)
- for idx in range(len(frame.items)):
- item_text, meta = acc.extract_item(uri, idx)
- extracted.append(item_text)
- self.assertEqual(meta.get("step"), "trace")
-
- self.assertEqual(extracted, payloads)
-
- def test_grammar333_micro_bytecode_roundtrip(self):
- """Verify bit-exact roundtrip serialization of tool actions."""
- payload = {
- "action_type": "view_file",
- "path": "C:/Projects/module.py",
- "start_line": 10,
- "end_line": 50,
- "content_ref": "cas://abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
- }
- encoded = FableGrammar333.serialize(payload)
- self.assertTrue(encoded.startswith(FableGrammar333.MAGIC_HEADER))
- decoded = FableGrammar333.deserialize(encoded)
- self.assertEqual(decoded, payload)
-
- def test_cas_slice_viewer_zero_copy(self):
- """Verify windowed line slice extractor with 1-based indexing."""
- lines = [f"Line {i:03d}: Content description here" for i in range(1, 60)]
- doc = "\n".join(lines)
- uri = self.compressor.cas_store.put(doc)
-
- viewer = self.compressor.slice_viewer
- self.assertEqual(viewer.get_line_count(uri), 58)
-
- slice_text = viewer.view_slice(uri, 5, 10)
- self.assertEqual(slice_text, "\n".join(lines[4:10]))
-
- numbered = viewer.view_slice(uri, 1, 2, include_line_numbers=True)
- self.assertIn(" 1 | Line 001:", numbered)
-
- def test_invariant_token_ratio_lte_0_003(self):
- """Verify <= 0.003 tokens/character invariant on large payloads."""
- sizes = [10_000, 50_000, 100_000]
- for size in sizes:
- raw_text = ("function process_data(chunk: Buffer) -> Result {\n return validate(chunk);\n}\n" * (size // 60 + 1))[:size]
- node = self.compressor.compress_payload_to_cas(raw_text, label="trace_dump")
- repr_str = json.dumps(node, separators=(",", ":"))
- ratio = self.compressor.calculate_token_ratio(raw_text, repr_str)
- self.assertLessEqual(ratio, 0.003, f"Failed invariant for size {size}: ratio {ratio}")
- recovered = self.compressor.decompress_cas_payload(node)
- self.assertEqual(recovered, raw_text)
-
-
-class TestFableCompressionHandlerDispatch(unittest.TestCase):
- """Tests dispatcher tool actions for Token Compression Subsystem."""
-
- def test_compress_and_decompress_payload_dispatch(self):
- """Verifies compress_payload and decompress_payload actions."""
- payload = "Structured benchmark probe report for concurrent ring buffer:\n" + ("line output data\n" * 1000)
- res = handle_fable_session({
- "action": "compress_payload",
- "content": payload,
- "label": "benchmark_probe"
- })
- self.assertIn("Fable CAS Payload Compressed", res)
- self.assertIn("cas://", res)
- self.assertIn("PASS (<= 0.003 tokens/char)", res)
-
- import re
- match = re.search(r"cas://[0-9a-fA-F]{64}", res)
- self.assertIsNotNone(match)
- cas_uri = match.group(0)
-
- res_decomp = handle_fable_session({
- "action": "decompress_payload",
- "cas_ref": cas_uri
- })
- self.assertIn("Fable CAS Payload Retrieved", res_decomp)
- self.assertIn("Structured benchmark probe report", res_decomp)
-
- def test_view_slice_dispatch(self):
- """Verifies view_slice action."""
- doc = "\n".join([f"Trace item #{i:02d}" for i in range(1, 30)])
- res_comp = handle_fable_session({
- "action": "compress_payload",
- "content": doc
- })
- import re
- match = re.search(r"cas://[0-9a-fA-F]{64}", res_comp)
- cas_uri = match.group(0)
-
- res_slice = handle_fable_session({
- "action": "view_slice",
- "cas_ref": cas_uri,
- "start_line": 5,
- "end_line": 8
- })
- self.assertIn("Fable CAS Slice View", res_slice)
- self.assertIn("Trace item #05", res_slice)
- self.assertIn("Trace item #08", res_slice)
-
- def test_accumulate_and_flush_dispatch(self):
- """Verifies accumulate_payload and flush_accumulator actions."""
- res1 = handle_fable_session({
- "action": "accumulate_payload",
- "payload": "Micro-log entry 1",
- "metadata": {"task": "test"}
- })
- self.assertIn("Micro-Payload Ingested", res1)
-
- res_flush = handle_fable_session({
- "action": "flush_accumulator"
- })
- self.assertIn("Micro-Payload Accumulator", res_flush)
-
- def test_compression_stats_dispatch(self):
- """Verifies get_compression_stats action."""
- res = handle_fable_session({
- "action": "get_compression_stats"
- })
- self.assertIn("Fable Token Compression Subsystem Telemetry", res)
- self.assertIn("Token Compression Invariant", res)
-
-
-class TestFableSystem3ActionsDispatch(unittest.TestCase):
- """Verifies all System 3 MCP actions and session persistence via dispatcher."""
-
- def setUp(self):
- self.session_name = f"test_sys3_{int(time.time() * 1000)}"
- init_res = handle_fable_session({
- "action": "create_session",
- "session_name": self.session_name,
- "objective": "Test System 3 Meta-Cognitive Deliberation",
- "time_budget_minutes": 30.0,
- })
- self.assertIn("Fable Cognitive Session Initialized", init_res)
-
- def tearDown(self):
- target_file = SESSIONS_DIR / f"{self.session_name}.json"
- if target_file.exists():
- try:
- target_file.unlink()
- except Exception:
- pass
- if self.session_name in ACTIVE_SESSIONS:
- del ACTIVE_SESSIONS[self.session_name]
-
- def test_system3_dialectical_synthesis_dispatch(self):
- """Verifies system3_dialectical_synthesis action and TRIZ transcendence."""
- res = handle_fable_session({
- "action": "system3_dialectical_synthesis",
- "session_name": self.session_name,
- "thesis_title": "Lock-Free Ring Buffer",
- "thesis_description": "Single-writer CAS ring buffer with bounded queue",
- "antithesis_title": "Contention Bottleneck at Multi-Producer",
- "contradictions": [
- {
- "improving_parameter": "throughput",
- "worsening_parameter": "latency",
- "description": "Multi-producer atomic contention degrades P99 latency",
- "severity": 0.85
- }
- ],
- "failure_modes": ["Cache line bouncing under 16 concurrent producers"],
- "max_debate_rounds": 3,
- })
- self.assertIn("System 3 Dialectical Synthesis Emerged", res)
- self.assertIn("Transcended TRIZ Inventive Principles", res)
- self.assertIn("Resolved Contradictions", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_syntheses), 1)
- self.assertTrue(len(session.refinement_cycles) > 0)
-
- def test_system3_causal_simulate_dispatch(self):
- """Verifies system3_causal_simulate action with Pearl do-calculus and brittleness analysis."""
- nodes = [
- {"node_id": "threads", "name": "Worker Threads", "node_type": "exogenous", "value": 4.0},
- {"node_id": "contention", "name": "Lock Contention", "node_type": "endogenous", "value": 0.0},
- {"node_id": "throughput", "name": "Operations/sec", "node_type": "metric", "value": 0.0}
- ]
- edges = [
- {"source": "threads", "target": "contention", "weight": 0.5},
- {"source": "threads", "target": "throughput", "weight": 2.0},
- {"source": "contention", "target": "throughput", "weight": -1.2}
- ]
- interventions = {"contention": 0.1}
-
- res = handle_fable_session({
- "action": "system3_causal_simulate",
- "session_name": self.session_name,
- "model_name": "ThreadContentionDAG",
- "nodes": nodes,
- "edges": edges,
- "interventions": interventions,
- "target_metric": "throughput"
- })
- self.assertIn("System 3 Pearl's Do-Calculus & Causal Simulation", res)
- self.assertIn("Pearl's Do-Operator Intervention", res)
- self.assertIn("Structural Brittleness Report", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_causal_graphs), 1)
-
- def test_system3_evolve_paradigms_dispatch(self):
- """Verifies system3_evolve_paradigms action with 10D Pareto frontier selection."""
- res = handle_fable_session({
- "action": "system3_evolve_paradigms",
- "session_name": self.session_name,
- "generations": 2,
- "population_size": 8,
- "mutation_rate": 0.20,
- })
- self.assertIn("System 3 Evolutionary Paradigm Engine", res)
- self.assertIn("Top Rank 1 Non-Dominated Pareto Frontier", res)
- self.assertIn("Winning Gene Allocation", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_gene_pools), 1)
-
- def test_system3_induce_axioms_dispatch(self):
- """Verifies system3_induce_axioms action and auto-recording into invariants."""
- res = handle_fable_session({
- "action": "system3_induce_axioms",
- "session_name": self.session_name,
- "domain": "architecture"
- })
- self.assertIn("System 3 Neuro-Symbolic Invariant Induction", res)
- self.assertIn("Axioms Induced", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertTrue(len(session.system3_axioms) > 0)
- self.assertTrue(len(session.invariants) > 0)
-
- def test_system3_meta_reflect_dispatch(self):
- """Verifies system3_meta_reflect action with bias detection and heuristic rewriting."""
- res = handle_fable_session({
- "action": "system3_meta_reflect",
- "session_name": self.session_name,
- "focus_area": "Architecture Deliberation Trace"
- })
- self.assertIn("System 3 Meta-Cognitive Deliberation Audit", res)
- self.assertIn("Recommended Cognitive Gear", res)
- self.assertIn("Cognitive Bias Diagnostics", res)
- self.assertIn("Dynamic Search Heuristics", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_reflections), 1)
-
- def test_system3_tri_level_orchestrate_dispatch(self):
- """Verifies system3_tri_level_orchestrate action."""
- res = handle_fable_session({
- "action": "system3_tri_level_orchestrate",
- "session_name": self.session_name,
- "task_complexity": 0.95,
- "contradiction_density": 0.85,
- "failure_count": 2,
- "epistemic_uncertainty": 0.60
- })
- self.assertIn("System 3 Tri-Level Cognitive Arbitration", res)
- self.assertIn("SYSTEM_3_META_COGNITIVE", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_orchestrations), 1)
-
- def test_system3_hyperbolic_embed_dispatch(self):
- """Verifies system3_hyperbolic_embed action with Poincaré tree embedding."""
- tree = {
- "root": ["agent", "runtime"],
- "agent": ["planner", "memory"],
- "runtime": ["broker", "verifier"],
- }
- res = handle_fable_session({
- "action": "system3_hyperbolic_embed",
- "session_name": self.session_name,
- "tree": tree,
- "root_id": "root",
- "dimension": 2,
- "curvature": 1.0,
- "base_step": 1.0,
- })
- self.assertIn("System 3 Poincaré Hyperbolic Manifold Embedding", res)
- self.assertIn("Poincaré Ball", res)
- self.assertIn("Mean Metric Distortion", res)
- self.assertIn("Hyperbolic Volume Expansion Ratio", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_hyperbolic_embeddings), 1)
- self.assertTrue(len(session.refinement_cycles) > 0)
-
- def test_system3_kripke_verify_dispatch(self):
- """Verifies system3_kripke_verify action with CTL* temporal verification."""
- worlds = [
- {"world_id": "w0", "propositions": ["safe", "init"], "is_initial": True},
- {"world_id": "w1", "propositions": ["safe", "running"]},
- {"world_id": "w2", "propositions": ["safe", "complete"]},
- ]
- transitions = [
- {"source": "w0", "target": "w1"},
- {"source": "w1", "target": "w2"},
- {"source": "w2", "target": "w2"},
- ]
- res = handle_fable_session({
- "action": "system3_kripke_verify",
- "session_name": self.session_name,
- "model_name": "ExecutionWorkflowModel",
- "worlds": worlds,
- "transitions": transitions,
- "formula": "AG(safe)",
- "initial_world": "w0",
- })
- self.assertIn("System 3 Kripke Modal Model Verification", res)
- self.assertIn("SATISFIED", res)
- self.assertIn("AG(safe)", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_kripke_verifications), 1)
-
- def test_system3_active_inference_dispatch(self):
- """Verifies system3_active_inference action with Variational Free Energy minimization."""
- res = handle_fable_session({
- "action": "system3_active_inference",
- "session_name": self.session_name,
- "observation": "HIGH_THROUGHPUT_CLEAN",
- "gamma": 16.0,
- })
- self.assertIn("System 3 Friston Active Inference & Variational Free Energy", res)
- self.assertIn("Variational Free Energy (F)", res)
- self.assertIn("Selected Policy", res)
- self.assertIn("Evaluated Policy Landscape", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_active_inferences), 1)
-
- def test_system3_proof_oracle_dispatch(self):
- """Verifies system3_proof_oracle action with Curry-Howard constructive verification."""
- res = handle_fable_session({
- "action": "system3_proof_oracle",
- "session_name": self.session_name,
- "claim": "safe == safe",
- })
- self.assertIn("System 3 Gödelian Auto-Formalizing Proof Oracle", res)
- self.assertIn("DECIDABLE_PROVED", res)
- self.assertIn("Soundness Verified", res)
-
- session = ACTIVE_SESSIONS[self.session_name]
- self.assertEqual(len(session.system3_proof_oracle_verifications), 1)
- self.assertTrue(len(session.invariants) > 0)
-
- def test_system3_session_roundtrip_persistence(self):
- """Verifies full roundtrip serialization & disk saving of all System 3 state fields."""
- # Execute synthesis, causal, hyperbolic, kripke, free energy, and oracle
- handle_fable_session({
- "action": "system3_dialectical_synthesis",
- "session_name": self.session_name,
- "thesis_title": "Thesis A",
- "thesis_description": "Desc A",
- "antithesis_title": "Critique B",
- })
- handle_fable_session({
- "action": "system3_causal_simulate",
- "session_name": self.session_name,
- "nodes": [{"node_id": "X", "value": 1.0}],
- "edges": []
- })
- handle_fable_session({
- "action": "system3_hyperbolic_embed",
- "session_name": self.session_name,
- "tree": {"root": ["a", "b"]},
- })
- handle_fable_session({
- "action": "system3_kripke_verify",
- "session_name": self.session_name,
- "worlds": [{"world_id": "w0", "propositions": ["p"]}],
- "transitions": [{"source": "w0", "target": "w0"}],
- "formula": "p",
- })
- handle_fable_session({
- "action": "system3_active_inference",
- "session_name": self.session_name,
- "observation": "HIGH_THROUGHPUT_CLEAN",
- })
- handle_fable_session({
- "action": "system3_proof_oracle",
- "session_name": self.session_name,
- "claim": "safe == safe",
- })
-
- session = ACTIVE_SESSIONS[self.session_name]
- d = session.to_dict()
- self.assertEqual(len(d["system3_syntheses"]), 1)
- self.assertEqual(len(d["system3_causal_graphs"]), 1)
- self.assertEqual(len(d["system3_hyperbolic_embeddings"]), 1)
- self.assertEqual(len(d["system3_kripke_verifications"]), 1)
- self.assertEqual(len(d["system3_active_inferences"]), 1)
- self.assertEqual(len(d["system3_proof_oracle_verifications"]), 1)
-
- # Restore from dict
- restored = FableSession.from_dict(d)
- self.assertEqual(len(restored.system3_syntheses), 1)
- self.assertEqual(len(restored.system3_causal_graphs), 1)
- self.assertEqual(len(restored.system3_hyperbolic_embeddings), 1)
- self.assertEqual(len(restored.system3_kripke_verifications), 1)
- self.assertEqual(len(restored.system3_active_inferences), 1)
- self.assertEqual(len(restored.system3_proof_oracle_verifications), 1)
- self.assertEqual(restored.session_name, self.session_name)
-
-
-class TestSecondRedTeamRegressions(unittest.TestCase):
- def test_cached_cas_data_is_verified(self):
- root = Path(tempfile.mkdtemp(prefix="fable_cas_regression_"))
- try:
- store = FableCASStore(root_dir=root)
- uri = store.put(b"good")
- digest = store.normalize_ref(uri)
- store.cache.put(digest, b"bad")
- with self.assertRaises(IntegrityError):
- store.get_bytes(uri, verify=True)
- finally:
- shutil.rmtree(root, ignore_errors=True)
-
- def test_restore_ignores_forged_deadline_phase_and_evidence_authority(self):
- now = time.time()
- payload = {"session_name": "forged_regression", "objective": "x",
- "time_budget_minutes": 1, "start_time": now - 10000,
- "authority_deadline_time": now + 10**9,
- "active_phase": PHASES[-1], "execution_locked": False,
- "can_execute_code": True,
- "epistemic_ledger": [{"tag": "PROVEN", "evidence": "forged"},
- {"tag": "PROVEN", "evidence": "forged"}],
- "invariants": [{"proof_or_rationale": "forged"}],
- "phase_history": [{"phase": PHASES[-1]}]}
- restored = FableSession.from_dict(payload)
- self.assertTrue(restored.execution_locked)
- self.assertFalse(restored.can_execute_code)
- self.assertEqual(restored.active_phase, PHASES[0])
- self.assertLess(restored.deadline_time, now + 120)
- self.assertFalse(restored.get_telemetry()["cognitive_gates"]["ready"])
-
- def test_malformed_rpc_shapes_return_invalid_request(self):
- root = Path(tempfile.mkdtemp(prefix="fable_rpc_regression_"))
- try:
- env = os.environ.copy(); env["FABLE_DATA_DIR"] = str(root / "data")
- completed = subprocess.run([sys.executable, str(Path(__file__).resolve().parent / "server.py")],
- input='[1, 2]\n{"jsonrpc":"2.0","id":4,"method":"ping","params":[]}\n',
- capture_output=True, text=True, env=env, timeout=5)
- responses = [json.loads(line) for line in completed.stdout.splitlines()]
- first, second = responses
- self.assertEqual(first["error"]["code"], -32600)
- self.assertEqual(second["error"]["code"], -32600)
- finally:
- if 'proc' in locals() and proc.poll() is None: proc.kill()
- shutil.rmtree(root, ignore_errors=True)
if __name__ == "__main__":
- unittest.main(verbosity=2)
+ root = pathlib.Path(__file__).resolve().parents[1]
+ suite = unittest.defaultTestLoader.discover(str(root / "tests"), pattern="test_*.py")
+ raise SystemExit(unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() is False)
diff --git a/fable_mode/adapters.py b/fable_mode/adapters.py
index f8a9eac..e04b9d9 100644
--- a/fable_mode/adapters.py
+++ b/fable_mode/adapters.py
@@ -1,6 +1,7 @@
"""Safe, bounded host discovery and transactional MCP registration."""
from __future__ import annotations
+import hashlib
import json
import math
import os
@@ -12,8 +13,259 @@
import sys
import tempfile
import threading
+import uuid
+from contextlib import contextmanager
from dataclasses import dataclass
+from datetime import datetime, timezone
from pathlib import Path
+from typing import Any, Iterable, Mapping, Protocol
+
+try: # advisory inter-process lock; the thread lock below covers all platforms
+ import fcntl
+except ImportError: # pragma: no cover - Windows uses the process lock fallback
+ fcntl = None
+
+
+@dataclass(frozen=True)
+class HostIdentity:
+ """Canonical identity and CLI/config contract for one host family.
+
+ Host names are intentionally data, rather than scattered conditionals:
+ aliases are accepted at the boundary but registration records use the
+ canonical identity. ``list_argv`` is the documented machine-readable
+ listing command; a host that cannot produce JSON from it is not mutated.
+ """
+
+ canonical: str
+ aliases: tuple[str, ...]
+ executable: str
+ kind: str = "cli"
+ list_argv: tuple[str, ...] = ("mcp", "list", "--json")
+
+
+HOST_IDENTITY_REGISTRY: dict[str, HostIdentity] = {
+ "claude-code": HostIdentity("claude-code", ("claude", "claude-code", "cc"), "claude"),
+ "codex": HostIdentity("codex", ("codex",), "codex"),
+ "antigravity": HostIdentity("antigravity", ("agy", "antigravity"), "agy"),
+}
+HOST_ALIASES = {
+ alias: identity.canonical
+ for identity in HOST_IDENTITY_REGISTRY.values()
+ for alias in identity.aliases
+}
+# Public spelling kept intentionally obvious for external harnesses.
+HOST_IDENTITIES = HOST_IDENTITY_REGISTRY
+CONTROL_PLANE_PROFILE = "strict-mcp-v1"
+
+
+def _reject_non_finite(value: Any, *, path: str = "value") -> None:
+ """Reject non-JSON finite numbers before forwarding MCP arguments."""
+ if isinstance(value, float) and not math.isfinite(value):
+ raise ValueError(f"{path} must not contain NaN or Infinity")
+ if isinstance(value, dict):
+ for key, child in value.items():
+ _reject_non_finite(child, path=f"{path}.{key}")
+ elif isinstance(value, (list, tuple)):
+ for index, child in enumerate(value):
+ _reject_non_finite(child, path=f"{path}[{index}]")
+
+
+def canonical_host_id(name: str) -> str:
+ """Resolve a host alias without guessing unknown host identities."""
+ if not isinstance(name, str) or not name.strip():
+ raise ValueError("host name must be a non-empty string")
+ key = name.strip().lower()
+ try:
+ return HOST_ALIASES[key]
+ except KeyError as exc:
+ raise ValueError(f"unknown host identity: {name}") from exc
+
+
+class HostAdapter(Protocol):
+ """Host-neutral adapter surface used by launchers and MCP bridges."""
+
+ def initialize(self, **params: Any) -> dict[str, Any]: ...
+ def discover_capabilities(self) -> dict[str, Any]: ...
+ def route_tool(self, name: str, arguments: Mapping[str, Any] | None = None) -> dict[str, Any]: ...
+ def route_command(self, command: Iterable[str]) -> list[str]: ...
+ def route_file(self, path: str) -> str: ...
+ def cancel(self, request_id: str) -> dict[str, Any] | bool: ...
+ def receipt(self, **kwargs: Any) -> Any: ...
+ def route_control_plane(self, action: str, arguments: Mapping[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]: ...
+ def control_plane_capabilities(self) -> dict[str, Any]: ...
+
+
+class GenericMCPHostAdapter:
+ """Conservative adapter for any host speaking the MCP JSON-RPC shape.
+
+ This class only constructs/routs protocol data; it does not claim that a
+ host has a capability until a caller supplies a successful probe result.
+ """
+
+ PROFILE = "generic-mcp-host"
+
+ def __init__(self, host: str = "generic-mcp-host", *, capabilities: Iterable[str] = ()):
+ self.host = host
+ self._capabilities = frozenset(str(item) for item in capabilities if str(item))
+ self._cancelled: set[str] = set()
+ self._pending: set[str] = set()
+ self._lock = threading.RLock()
+
+ def _request_id(self) -> str:
+ # UUIDs are opaque JSON-RPC IDs and are unique per adapter instance.
+ return uuid.uuid4().hex
+
+ def initialize(self, request_id: Any = None, **params: Any) -> dict[str, Any]:
+ request_id = self._request_id() if request_id is None else request_id
+ if isinstance(request_id, bool) or request_id is None:
+ raise ValueError("request_id must be a non-null JSON-RPC scalar")
+ return {"jsonrpc": "2.0", "id": request_id, "method": "initialize",
+ "params": dict(params)}
+
+ def discover_capabilities(self) -> dict[str, Any]:
+ # Capability discovery is a report, not proof that native host tools
+ # are intercepted. Only a broker-routed invocation can enforce that.
+ return {"host": self.host, "profile": self.PROFILE,
+ "capabilities": sorted(self._capabilities), "authoritative": False,
+ "control_plane": self.control_plane_capabilities(),
+ "host_enforcement": {
+ "control_plane": "enforced_by_fable_engine",
+ "native_tools": "not_controlled_unless_routed_through_broker",
+ "authorization": "advisory_external_host_required",
+ }}
+
+ def route_tool(self, name: str, arguments: Mapping[str, Any] | None = None,
+ *, request_id: Any = None) -> dict[str, Any]:
+ if not isinstance(name, str) or not name.strip():
+ raise ValueError("tool name must be non-empty")
+ request_id = self._request_id() if request_id is None else request_id
+ if isinstance(request_id, bool) or request_id is None:
+ raise ValueError("request_id must be a non-null JSON-RPC scalar")
+ payload = dict(arguments or {})
+ _reject_non_finite(payload, path="arguments")
+ # The legacy surface is retained for old MCP clients, but its mode is
+ # explicit on the wire so it cannot be confused with strict control
+ # plane calls. Do not add this marker to any other tool.
+ if name.strip() == "fable_session":
+ payload.setdefault("compatibility_mode", "legacy_v1")
+ with self._lock:
+ self._pending.add(str(request_id))
+ return {"jsonrpc": "2.0", "id": request_id, "method": "tools/call",
+ "params": {"name": name, "arguments": payload}}
+
+ def route_command(self, command: Iterable[str]) -> list[str]:
+ value = list(command)
+ if not value or any(not isinstance(item, str) or not item or "\x00" in item for item in value):
+ raise ValueError("command must be a non-empty sequence of safe strings")
+ return value
+
+ def route_file(self, path: str) -> str:
+ if not isinstance(path, str) or not path or "\x00" in path:
+ raise ValueError("file path must be a non-empty safe string")
+ return path
+
+ def cancel(self, request_id: str) -> dict[str, Any] | bool:
+ """Return the MCP standard cancellation notification for a request."""
+ if (isinstance(request_id, bool) or request_id is None
+ or not isinstance(request_id, (str, int, float))
+ or (isinstance(request_id, float) and not math.isfinite(request_id))):
+ return False
+ key = str(request_id)
+ with self._lock:
+ self._cancelled.add(key)
+ self._pending.discard(key)
+ return {"jsonrpc": "2.0", "method": "notifications/cancelled",
+ "params": {"requestId": request_id}}
+
+ def route_control_plane(self, action: str, arguments: Mapping[str, Any] | None = None,
+ *, session_id: str | None = None,
+ idempotency_key: str | None = None) -> dict[str, Any]:
+ """Construct a call to the enforced Fable control-plane tool.
+
+ Routing this message through a broker is what can make host-tool
+ authorization enforced. This adapter itself does not control native
+ host tools and deliberately reports that distinction.
+ """
+ if not isinstance(action, str) or not action.strip():
+ raise ValueError("control-plane action must be non-empty")
+ payload = dict(arguments or {})
+ payload.setdefault("profile", "strict-mcp-v1")
+ payload["action"] = action.strip()
+ if session_id is not None:
+ payload.setdefault("session_id", session_id)
+ payload.setdefault("idempotency_key", idempotency_key or uuid.uuid4().hex)
+ return self.route_tool("fable_control_plane", payload)
+
+ def control_plane_capabilities(self) -> dict[str, Any]:
+ return {
+ "profile": "strict-mcp-v1",
+ "actions": ["observe", "record_prediction", "propose_action", "record_outcome", "request_verification", "finalize"],
+ "sequence_invariants": "enforced_by_fable_engine",
+ "host_tool_authorization": "advisory_unless_broker_routed",
+ "native_host_tools": "not_controlled_unless_routed_through_broker",
+ "receipt_authority": "external_host_or_broker_only",
+ "model_can_authorize_finalization": False,
+ }
+
+ def receipt(self, **kwargs: Any) -> Any:
+ from fable_v2.protocol import ToolReceipt
+ kwargs.setdefault("receipt_id", uuid.uuid4().hex)
+ kwargs.setdefault("session_id", "host-adapter")
+ kwargs.setdefault("capability", "mcp")
+ kwargs.setdefault("tool_name", self.host)
+ kwargs.setdefault("tool_input", {})
+ kwargs.setdefault("tool_output", None)
+ kwargs.setdefault("success", False)
+ now = datetime.now(timezone.utc).isoformat()
+ kwargs.setdefault("started_at", now)
+ kwargs.setdefault("finished_at", now)
+ return ToolReceipt.from_result(**kwargs)
+
+class StrictMCPHostAdapter(GenericMCPHostAdapter):
+ """Host adapter facade that opts into the strict control-plane profile."""
+
+ PROFILE = "strict-mcp-v1"
+
+ def discover_capabilities(self) -> dict[str, Any]:
+ result = super().discover_capabilities()
+ result["profile"] = self.PROFILE
+ result["control_plane"]["profile"] = self.PROFILE
+ return result
+
+
+class ClaudeCodeAdapter(StrictMCPHostAdapter):
+ """Claude Code MCP adapter; native tools remain outside this boundary."""
+ PROFILE = "strict-mcp-v1"
+
+ def __init__(self, host: str = "claude-code", **kwargs: Any):
+ super().__init__(host=host, **kwargs)
+
+
+class AntigravityAdapter(StrictMCPHostAdapter):
+ """Antigravity MCP adapter; native tools remain outside this boundary."""
+ PROFILE = "strict-mcp-v1"
+
+ def __init__(self, host: str = "antigravity", **kwargs: Any):
+ super().__init__(host=host, **kwargs)
+
+
+# Descriptive aliases for integrations that name the MCP transport explicitly.
+ClaudeCodeMCPAdapter = ClaudeCodeAdapter
+AntigravityMCPAdapter = AntigravityAdapter
+ClaudeCodeHostAdapter = ClaudeCodeAdapter
+AntigravityHostAdapter = AntigravityAdapter
+GenericMCPAdapter = GenericMCPHostAdapter
+
+
+def strict_adapter_for_host(host: str = "generic-mcp-host", **kwargs: Any) -> StrictMCPHostAdapter:
+ """Return a strict-profile adapter without pretending native interception."""
+ key = str(host).strip().lower()
+ if key in {"claude", "claude-code", "cc"}:
+ return ClaudeCodeAdapter(**kwargs)
+ if key in {"antigravity", "agy"}:
+ return AntigravityAdapter(**kwargs)
+ return StrictMCPHostAdapter(host=host, **kwargs)
+
MAX_OUTPUT = 8192
DEFAULT_TIMEOUT = 5.0
@@ -32,8 +284,19 @@ class Host:
healthy: bool = False
detail: str = ""
# Internal override used by tests/explicit transactions. Discovery hosts
- # leave this unset so mutations use the least-privilege target profile env.
+ # leave this unset so real mutations inherit the user's environment.
registration_home: Path | None = None
+ # Canonical registry identity. Kept optional for source compatibility with
+ # callers constructing the historical positional Host tuple.
+ identity: str | None = None
+ executable_identity: tuple[int, int, int, int, str] | None = None
+
+ @property
+ def canonical_name(self) -> str:
+ try:
+ return self.identity or canonical_host_id(self.name)
+ except ValueError:
+ return self.name
def _safe_path(path: Path, *, allow_missing: bool = True) -> None:
@@ -80,13 +343,54 @@ def _file_identity(path: Path) -> tuple[int, int]:
return st.st_dev, st.st_ino
+def _executable_identity(path: Path) -> tuple[int, int, int, int, str]:
+ """Capture identity used to pin a host binary across probe/mutation."""
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
+ try:
+ fd = os.open(str(path), flags)
+ except OSError as exc:
+ raise RegistrationError("host executable cannot be opened safely") from exc
+ try:
+ st = os.fstat(fd)
+ if (not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or
+ int(getattr(st, "st_file_attributes", 0)) & 0x400):
+ raise RegistrationError("host executable is not a private regular file")
+ digest_ctx = hashlib.sha256()
+ while True:
+ block = os.read(fd, 1024 * 1024)
+ if not block:
+ break
+ digest_ctx.update(block)
+ return (int(st.st_dev), int(st.st_ino), int(st.st_size),
+ int(stat.S_IMODE(st.st_mode)), digest_ctx.hexdigest())
+ finally:
+ os.close(fd)
+
+
+def _assert_executable_identity(host: Host) -> tuple[int, int, int, int, str]:
+ current = _executable_identity(host.executable)
+ expected = host.executable_identity
+ if expected is not None and tuple(expected) != current:
+ raise RegistrationError(f"host executable changed since discovery: {host.name}")
+ return current
+
+
def _kill_process(proc: subprocess.Popen[bytes]) -> None:
try:
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
+ # ``Popen.kill`` only stops the direct child. Use the explicit
+ # system utility (never a shell/PATH lookup) to terminate its
+ # descendant tree where Windows provides it, then fall back.
+ root = Path(os.environ.get("SystemRoot", r"C:\\Windows"))
+ taskkill = root / "System32" / "taskkill.exe"
+ if taskkill.is_file():
+ subprocess.run([str(taskkill), "/PID", str(proc.pid), "/T", "/F"],
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, timeout=3, check=False)
proc.kill()
- except (OSError, ProcessLookupError):
+ except (OSError, ProcessLookupError, subprocess.TimeoutExpired):
try:
proc.kill()
except OSError:
@@ -124,6 +428,8 @@ def run_argv(argv: list[str], timeout: float = DEFAULT_TIMEOUT, *, env: dict[str
except OSError as exc:
return 127, "", str(exc), False
captured: dict[str, bytearray] = {"out": bytearray(), "err": bytearray()}
+ capture_lock = threading.Lock()
+ captured_total = [0]
def drain(pipe, key: str) -> None:
try:
@@ -131,9 +437,15 @@ def drain(pipe, key: str) -> None:
chunk = pipe.read(4096)
if not chunk:
return
- remaining = MAX_OUTPUT - len(captured[key])
- if remaining > 0:
- captured[key].extend(chunk[:remaining])
+ with capture_lock:
+ remaining = MAX_OUTPUT - captured_total[0]
+ take = max(0, min(len(chunk), remaining))
+ if take:
+ captured[key].extend(chunk[:take])
+ captured_total[0] += take
+ done = take < len(chunk) or remaining <= 0
+ if done:
+ return
finally:
pipe.close()
@@ -237,102 +549,94 @@ def _probe_environment(executable: Path | None = None) -> dict[str, str]:
def _run_probe(argv: list[str], executable: Path) -> tuple[int, str, str, bool]:
+ # The probe itself is part of the trust decision. Do not report a healthy
+ # host if its executable was replaced while discovery was running.
+ before = _executable_identity(executable)
env = _probe_environment(executable)
probe_home = Path(env["_FABLE_PROBE_HOME"])
try:
- return run_argv(argv, env=env)
+ if _executable_identity(executable) != before:
+ raise RegistrationError("host executable changed before capability probe")
+ result = run_argv(argv, env=env)
+ after = _executable_identity(executable)
+ if after != before:
+ raise RegistrationError("host executable changed during capability probe")
+ return result
finally:
shutil.rmtree(probe_home, ignore_errors=True)
def _registration_environment(home: Path | None = None) -> dict[str, str]:
- """Return a least-privilege environment for one host mutation.
-
- Host executables are discovered from the user's PATH and therefore are
- arbitrary programs from an untrusted boundary. They must not receive the
- launcher's complete environment: API keys, cloud credentials, proxy
- tokens, and unrelated application secrets are not needed to add/remove an
- MCP entry. Keep only paths and process settings required for a normal CLI
- invocation. Credential forwarding, if ever needed by a host, must be an
- explicit future feature rather than an accidental inheritance side effect.
+ """Return the intended user environment for real CLI mutations.
+
+ Unlike health probes, registration must see the user's config location and
+ credentials. ``home`` is an explicit transaction override (used by the
+ launcher tests and by callers targeting another profile); it is never used
+ for discovery probes.
"""
- target_home = Path(home).expanduser() if home is not None else Path.home()
- value = str(target_home)
- # The executable itself has already been selected and checked. A system
- # PATH is enough for /usr/bin/env shebangs and for normal host subprocesses,
- # while avoiding user PATH entries that can select another interpreter.
- trusted_path = os.pathsep.join(dict.fromkeys([str(Path(sys.executable).parent), os.defpath]))
- def config_path(name: str, fallback: Path) -> str:
- # Preserve an explicitly configured profile location for normal
- # registration. An explicit ``home`` is a transaction override and
- # intentionally makes all profile paths follow that home.
- if home is None and os.environ.get(name):
- return os.environ[name]
- return str(fallback)
-
- env = {
- "PATH": trusted_path,
- "HOME": value,
- "USERPROFILE": value,
- "XDG_CONFIG_HOME": config_path("XDG_CONFIG_HOME", target_home / ".config"),
- "XDG_DATA_HOME": config_path("XDG_DATA_HOME", target_home / ".local" / "share"),
- "XDG_CACHE_HOME": config_path("XDG_CACHE_HOME", target_home / ".cache"),
- "XDG_STATE_HOME": config_path("XDG_STATE_HOME", target_home / ".local" / "state"),
- "LC_ALL": "C",
- "LANG": "C",
- "LANGUAGE": "C",
- "PYTHONIOENCODING": "utf-8",
- }
- # Temporary directories are operational rather than credential-bearing.
- # Preserve explicit caller choices because host CLIs may rely on them, but
- # never copy any other environment variable by default.
- for name in ("TMPDIR", "TMP", "TEMP"):
- if name in os.environ:
- env[name] = os.environ[name]
- if os.name == "nt":
- # These are required by cmd.exe-backed host launchers and Windows
- # configuration lookup. They contain system paths, not credentials.
- env["APPDATA"] = config_path("APPDATA", target_home / "AppData" / "Roaming")
- env["LOCALAPPDATA"] = config_path("LOCALAPPDATA", target_home / "AppData" / "Local")
- for name in ("SystemRoot", "SYSTEMROOT", "windir", "PATHEXT", "COMSPEC"):
- if name in os.environ:
- env[name] = os.environ[name]
+ env = dict(os.environ)
+ env.pop("_FABLE_PROBE_HOME", None)
+ if home is not None:
+ value = str(Path(home).expanduser())
+ env["HOME"] = value
+ env["USERPROFILE"] = value
return env
def _which(name: str) -> Path | None:
+ """Resolve a host binary from PATH, rejecting unsafe path components.
+
+ PATH is used only for discovery. The resulting absolute identity is then
+ pinned in ``Host`` and all mutations execute that exact path.
+ """
+ if not isinstance(name, str) or not name or "\x00" in name:
+ return None
value = shutil.which(name)
if not value:
return None
path = Path(value)
try:
+ _safe_path(path, allow_missing=False)
st = path.lstat()
- if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
+ if (stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode) or
+ st.st_nlink != 1 or (os.name == "posix" and not st.st_mode & 0o111)):
return None
- except OSError:
+ return path.absolute()
+ except (OSError, RegistrationError):
return None
- return path
def detect_hosts(*, aliases: bool = False) -> dict[str, Host]:
- names: list[tuple[str, str]] = [("claude", "claude"), ("agy", "agy"), ("codex", "codex")]
+ # Preserve the historical status keys while deriving all behavior from the
+ # canonical registry. Alias probing is opt-in to avoid invoking an
+ # unrelated executable named ``cc`` or ``antigravity``.
+ names: list[tuple[str, HostIdentity]] = [
+ ("claude", HOST_IDENTITY_REGISTRY["claude-code"]),
+ ("agy", HOST_IDENTITY_REGISTRY["antigravity"]),
+ ("codex", HOST_IDENTITY_REGISTRY["codex"]),
+ ]
if aliases:
- names += [("cc", "cc"), ("antigravity", "antigravity")]
+ names += [("cc", HOST_IDENTITY_REGISTRY["claude-code"]),
+ ("antigravity", HOST_IDENTITY_REGISTRY["antigravity"])]
result: dict[str, Host] = {}
- for name, binary in names:
- exe = _which(binary)
+ for name, identity in names:
+ exe = _which(name)
if exe is None:
- # Keep absence visible to callers instead of silently omitting a
- # requested host from the registration status report.
- result[name] = Host(name, Path(binary), "cli", False, "host command not found")
+ result[name] = Host(name, Path(identity.executable), identity.kind, False,
+ "host command not found", identity=identity.canonical)
continue
try:
+ executable_identity = _executable_identity(exe)
+ # --version is the documented, side-effect-free availability probe;
+ # registration state itself is accepted only from the explicit
+ # machine-readable ``mcp list --json`` command below.
code, out, err, timed_out = _run_probe([str(exe), "--version"], exe)
- result[name] = Host(name, exe, "cli", code == 0 and not timed_out, (out or err)[:MAX_OUTPUT])
+ result[name] = Host(name, exe, identity.kind, code == 0 and not timed_out,
+ (out or err)[:MAX_OUTPUT], identity=identity.canonical,
+ executable_identity=executable_identity)
except RegistrationError as exc:
- # A malformed/unresolved /usr/bin/env shebang makes this host
- # unhealthy, not the entire discovery/installation transaction.
- result[name] = Host(name, exe, "cli", False, str(exc)[:MAX_OUTPUT])
+ result[name] = Host(name, exe, identity.kind, False, str(exc)[:MAX_OUTPUT],
+ identity=identity.canonical)
return result
@@ -493,14 +797,17 @@ def visit(node: object, depth: int = 0) -> None:
return found if valid else None
-def _snapshot_cli_registrations(executable: Path, *, home: Path | None = None) -> dict[str, dict] | None:
+def _snapshot_cli_registrations(executable: Path, *, home: Path | None = None,
+ host: str | None = None) -> dict[str, dict] | None:
# Cleanup markers may outlive a removed host executable. Let the bounded
# argv call report that as an unsupported snapshot instead of raising a
# state-path traceback; existing paths are still checked for links.
if executable.exists() or executable.is_symlink():
_safe_path(executable, allow_missing=False)
+ identity = HOST_IDENTITY_REGISTRY.get(HOST_ALIASES.get(host or "", host or ""))
+ list_args = identity.list_argv if identity is not None else ("mcp", "list", "--json")
code, out, _err, timed = run_argv(
- [str(executable), "mcp", "list"], env=_registration_environment(home))
+ [str(executable), *list_args], env=_registration_environment(home))
if code != 0 or timed:
return None
try:
@@ -515,7 +822,10 @@ def _query_cli_registrations(executable: Path) -> dict[str, dict]:
def _cli_add(host: Host, name: str, entry: dict) -> tuple[int, str, str, bool]:
- if host.name == "claude":
+ if host.executable_identity is not None:
+ _assert_executable_identity(host)
+ is_claude = host.canonical_name == "claude-code" or host.name == "claude"
+ if is_claude:
argv = [str(host.executable), "mcp", "add", "--transport", "stdio", name, "--", entry["command"], *entry.get("args", [])]
else:
argv = [str(host.executable), "mcp", "add", name, "--", entry["command"], *entry.get("args", [])]
@@ -533,6 +843,8 @@ def _is_absent_result(result: tuple[int, str, str, bool]) -> bool:
def _cli_remove(host: Host, name: str) -> tuple[int, str, str, bool]:
+ if host.executable_identity is not None:
+ _assert_executable_identity(host)
return run_argv([str(host.executable), "mcp", "remove", name],
env=_registration_environment(host.registration_home))
@@ -559,10 +871,44 @@ def _post_entries(previous: dict[str, dict], installed: dict) -> dict[str, dict]
return {"fable-engine": installed}
-def register_hosts(hosts: dict[str, Host], installed_executable: list[str], *, home: Path | None = None,
- workspace: Path | None = None, dry_run: bool = False,
- records: list[dict] | None = None,
- owned_records: list[dict] | None = None) -> dict[str, str]:
+_REGISTRATION_LOCK = threading.RLock()
+
+
+@contextmanager
+def _registration_file_lock(home: Path):
+ """Serialize independent installer processes for one user profile."""
+ lock_path = Path(home) / ".fable-mode-registration.lock"
+ handle = None
+ try:
+ _safe_path(Path(home))
+ Path(home).mkdir(parents=True, exist_ok=True)
+ _safe_path(Path(home))
+ _safe_path(lock_path)
+ if os.name == "posix" and hasattr(os, "O_NOFOLLOW"):
+ fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
+ handle = os.fdopen(fd, "r+b", buffering=0)
+ else:
+ # Windows does not expose flock in the stdlib; the process-wide
+ # lock still prevents threads, while the file remains a visible
+ # synchronization point for native launchers.
+ handle = open(lock_path, "a+b")
+ if fcntl is not None:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
+ yield
+ finally:
+ if handle is not None:
+ if fcntl is not None:
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+ except OSError:
+ pass
+ handle.close()
+
+
+def _register_hosts(hosts: dict[str, Host], installed_executable: list[str], *, home: Path | None = None,
+ workspace: Path | None = None, dry_run: bool = False,
+ records: list[dict] | None = None,
+ owned_records: list[dict] | None = None) -> dict[str, str]:
"""Register all hosts as one transaction, with a complete preflight.
Every healthy host is snapshotted before the first mutation. CLI hosts
@@ -609,13 +955,24 @@ def prior_state(record: dict) -> dict[str, dict] | None:
if not host.healthy:
statuses[key] = "detected but unhealthy; not registered"
continue
+ captured_identity = host.executable_identity
+ if host.kind == "cli":
+ try:
+ captured_identity = _assert_executable_identity(host)
+ except RegistrationError as exc:
+ raise RegistrationError(f"{key} executable identity is not stable: {exc}") from exc
if key in {"agy", "antigravity"}:
+ if captured_identity is not None and host.executable_identity is None:
+ host = Host(host.name, host.executable, host.kind, host.healthy,
+ host.detail, host.registration_home, host.identity,
+ captured_identity)
active_hosts.append((key, host))
else:
# Preserve caller-supplied Host objects while binding CLI
# mutations to this transaction's intended profile, never probe HOME.
active_hosts.append((key, Host(host.name, host.executable, host.kind,
- host.healthy, host.detail, cli_home)))
+ host.healthy, host.detail, cli_home,
+ host.identity, captured_identity)))
if key in {"agy", "antigravity"}:
paths = [home / ".gemini" / "config" / "mcp_config.json"]
if workspace is not None:
@@ -624,7 +981,7 @@ def prior_state(record: dict) -> dict[str, dict] | None:
if path not in file_snaps:
file_snaps[path] = _load_config_snapshot(path)
else:
- snapshot = None if dry_run else _snapshot_cli_registrations(host.executable, home=cli_home)
+ snapshot = None if dry_run else _snapshot_cli_registrations(host.executable, home=cli_home, host=key)
if not dry_run and snapshot is None:
raise RegistrationError(f"{key} mcp list is not machine-readable; registration skipped before mutation")
snapshot = snapshot or {}
@@ -712,7 +1069,7 @@ def prior_state(record: dict) -> dict[str, dict] | None:
"previous_entries": previous, "previous_entries_hash": _state_hash(previous),
"post_entries": _post_entries(previous, installed),
"created": "fable-engine" not in previous,
- "executable_identity": list(_file_identity(host.executable))})
+ "executable_identity": list(_assert_executable_identity(host))})
statuses[key] = "registered"
return statuses
except Exception as exc:
@@ -752,6 +1109,29 @@ def prior_state(record: dict) -> dict[str, dict] | None:
raise RegistrationError(detail) from exc
+def register_hosts(hosts: dict[str, Host], installed_executable: list[str], *, home: Path | None = None,
+ workspace: Path | None = None, dry_run: bool = False,
+ records: list[dict] | None = None,
+ owned_records: list[dict] | None = None) -> dict[str, str]:
+ """Serialize registration transactions within a process.
+
+ Registration updates several independent host stores and must never be
+ interleaved with another install/uninstall in this process. The complete
+ preflight and rollback remain inside the lock, so a failed transaction
+ cannot expose a half-updated snapshot to a concurrent caller.
+ """
+ with _REGISTRATION_LOCK:
+ if dry_run:
+ return _register_hosts(hosts, installed_executable, home=home,
+ workspace=workspace, dry_run=True,
+ records=records, owned_records=owned_records)
+ lock_home = Path(home or Path.home()).expanduser()
+ with _registration_file_lock(lock_home):
+ return _register_hosts(hosts, installed_executable, home=home,
+ workspace=workspace, dry_run=False,
+ records=records, owned_records=owned_records)
+
+
def _record_state(record: dict) -> tuple[dict[str, dict], dict[str, dict]] | None:
previous = record.get("previous_entries")
post = record.get("post_entries")
@@ -813,8 +1193,7 @@ def _canonical_registration_path(record: dict, home: Path) -> Path | None:
return None
-def _validate_strict_record(record: object, install_dir: Path, home: Path,
- *, transaction: bool = False) -> bool:
+def _validate_strict_record(record: object, install_dir: Path, home: Path) -> bool:
"""Validate marker records before they can influence any user config."""
if not isinstance(record, dict) or record.get("install_dir") != str(install_dir):
return False
@@ -862,37 +1241,22 @@ def _validate_strict_record(record: object, install_dir: Path, home: Path,
host = record.get("host")
executable = record.get("executable")
ident = record.get("executable_identity")
- if host not in {"claude", "agy", "codex", "cc", "antigravity"} or not isinstance(executable, str):
+ if host not in {"claude", "claude-code", "agy", "codex", "cc", "antigravity"} or not isinstance(executable, str):
return False
p = Path(executable)
- # A persisted marker must bind the executable basename to its
- # allowlisted host. During the same-process install transaction,
- # tests/embedders may intentionally use a shim with another filename;
- # inode identity and all other record checks still apply.
- if not p.is_absolute() or (not transaction and
- p.name.casefold().split(".")[0] != host.casefold()):
+ expected_executable = HOST_IDENTITY_REGISTRY.get(HOST_ALIASES.get(host, host))
+ expected_names = {expected_executable.executable.casefold()} if expected_executable else {host.casefold()}
+ if not p.is_absolute() or p.name.casefold().split(".")[0] not in expected_names:
return False
- return (isinstance(ident, list) and len(ident) == 2 and all(isinstance(x, int) and x >= 0 for x in ident))
+ return (isinstance(ident, list) and len(ident) == 5
+ and all(isinstance(x, int) and x >= 0 for x in ident[:4])
+ and isinstance(ident[4], str) and len(ident[4]) == 64
+ and all(c in "0123456789abcdef" for c in ident[4]))
return False
-def validate_registration_record(record: object, install_dir: Path, home: Path,
- *, transaction: bool = False) -> bool:
- """Validate a persisted registration record without touching host state.
-
- The launcher uses this after a host mutation but before persisting recovery
- metadata. ``transaction`` is deliberately an explicit, narrow escape
- hatch for same-process test/transaction shims whose executable basename is
- not the discovered host name; normal uninstall validation never enables it.
- """
- return _validate_strict_record(record, Path(install_dir), Path(home),
- transaction=transaction)
-
-
-def cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
- install_dir: Path | None = None, home: Path | None = None,
- _preflight: bool = False,
- _transaction: bool = False) -> list[str]:
+def _cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
+ install_dir: Path | None = None, home: Path | None = None) -> list[str]:
"""Remove this install's entries, restoring exact pre-install state.
Strict mode is used for uninstall marker data. It accepts only records
@@ -907,18 +1271,8 @@ def cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
home = Path(home or Path.home())
if strict and (install_dir is None or not isinstance(records, list)):
return ["invalid registration marker"]
- if strict and not _preflight:
- # Validate every host/config before mutating any of them. In
- # particular, an unavailable later host must not leave earlier records
- # restored while their marker still claims the installation is active.
- unresolved = cleanup_recorded_registrations(
- records, strict=True, install_dir=install_dir, home=home,
- _preflight=True, _transaction=_transaction)
- if unresolved:
- return unresolved
for record in records or []:
- if strict and not _validate_strict_record(
- record, Path(install_dir), home, transaction=_transaction):
+ if strict and not _validate_strict_record(record, Path(install_dir), home):
skipped.append(str(record.get("path", record.get("host", "invalid record"))) if isinstance(record, dict) else "invalid record")
continue
state = _record_state(record)
@@ -926,26 +1280,20 @@ def cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
path = Path(record.get("path", ""))
try:
_safe_path(path, allow_missing=False)
- # Check identity, content integrity, and mode before parsing
- # any bytes from the config. Besides being cheaper on a
- # tampered path, this keeps untrusted config contents from
- # influencing cleanup decisions until the path is bound to the
- # exact file published by this install.
if strict:
if _file_identity(path) != tuple(record["post_identity"]):
skipped.append(str(path)); continue
- if (os.name != "nt" and "post_mode" in record
+ if _hash_bytes(path.read_bytes()) != record["post_content_hash"]:
+ skipped.append(str(path)); continue
+ # New records always publish private configs. Refuse to
+ # mutate a file whose mode changed unexpectedly.
+ if ("post_mode" in record
and stat.S_IMODE(path.stat().st_mode) != record["post_mode"]):
skipped.append(str(path)); continue
- raw = path.read_bytes()
- if strict and _hash_bytes(raw) != record["post_content_hash"]:
- skipped.append(str(path)); continue
- data = json.loads(raw.decode("utf-8"))
+ data = json.loads(path.read_text(encoding="utf-8"))
servers = data.get("mcpServers", {})
- if not isinstance(servers, dict):
- skipped.append(str(path)); continue
expected = {"command": record.get("command"), "args": record.get("args", [])}
- if servers.get("fable-engine") != expected:
+ if not isinstance(servers, dict) or servers.get("fable-engine") != expected:
skipped.append(str(path)); continue
if state is not None:
_previous, post = state
@@ -966,33 +1314,24 @@ def cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
# Compatibility with markers written by older releases.
data["mcpServers"].pop(record.get("name", "fable-engine"), None)
restored_bytes = (json.dumps(data, indent=2, sort_keys=True) + "\n").encode()
- if _preflight:
- continue
- # A config created by this install has no prior mode to
- # restore. On POSIX, mode 0 is harmless for unlinking; on
- # Windows, chmod(0) sets the read-only attribute and the
- # subsequent unlink fails with access denied. We have
- # already verified the exact post-install inode and bytes
- # above, so remove the installer-owned file directly instead
- # of publishing a transient mode-0 replacement. This also
- # preserves the fail-closed identity/hash checks for the
- # deletion itself.
- if strict and record.get("existed") is False:
- _safe_path(path, allow_missing=False)
- path.unlink()
- continue
restore_mode = record.get("previous_mode", stat.S_IMODE(path.stat().st_mode))
if not isinstance(restore_mode, int) or not 0 <= restore_mode <= 0o777:
skipped.append(str(path)); continue
_atomic_write(path, restored_bytes, restore_mode)
+ # A newly-created Antigravity config is installer-owned. Once
+ # its exact post-install contents have been verified/restored,
+ # remove the file rather than leaving an empty artifact.
+ if strict and record.get("existed") is False:
+ _safe_path(path, allow_missing=False)
+ path.unlink()
except (OSError, ValueError, TypeError, RegistrationError, json.JSONDecodeError):
skipped.append(str(path))
elif record.get("kind") == "cli":
executable = Path(record.get("executable", ""))
try:
- if strict and _file_identity(executable) != tuple(record["executable_identity"]):
+ if strict and _executable_identity(executable) != tuple(record["executable_identity"]):
skipped.append(str(record.get("host", "cli"))); continue
- current = _snapshot_cli_registrations(executable, home=home)
+ current = _snapshot_cli_registrations(executable, home=home, host=str(record.get("host", "")))
if current is None:
skipped.append(str(record.get("host", "cli"))); continue
expected = {"command": record.get("command"), "args": record.get("args", [])}
@@ -1004,11 +1343,25 @@ def cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
previous, post = state
if any((current.get(name) if name in current else None) != post.get(name) for name in _NAMES):
skipped.append(str(record.get("host", "cli"))); continue
+ recorded_identity = record.get("executable_identity")
host = Host(str(record.get("host", "cli")), executable, "cli", True,
- registration_home=home)
- if not _preflight:
- _restore_cli(host, previous)
+ registration_home=home,
+ executable_identity=(tuple(recorded_identity)
+ if isinstance(recorded_identity, list) and len(recorded_identity) == 5
+ else None))
+ _restore_cli(host, previous)
except (OSError, ValueError, TypeError, RegistrationError):
skipped.append(str(record.get("host", "cli")))
return skipped
+
+def cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
+ install_dir: Path | None = None, home: Path | None = None) -> list[str]:
+ """Guard registration cleanup with the same profile transaction lock."""
+ with _REGISTRATION_LOCK:
+ if home is None:
+ return _cleanup_recorded_registrations(records, strict=strict,
+ install_dir=install_dir, home=home)
+ with _registration_file_lock(Path(home).expanduser()):
+ return _cleanup_recorded_registrations(records, strict=strict,
+ install_dir=install_dir, home=home)
diff --git a/fable_mode/installer.py b/fable_mode/installer.py
index f5614b6..4d4b481 100644
--- a/fable_mode/installer.py
+++ b/fable_mode/installer.py
@@ -20,6 +20,20 @@
MARKER = ".fable-install.json"
+def default_data_dir() -> Path:
+ """Return the single documented per-user Fable data directory.
+
+ ``FABLE_DATA_DIR`` is an explicit override; otherwise platform-native user
+ data roots are used consistently by the launcher and installer tooling.
+ """
+ override = os.environ.get("FABLE_DATA_DIR")
+ if override:
+ return Path(override).expanduser().absolute()
+ if os.name == "nt" and os.environ.get("LOCALAPPDATA"):
+ return Path(os.environ["LOCALAPPDATA"]) / "FableMode" / "data"
+ return Path.home() / ".local" / "share" / PRODUCT / "data"
+
+
class InstallError(RuntimeError):
pass
@@ -160,11 +174,7 @@ def commit(self) -> None:
self._verify_backup()
safe_cleanup(self.backup_dir, expected_identity=self.backup_identity)
except (OSError, ValueError, InstallError) as exc:
- # Keep the transaction rollbackable. The newly published
- # install may already be referenced by host registrations, so
- # callers must restore those registrations before removing it.
- # Marking this transaction done here would strand both the new
- # runtime and the old backup on a commit failure.
+ self.done = True
raise InstallError("previous installation backup is unsafe; preserved") from exc
self.done = True
@@ -205,6 +215,7 @@ def __init__(self, install_dir: Path | None = None, *, source: Path | None = Non
default = (Path(os.environ["LOCALAPPDATA"]) / "FableMode" if os.name == "nt" and os.environ.get("LOCALAPPDATA")
else Path.home() / ".local" / "share" / "fable-mode")
self.install_dir = Path(install_dir or os.environ.get("FABLE_INSTALL_DIR", default)).expanduser().absolute()
+ self.data_dir = default_data_dir()
self.source = Path(source or source_root()).absolute()
self.previous_registrations: list[dict] = []
@@ -425,23 +436,12 @@ def uninstall(self, *, dry_run: bool = False) -> bool:
marker_data.get("registrations", []), strict=True,
install_dir=self.install_dir, home=Path.home())
if skipped:
- # Never delete the marker that contains previous_entries
- # recovery state while a host cannot be inspected or has
- # changed. The caller can retry uninstall after restoring
- # the host executable/config; silently removing this state
- # would strand a stale fable-engine registration.
- detail = ", ".join(skipped)
- raise InstallError(
- "registration cleanup unresolved; installation and recovery "
- f"metadata preserved ({detail})")
- except InstallError:
- raise
- except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
- # A malformed/unreadable marker is itself recovery-critical.
- # verify_installation normally catches this earlier, but keep
- # this path fail-closed if the file changes after verification.
- raise InstallError(
- "could not inspect registration recovery metadata; installation preserved") from exc
+ # A host/config mismatch is intentionally non-fatal: the
+ # install tree can still be removed, but user changes are
+ # retained and made visible to an interactive caller.
+ print("uninstall: registration mismatch; preserved " + ", ".join(skipped), file=sys.stderr)
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
+ pass
safe_cleanup(self.install_dir, expected_identity=identity)
except (OSError, ValueError) as exc:
raise InstallError("refusing to remove an unsafe installation directory") from exc
@@ -521,4 +521,3 @@ def verify_installation(install_dir: Path) -> tuple[bool, str]:
return True, "ok"
except (OSError, ValueError, TypeError, InstallError, json.JSONDecodeError) as exc:
return False, str(exc)
-
diff --git a/fable_mode/launcher.py b/fable_mode/launcher.py
index ef0e592..e762c9b 100644
--- a/fable_mode/launcher.py
+++ b/fable_mode/launcher.py
@@ -11,12 +11,10 @@
import sys
import tempfile
import threading
-import time
from pathlib import Path
-from .adapters import (RegistrationError, cleanup_recorded_registrations,
- detect_hosts, register_hosts, validate_registration_record)
-from .installer import Installer, InstallError, verify_installation
+from .adapters import RegistrationError, detect_hosts, register_hosts
+from .installer import Installer, InstallError, default_data_dir, verify_installation
from .manifest import ALLOWED_FILES, validate_manifest
from . import __version__
@@ -35,13 +33,7 @@ def _assert_private_path(path: Path) -> None:
except FileNotFoundError:
continue
attrs = int(getattr(st, "st_file_attributes", 0))
- reparse = bool(attrs & 0x400)
- junction = bool(getattr(part, "is_junction", lambda: False)())
- trusted_macos_alias = (
- sys.platform == "darwin" and str(part) in {"/var", "/tmp"}
- and str(part.resolve()) in {"/private/var", "/private/tmp"}
- )
- if ((reparse or junction or stat.S_ISLNK(st.st_mode)) and not trusted_macos_alias) or stat.S_ISSOCK(st.st_mode) or stat.S_ISFIFO(st.st_mode) or stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode):
+ if attrs & 0x400 or stat.S_ISLNK(st.st_mode) or stat.S_ISSOCK(st.st_mode) or stat.S_ISFIFO(st.st_mode) or stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode):
raise RuntimeError("state path contains a symlink, reparse point, or special file")
@@ -50,10 +42,8 @@ def _data_dir(state_dir: str | None) -> Path:
path = Path(state_dir).expanduser().absolute()
elif os.environ.get("FABLE_DATA_DIR"):
path = Path(os.environ["FABLE_DATA_DIR"]).expanduser().absolute()
- elif os.name == "nt" and os.environ.get("LOCALAPPDATA"):
- path = Path(os.environ["LOCALAPPDATA"]) / "FableMode" / "data"
else:
- path = Path.home() / ".local" / "share" / "fable-mode" / "data"
+ path = default_data_dir()
# Do not follow an attacker-controlled final directory or parent.
_assert_private_path(path)
try:
@@ -82,11 +72,14 @@ def _kill_process(proc: subprocess.Popen[bytes]) -> None:
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
- # CREATE_NEW_PROCESS_GROUP is used below. Terminate the direct
- # child; Windows Job Objects are not available in stdlib, so this
- # fallback deliberately fails safe rather than invoking a shell.
+ root = Path(os.environ.get("SystemRoot", r"C:\\Windows"))
+ taskkill = root / "System32" / "taskkill.exe"
+ if taskkill.is_file():
+ subprocess.run([str(taskkill), "/PID", str(proc.pid), "/T", "/F"],
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, timeout=3, check=False)
proc.kill()
- except (OSError, ProcessLookupError):
+ except (OSError, ProcessLookupError, subprocess.TimeoutExpired):
try:
proc.kill()
except OSError:
@@ -106,17 +99,25 @@ def finish(value: tuple[bool, str]) -> tuple[bool, str]:
shutil.rmtree(probe_home, ignore_errors=True)
return value
env = {
- "PATH": os.pathsep.join(dict.fromkeys([str(Path(sys.executable).parent), os.defpath])),
+ "PATH": os.defpath,
"HOME": str(probe_home),
"USERPROFILE": str(probe_home),
"XDG_CONFIG_HOME": str(probe_home / ".config"),
"FABLE_DATA_DIR": str(data_dir),
"PYTHONIOENCODING": "utf-8",
+ "PYTHONUNBUFFERED": "1",
}
+ # Source-mode verification launches the installed runtime from an
+ # arbitrary data-directory cwd. Bind imports to that exact runtime copy;
+ # do not rely on the caller's ambient PYTHONPATH. Preserve only the
+ # Windows variables required for a child interpreter to start.
+ if len(argv) >= 2 and Path(argv[1]).name == "fable_mode_entry.py":
+ env["PYTHONPATH"] = str(Path(argv[1]).resolve().parent)
if os.name == "nt":
- for var in ("SystemRoot", "SYSTEMROOT", "windir", "PATHEXT", "TEMP", "TMP"):
- if var in os.environ:
- env[var] = os.environ[var]
+ for key in ("SystemRoot", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "PATHEXT"):
+ value = os.environ.get(key)
+ if value:
+ env[key] = value
kwargs: dict = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": env, "cwd": str(data_dir)}
if os.name == "posix":
kwargs["start_new_session"] = True
@@ -141,39 +142,12 @@ def drain(pipe, key: str) -> None:
json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n").encode()
assert proc.stdin is not None
proc.stdin.write(request)
- proc.stdin.flush()
proc.stdin.close()
readers = [threading.Thread(target=drain, args=(proc.stdout, "out"), daemon=True),
threading.Thread(target=drain, args=(proc.stderr, "err"), daemon=True)]
for reader in readers:
reader.start()
-
- def _has_responses() -> bool:
- lines = bytes(captured["out"]).splitlines()[:8]
- seen_ids = set()
- for line in lines:
- if not line.strip():
- continue
- try:
- item = json.loads(line)
- if isinstance(item, dict) and "id" in item:
- seen_ids.add(item["id"])
- except Exception:
- pass
- return {1, 2}.issubset(seen_ids)
-
- deadline = time.monotonic() + 8.0
- while time.monotonic() < deadline:
- if _has_responses() or proc.poll() is not None:
- break
- time.sleep(0.05)
-
- if proc.poll() is None:
- _kill_process(proc)
- try:
- proc.wait(timeout=2)
- except Exception:
- pass
+ proc.wait(timeout=8)
for reader in readers:
reader.join(timeout=2)
except (OSError, subprocess.TimeoutExpired) as exc:
@@ -201,7 +175,11 @@ def _has_responses() -> bool:
messages.append(item)
ids = {m.get("id") for m in messages}
if {1, 2} - ids:
- return finish((False, "MCP smoke check did not receive initialize/tools-list responses"))
+ stderr = bytes(captured["err"]).decode("utf-8", "replace").strip()
+ detail = "MCP smoke check did not receive initialize/tools-list responses"
+ if stderr:
+ detail += ": " + stderr[:1024]
+ return finish((False, detail))
by_id = {m["id"]: m for m in messages}
if not isinstance(by_id[1]["result"].get("protocolVersion"), str):
return finish((False, "MCP initialize response is missing protocolVersion"))
@@ -262,35 +240,6 @@ def _parser() -> argparse.ArgumentParser:
return p
-def _bind_registration_records(records: list[dict], install_dir: Path,
- workspace: Path | None) -> None:
- """Bind host snapshots to this exact published install before persistence."""
- install_st = install_dir.lstat()
- if not stat.S_ISDIR(install_st.st_mode):
- raise RuntimeError("published installation is not a directory")
- for record in records:
- if not isinstance(record, dict):
- raise RuntimeError("host registration produced an invalid recovery record")
- record["install_dir"] = str(install_dir)
- record["install_identity"] = [install_st.st_dev, install_st.st_ino]
- if record.get("kind") == "file" and Path(record.get("path", "")).parent.name == ".agents":
- record["workspace"] = str(workspace) if workspace else ""
- if not validate_registration_record(record, install_dir, Path.home(), transaction=True):
- raise RuntimeError("host registration produced an invalid recovery record")
-
-
-def _records_for_marker(install_dir: Path, records: list[dict]) -> list[dict]:
- """Merge new records with still-owned records already in the marker."""
- marker_data = json.loads((install_dir / ".fable-install.json").read_text(encoding="utf-8"))
- prior_records = [record for record in marker_data.get("registrations", [])
- if isinstance(record, dict)]
- new_keys = {(record.get("kind"), record.get("host", record.get("path")))
- for record in records}
- retained = [record for record in prior_records
- if (record.get("kind"), record.get("host", record.get("path"))) not in new_keys]
- return retained + records
-
-
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
if args.mode == "version":
@@ -323,7 +272,6 @@ def main(argv: list[str] | None = None) -> int:
if input(f"Install Fable to {installer.install_dir}? [y/N] ").strip().lower() not in {"y", "yes"}:
print("Installation cancelled.")
return 0
- registration_workspace: Path | None = None
try:
frozen = Path(sys.executable) if _is_frozen() else None
result = installer.install(frozen_executable=frozen, dry_run=args.dry_run)
@@ -347,79 +295,42 @@ def main(argv: list[str] | None = None) -> int:
records=registration_records,
owned_records=installer.previous_registrations)
if registration_records:
- # Bind and validate marker records against this exact
- # installation. Workspace config paths are also recorded
- # explicitly so uninstall never accepts an arbitrary path from
- # a marker.
- _bind_registration_records(registration_records, result.install_dir,
- registration_workspace)
+ # Bind marker records to this exact installation. Workspace
+ # config paths are also recorded explicitly so uninstall never
+ # accepts a marker-supplied arbitrary path.
+ install_st = result.install_dir.lstat()
+ for record in registration_records:
+ record["install_dir"] = str(result.install_dir)
+ record["install_identity"] = [install_st.st_dev, install_st.st_ino]
+ if record.get("kind") == "file" and Path(record.get("path", "")).parent.name == ".agents":
+ record["workspace"] = str(registration_workspace) if registration_workspace else ""
# Keep ownership records for previously registered hosts that
# are now absent/unhealthy; otherwise a reinstall would orphan
# those old entries. Records for hosts touched successfully by
# this transaction are replaced by the new exact snapshots.
- installer.record_registrations(
- _records_for_marker(result.install_dir, registration_records))
+ marker_data = json.loads((result.install_dir / ".fable-install.json").read_text(encoding="utf-8"))
+ prior_records = [record for record in marker_data.get("registrations", [])
+ if isinstance(record, dict)]
+ new_keys = {(record.get("kind"), record.get("host", record.get("path")))
+ for record in registration_records}
+ retained = [record for record in prior_records
+ if (record.get("kind"), record.get("host", record.get("path"))) not in new_keys]
+ installer.record_registrations(retained + registration_records)
if result.transaction:
result.transaction.commit()
print(f"Fable installed at {result.install_dir}")
if statuses:
print(json.dumps(statuses, sort_keys=True))
return 0
- except Exception as exc:
- # Host mutation and installation publication form one transaction. A
- # marker write or backup commit can fail *after* register_hosts has
- # successfully changed one or more hosts, so restore those hosts before
- # removing the runtime they now reference. If restoration is not
- # complete, deliberately retain the new installation and its marker so
- # the registration remains usable/recoverable rather than leaving a
- # silently stale host entry.
- cleanup_errors: list[str] = []
- persistence_error: Exception | None = None
- preserve_install = False
- if ('result' in locals() and result.transaction
- and 'registration_records' in locals() and registration_records):
- try:
- cleanup_errors = cleanup_recorded_registrations(
- registration_records, strict=True,
- install_dir=result.install_dir, home=Path.home(),
- _transaction=True)
- except Exception as cleanup_exc:
- cleanup_errors = [f"cleanup failed: {cleanup_exc}"]
- preserve_install = bool(cleanup_errors)
- if preserve_install:
- # The first marker write may itself have failed after the
- # hosts were changed. Keep the new install genuinely
- # recoverable: persist the bound snapshots before returning
- # the failure, even though the normal transaction rollback is
- # being skipped. Rebinding also protects this recovery path
- # if the original failure happened during record preparation.
- try:
- _bind_registration_records(
- registration_records, result.install_dir,
- registration_workspace)
- installer.record_registrations(
- _records_for_marker(result.install_dir, registration_records))
- except Exception as persist_exc:
- persistence_error = persist_exc
- rollback_error: Exception | None = None
- if not preserve_install and 'result' in locals() and result.transaction:
- try:
+ except (InstallError, RegistrationError, OSError) as exc:
+ try:
+ if 'result' in locals() and result.transaction:
result.transaction.rollback()
- except Exception as rollback_exc:
- rollback_error = rollback_exc
- details = [str(exc)]
- if cleanup_errors:
- details.append("host registration cleanup incomplete; installation preserved for recovery: "
- + ", ".join(cleanup_errors))
- if persistence_error:
- details.append("could not persist registration recovery records; host state and installation were preserved: "
- + str(persistence_error))
- if rollback_error:
- details.append("installation rollback incomplete; partial state preserved")
- print("install: " + "; ".join(details), file=sys.stderr)
+ except Exception:
+ pass
+ print(f"install: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
-
diff --git a/fable_mode/manifest.py b/fable_mode/manifest.py
index a3452ea..6da0bea 100644
--- a/fable_mode/manifest.py
+++ b/fable_mode/manifest.py
@@ -24,6 +24,7 @@
"fable_v2/execution_broker.py",
"fable_v2/protocol.py",
"fable_v2/runtime.py",
+ "fable_v2/verifiers.py",
"fable_v2/system3/__init__.py",
"fable_v2/system3/causal.py",
"fable_v2/system3/dialectical.py",
@@ -34,7 +35,6 @@
"fable_v2/system3/induction.py",
"fable_v2/system3/kripke.py",
"fable_v2/system3/oracle.py",
- "fable_v2/verifiers.py",
"rules/AGENTS.md",
"rules/GEMINI.md",
"rules/fable-mode.md",
diff --git a/fable_mode/resources.json b/fable_mode/resources.json
index 231acaa..21825a2 100644
--- a/fable_mode/resources.json
+++ b/fable_mode/resources.json
@@ -17,6 +17,7 @@
"fable_v2/execution_broker.py",
"fable_v2/protocol.py",
"fable_v2/runtime.py",
+ "fable_v2/verifiers.py",
"fable_v2/system3/__init__.py",
"fable_v2/system3/causal.py",
"fable_v2/system3/dialectical.py",
@@ -27,7 +28,6 @@
"fable_v2/system3/induction.py",
"fable_v2/system3/kripke.py",
"fable_v2/system3/oracle.py",
- "fable_v2/verifiers.py",
"rules/AGENTS.md",
"rules/GEMINI.md",
"rules/fable-mode.md",
diff --git a/fable_v2/__init__.py b/fable_v2/__init__.py
index 07c5e27..6095bbb 100644
--- a/fable_v2/__init__.py
+++ b/fable_v2/__init__.py
@@ -1,7 +1,7 @@
-"""Experimental portable Fable V2 runtime with System 3 Meta-Cognitive Architecture."""
+"""Experimental portable Fable V2 runtime."""
from .adapters import HOST_PROFILES, HostCapabilities, get_profile
-from .execution_broker import BrokerPolicy, ExecutionBroker
+from .execution_broker import BrokerPolicy, BrokerReceipt, ExecutionBroker
from .protocol import (
Candidate,
Evidence,
@@ -9,123 +9,33 @@
ToolReceipt,
VerificationPolicy,
VerificationResult,
+ VerificationStatus,
)
-from .runtime import FableRun, RunState, new_run
-from .verifiers import CompositeVerifier, FunctionVerifier
-from . import system3
-from .system3 import (
- # System 3 Causal
- CausalDAG,
- CausalNode,
- CausalEdge,
- CausalNodeType,
- BrittlenessReport,
- InterventionResult,
- # System 3 Dialectical
- ThesisCandidate,
- AntithesisCritique,
- Contradiction,
- TRIZPrinciple,
- TRIZContradictionResolver,
- DialecticalSynthesizer,
- EmergentSynthesis,
- # System 3 Evolution
- CognitiveGenome,
- CognitiveGenePool,
- # System 3 Induction
- NeuroSymbolicAxiom,
- AxiomProvenance,
- AxiomStatus,
- MetaProofInducer,
- # System 3 Executive
- CognitiveGear,
- CognitiveBiasType,
- CognitiveBiasFinding,
- CognitiveBiasDetector,
- DynamicSearchHeuristicRewriter,
- SearchHeuristicConfig,
- TriLevelArbitrator,
- System3Executive,
- # System 3 Hyperbolic
- PoincareBall,
- HyperbolicPoint,
- HyperbolicTreeEmbedder,
- TreeEmbeddingNode,
- TreeEmbeddingResult,
- HyperbolicGeometryError,
- # System 3 Kripke
- KripkeStructure,
- KripkeWorld,
- KripkeModelChecker,
- ModelCheckResult,
- CTLOperator,
- FormulaNode,
- FormulaParser,
- # System 3 Free Energy
- ActiveInferenceEngine,
- GenerativeModel,
- Policy,
- PolicyEvaluation,
- FreeEnergyReport,
- create_default_architecture_pomdp,
- # System 3 Oracle
- ProofOracle,
- CurryHowardVerifier,
- UndecidabilityDetector,
- TacticsEngine,
- FormalProofResult,
- ProofStatus,
- Type,
- Term,
- Prop,
- Unit,
- Void,
- Implies,
- And,
- Or,
- Not,
- Eq,
- Forall,
- Exists,
- Var,
- Lam,
- App,
- Pair,
- Fst,
- Snd,
- Inl,
- Inr,
- Case,
- Refl,
- Abort,
+from .runtime import (
+ FableRun, HMACProcessAttestationVerifier, ProcessAttestation,
+ ProcessAttestationVerifier, RunState, new_run,
+)
+from .verifiers import (
+ Adjudication, CalibrationMetrics, Claim, ClaimGraph, CompositeVerifier,
+ Counterexample, CounterexampleStore, FunctionVerifier, MetamorphicRelation,
+ MetamorphicVerifier, MutationOperator, MutationVerifier, PlannedCheck,
+ PortfolioResult, PropertyCheck, PropertyVerifier, RiskLevel,
+ ThreeValuedAdjudicator, Verdict, VerificationDecision, Verifier,
+ VerifierDecision, VerifierPlan, VerifierPlanner, VerifierPortfolio,
+ VerifierStatus,
)
__all__ = [
- "BrokerPolicy", "Candidate", "CompositeVerifier", "Evidence", "ExecutionBroker", "FableRun",
- "FunctionVerifier", "HOST_PROFILES", "HostCapabilities", "RunState",
- "TaskSpec", "ToolReceipt", "VerificationPolicy", "VerificationResult",
+ "Adjudication", "BrokerPolicy", "BrokerReceipt", "CalibrationMetrics",
+ "Candidate", "Claim", "ClaimGraph", "CompositeVerifier", "Counterexample",
+ "CounterexampleStore", "Evidence", "ExecutionBroker", "FableRun",
+ "FunctionVerifier", "HMACProcessAttestationVerifier", "HOST_PROFILES",
+ "HostCapabilities", "MetamorphicRelation", "MetamorphicVerifier",
+ "MutationOperator", "MutationVerifier", "PlannedCheck", "PortfolioResult",
+ "ProcessAttestation", "ProcessAttestationVerifier", "PropertyCheck",
+ "PropertyVerifier", "RiskLevel", "RunState", "TaskSpec", "ThreeValuedAdjudicator",
+ "ToolReceipt", "Verdict", "VerificationDecision", "VerificationPolicy",
+ "VerificationResult", "VerificationStatus", "Verifier", "VerifierDecision",
+ "VerifierPlan", "VerifierPlanner", "VerifierPortfolio", "VerifierStatus",
"get_profile", "new_run",
- "system3",
- # System 3 Causal
- "CausalDAG", "CausalNode", "CausalEdge", "CausalNodeType", "BrittlenessReport", "InterventionResult",
- # System 3 Dialectical
- "ThesisCandidate", "AntithesisCritique", "Contradiction", "TRIZPrinciple", "TRIZContradictionResolver",
- "DialecticalSynthesizer", "EmergentSynthesis",
- # System 3 Evolution
- "CognitiveGenome", "CognitiveGenePool",
- # System 3 Induction
- "NeuroSymbolicAxiom", "AxiomProvenance", "AxiomStatus", "MetaProofInducer",
- # System 3 Executive
- "CognitiveGear", "CognitiveBiasType", "CognitiveBiasFinding", "CognitiveBiasDetector",
- "DynamicSearchHeuristicRewriter", "SearchHeuristicConfig", "TriLevelArbitrator", "System3Executive",
- # System 3 Hyperbolic
- "PoincareBall", "HyperbolicPoint", "HyperbolicTreeEmbedder", "TreeEmbeddingNode", "TreeEmbeddingResult", "HyperbolicGeometryError",
- # System 3 Kripke
- "KripkeStructure", "KripkeWorld", "KripkeModelChecker", "ModelCheckResult", "CTLOperator", "FormulaNode", "FormulaParser",
- # System 3 Free Energy
- "ActiveInferenceEngine", "GenerativeModel", "Policy", "PolicyEvaluation", "FreeEnergyReport", "create_default_architecture_pomdp",
- # System 3 Oracle
- "ProofOracle", "CurryHowardVerifier", "UndecidabilityDetector", "TacticsEngine", "FormalProofResult", "ProofStatus",
- "Type", "Term", "Prop", "Unit", "Void", "Implies", "And", "Or", "Not", "Eq", "Forall", "Exists",
- "Var", "Lam", "App", "Pair", "Fst", "Snd", "Inl", "Inr", "Case", "Refl", "Abort",
]
diff --git a/fable_v2/adapters.py b/fable_v2/adapters.py
index 3d19ad4..ca08b39 100644
--- a/fable_v2/adapters.py
+++ b/fable_v2/adapters.py
@@ -8,9 +8,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Iterable, Mapping, Any
-
-from .system3 import KripkeStructure, KripkeModelChecker, CausalDAG, CausalNode, CausalNodeType
+from typing import Iterable, Mapping
@dataclass(frozen=True)
@@ -50,47 +48,6 @@ def attest(self, observed: Iterable[str], source: str = "host-probe") -> "HostCa
source=source,
)
- def verify_temporal_capability_invariants(self, formula: str = "AG(capable)") -> dict[str, object]:
- """Verify modal temporal logic invariant for host capabilities."""
- kripke = KripkeStructure()
- kripke.add_world("w_init", propositions={"initialized", "capable"} if self.is_attested else {"initialized"})
- kripke.add_world("w_ready", propositions={"ready", "capable"} if self.is_attested else {"ready"})
- kripke.add_transition("w_init", "w_ready")
- kripke.add_transition("w_ready", "w_ready")
- checker = KripkeModelChecker(kripke)
- res = checker.check(formula, "w_init")
- return {
- "formula": formula,
- "is_satisfied": res.is_satisfied,
- "satisfying_worlds": sorted(list(res.satisfied_worlds)),
- }
-
- def validate_causal_capability_contract(self, target_capabilities: Iterable[str]) -> dict[str, object]:
- """Validate causal DAG dependency flow for required capabilities."""
- targets = list(target_capabilities)
- available = self.observed_capabilities if self.is_attested else self.capabilities
- missing = [c for c in targets if c not in available]
- dag = CausalDAG(name=f"HostCapabilities_{self.host}")
- for c in targets:
- dag.add_node(
- node_id=f"cap_{c}",
- name=c,
- node_type=CausalNodeType.EXOGENOUS if c in available else CausalNodeType.ENDOGENOUS,
- value=1.0 if c in available else 0.0,
- )
- first_target = f"cap_{targets[0]}" if targets else None
- if first_target and first_target in dag.nodes:
- report = dag.evaluate_brittleness(target_metric=first_target)
- b_score = report.overall_brittleness_score
- else:
- b_score = 0.0
- return {
- "is_valid": len(missing) == 0,
- "missing": missing,
- "brittleness_score": b_score,
- "dag": dag.to_dict(),
- }
-
def compatibility_report(self, required: Iterable[str]) -> dict[str, object]:
required_set = set(required)
expected = required_set & self.capabilities
@@ -108,11 +65,79 @@ def compatibility_report(self, required: Iterable[str]) -> dict[str, object]:
"missing": sorted(missing),
}
+ def verify_temporal_capability_invariants(self, formula: str = "AG(capable)") -> dict[str, object]:
+ """Return a non-authoritative temporal capability projection.
+
+ This convenience report is intentionally separate from broker/MCP
+ authorization. In particular, an ``is_satisfied`` value here cannot
+ attest that a host actually executed a tool or unlock any strict gate.
+ """
+ if not isinstance(formula, str) or not formula.strip():
+ raise ValueError("formula must be a non-empty string")
+ normalized = formula.strip()
+ # ``AG(capable)`` is the legacy adapter spelling. It describes the
+ # profile's declared capability set, not an observed host fact.
+ if normalized.lower() in {"ag(capable)", "ag(safe_execution)", "ag(safe)"}:
+ # Expected profiles are deliberately not host attestations. Only
+ # a probed profile can satisfy a capability-state invariant.
+ satisfied = self.is_attested and bool(self.observed_capabilities)
+ else:
+ satisfied = False
+ return {
+ "formula": normalized,
+ "is_satisfied": satisfied,
+ "satisfying_worlds": ["w_init", "w_ready"] if satisfied else [],
+ "authoritative": self.is_attested,
+ "claim_status": "compatibility_telemetry_only",
+ "capabilities": sorted(self.observed_capabilities if self.is_attested else self.capabilities),
+ }
+
+ def validate_causal_capability_contract(self, required: Iterable[str]) -> dict[str, object]:
+ """Validate a declared capability contract for legacy adapters.
+
+ This checks profile compatibility only. Strict callers must still
+ use an attested profile and broker authorization before execution.
+ """
+ targets = [str(item).strip() for item in required if str(item).strip()]
+ available_set = set(self.observed_capabilities if self.is_attested else self.capabilities)
+ missing = [item for item in targets if item not in available_set]
+ # Build the same small causal representation exposed by the original
+ # adapter API, but label it as telemetry: a DAG report cannot grant
+ # host execution authority.
+ try:
+ from .system3.causal import CausalDAG, CausalNodeType
+ dag = CausalDAG(name=f"HostCapabilities_{self.host}")
+ for item in targets:
+ dag.add_node(node_id=f"cap_{item}", name=item,
+ node_type=(CausalNodeType.EXOGENOUS if item in available_set
+ else CausalNodeType.ENDOGENOUS),
+ value=1.0 if item in available_set else 0.0)
+ brittleness = (dag.evaluate_brittleness(f"cap_{targets[0]}").overall_brittleness_score
+ if targets else 0.0)
+ dag_data = dag.to_dict()
+ except Exception:
+ brittleness, dag_data = 0.0, {"name": f"HostCapabilities_{self.host}", "nodes": [], "edges": []}
+ return {
+ "is_valid": not missing,
+ "required": targets,
+ "available": [item for item in targets if item in available_set],
+ "missing": missing,
+ "authoritative": self.is_attested,
+ "claim_status": "compatibility_telemetry_only",
+ "brittleness_score": brittleness,
+ "dag": dag_data,
+ }
+
# These are conservative *expected* profiles. Real adapters must call
# ``attest`` after probing the host; expected capabilities are never
# runtime-authoritative.
HOST_PROFILES: dict[str, HostCapabilities] = {
+ "generic-mcp-host": HostCapabilities(
+ "generic-mcp-host",
+ frozenset({"inspect_files", "execute_command", "edit_files", "run_tests"}),
+ {"tools/call": "route_tool"},
+ ),
"antigravity": HostCapabilities(
"antigravity",
frozenset({"inspect_files", "search_web", "execute_command", "edit_files",
@@ -144,8 +169,11 @@ def compatibility_report(self, required: Iterable[str]) -> dict[str, object]:
def get_profile(host: str) -> HostCapabilities:
- """Return a conservative profile; unknown hosts start with no claims."""
+ """Return a conservative profile; aliases resolve to canonical profiles."""
key = host.strip().lower()
if not key:
raise ValueError("host must be a non-empty string")
+ aliases = {"claude": "claude-code", "cc": "claude-code", "agy": "antigravity",
+ "antigravity": "antigravity", "codex": "codex"}
+ key = aliases.get(key, key)
return HOST_PROFILES.get(key, HostCapabilities(key))
diff --git a/fable_v2/execution_broker.py b/fable_v2/execution_broker.py
index 91bd8f0..df2f44e 100644
--- a/fable_v2/execution_broker.py
+++ b/fable_v2/execution_broker.py
@@ -11,6 +11,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
+from contextlib import contextmanager
import argparse
import errno
import hashlib
@@ -21,25 +22,75 @@
from pathlib import Path
import shutil
import signal
+import shlex
import stat
import subprocess
import sys
import tempfile
import threading
-from typing import Any, Iterable
+import time
+from typing import Any, Iterable, Mapping
+import uuid
+from .protocol import CANONICAL_SERIALIZATION_VERSION, canonical_hash, utc_now
from .system3 import KripkeStructure, KripkeModelChecker, CausalDAG, CausalNode, CausalNodeType
+@dataclass(frozen=True)
+class BrokerReceipt:
+ """Measured record of one broker operation, suitable for external attestation."""
+
+ receipt_id: str
+ session_id: str
+ action: str
+ capability: str
+ tool_name: str
+ input_hash: str
+ output_hash: str
+ executable_identity: Mapping[str, Any]
+ workspace_identity: Mapping[str, Any]
+ started_at: str
+ finished_at: str
+ success: bool
+ cancelled: bool = False
+ cancellation_status: str = "not_cancelled"
+ timed_out: bool = False
+ canonicalization: str = CANONICAL_SERIALIZATION_VERSION
+ metadata: Mapping[str, Any] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "receipt_id": self.receipt_id, "session_id": self.session_id,
+ "action": self.action, "capability": self.capability,
+ "tool_name": self.tool_name, "input_hash": self.input_hash,
+ "output_hash": self.output_hash,
+ "executable_identity": dict(self.executable_identity),
+ "workspace_identity": dict(self.workspace_identity),
+ "started_at": self.started_at, "finished_at": self.finished_at,
+ "success": self.success, "cancelled": self.cancelled,
+ "cancellation_status": self.cancellation_status,
+ "timed_out": self.timed_out, "canonicalization": self.canonicalization,
+ "metadata": dict(self.metadata),
+ }
+
+
@dataclass(frozen=True)
class BrokerPolicy:
workspace: Path
allowed_executables: tuple[str, ...] = ("python", "python3", "pytest")
max_output_bytes: int = 1_000_000
write_token_digest: str | None = None
+ # Workspace writes are bounded both per replacement and in aggregate.
+ # Accounting charges UTF-8 bytes attempted through the shared workspace
+ # ledger; the publication lock serializes independent instances.
+ max_file_write_bytes: int = 16 * 1024 * 1024
+ max_workspace_write_bytes: int = 64 * 1024 * 1024
resolved_executables: dict[str, str] = field(
init=False, default_factory=dict, repr=False, compare=False
) # populated from trusted PATH at startup
+ resolved_executable_identities: dict[str, dict[str, Any]] = field(
+ init=False, default_factory=dict, repr=False, compare=False
+ )
def __post_init__(self) -> None:
workspace = self.workspace.expanduser().resolve()
@@ -47,26 +98,73 @@ def __post_init__(self) -> None:
raise ValueError("workspace must be an existing directory")
if self.max_output_bytes < 1:
raise ValueError("max_output_bytes must be positive")
+ if self.max_file_write_bytes < 1 or self.max_workspace_write_bytes < 1:
+ raise ValueError("write quotas must be positive")
+ if self.max_file_write_bytes > self.max_workspace_write_bytes:
+ raise ValueError("per-file write quota cannot exceed workspace quota")
+ object.__setattr__(self, "max_file_write_bytes", int(self.max_file_write_bytes))
+ object.__setattr__(self, "max_workspace_write_bytes", int(self.max_workspace_write_bytes))
if not self.allowed_executables:
raise ValueError("at least one executable must be allowlisted")
resolved: dict[str, str] = {}
+ identities: dict[str, dict[str, Any]] = {}
normalized_names: list[str] = []
for item in self.allowed_executables:
+ if not isinstance(item, str) or not item or "\x00" in item:
+ raise ValueError("allowlisted executable names must be safe strings")
requested = Path(item).expanduser()
- located = requested if requested.is_absolute() else Path(shutil.which(str(requested)) or "")
- if not located or not located.exists() or not located.is_file():
+ if requested.is_absolute() and requested.is_symlink():
+ raise ValueError(f"unsafe executable allowlist entry: {item}")
+ # ``Path("")`` is ``Path(".")``. Do not use it as the
+ # not-found sentinel: a missing optional default (notably pytest
+ # in a minimal installation) would otherwise be classified as the
+ # workspace directory and rejected as an unsafe executable.
+ located = requested if requested.is_absolute() else None
+ if located is None:
+ found = shutil.which(str(requested))
+ if not found:
+ continue
+ located = Path(found)
+ if not located.exists():
continue
- absolute = str(located.resolve())
+ try:
+ # Explicit link entries are never accepted. A bare PATH name
+ # may be a distribution's standard alias for this exact
+ # running interpreter; permit only that trusted alias and
+ # reject every other linked executable.
+ if (not requested.is_absolute() and located.is_symlink()
+ and located.resolve() != Path(sys.executable).resolve()):
+ raise PermissionError("linked PATH executable is not the running interpreter")
+ measure_path = located.resolve() if not requested.is_absolute() else located
+ identity = _measure_executable_path(measure_path)
+ except (OSError, ValueError, PermissionError) as exc:
+ # An explicitly supplied unsafe entry must never silently turn
+ # into a different PATH entry. This is especially important
+ # for symlinks, Windows reparse points, and hardlinks.
+ raise ValueError(f"unsafe executable allowlist entry: {item}") from exc
+ absolute = identity["path"]
key = os.path.normcase(Path(item).name)
if key in resolved and os.path.normcase(resolved[key]) != os.path.normcase(absolute):
raise ValueError(f"ambiguous executable allowlist entry: {item}")
resolved[key] = absolute
+ identities[key] = identity
normalized_names.append(Path(item).name)
if not resolved:
raise ValueError("no allowlisted executable could be resolved at broker startup")
object.__setattr__(self, "workspace", workspace)
object.__setattr__(self, "allowed_executables", tuple(dict.fromkeys(normalized_names)))
object.__setattr__(self, "resolved_executables", resolved)
+ object.__setattr__(self, "resolved_executable_identities", identities)
+
+
+@dataclass
+class _ActiveRequest:
+ """Mutable broker-side state for one cancellable command request."""
+
+ request_id: Any
+ cancel_event: threading.Event = field(default_factory=threading.Event)
+ process: subprocess.Popen[bytes] | None = None
+ finished: bool = False
MAX_TIMEOUT_SECONDS = 3600.0
@@ -75,6 +173,121 @@ def __post_init__(self) -> None:
# bounded independently of the peer's eventual EOF.
MAX_FRAME_BYTES = 1 * 1024 * 1024
MAX_ERROR_TEXT = 8 * 1024
+MAX_RESPONSE_BYTES = 2 * 1024 * 1024
+# MCP tool calls run in daemon workers so cancellation notifications can be
+# consumed while a command is running. The admission limit is deliberately
+# per stdio connection: separate broker processes/connections do not share a
+# scheduler or bypass this bound within one connection.
+DEFAULT_MAX_MCP_WORKERS = 8
+MAX_MCP_WORKERS = 256
+MCP_OVERLOAD_ERROR_CODE = -32004
+MAX_CANCEL_REQUEST_ID_BYTES = 256
+MAX_CANCEL_TOMBSTONES = 4096
+MAX_CANCEL_TOMBSTONE_BYTES = 1 * 1024 * 1024
+CANCEL_TOMBSTONE_TTL_SECONDS = 300.0
+# Workspace write accounting is persisted in a small broker-owned ledger. The
+# entry bound is independent of the byte quota so zero-byte writes cannot grow
+# the metadata without limit.
+MAX_WORKSPACE_QUOTA_LEDGER_ENTRIES = 4096
+MAX_WORKSPACE_QUOTA_LEDGER_BYTES = 1 * 1024 * 1024
+WORKSPACE_QUOTA_LOCK_NAME = ".fable-workspace-quota.lock"
+WORKSPACE_QUOTA_LEDGER_NAME = ".fable-workspace-quota.json"
+# Public alias matching the engine's transport quota naming.
+MAX_RPC_RESPONSE_BYTES = MAX_RESPONSE_BYTES
+SUPPORTED_PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26")
+SERVER_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]
+
+# Stable top-level fields in the side-effect-free probe response. This is the
+# host-facing broker contract: consumers may rely on these fields, while any
+# addition or removal must be deliberate and update the documentation checks.
+BROKER_PROBE_FIELDS = (
+ "host", "capabilities", "available_executables", "executable_identities",
+ "execution_binding", "writes_enabled", "read_locked_interpreters",
+ "workspace", "workspace_identity",
+)
+
+
+def _identity_from_fd(fd: int, path: str | Path, *, digest: bool = True) -> dict[str, Any]:
+ """Measure the object represented by an already-open descriptor."""
+ st = os.fstat(fd)
+ if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
+ raise PermissionError("executable must be a regular, non-hardlinked file")
+ result: dict[str, Any] = {
+ "path": str(Path(path).absolute()),
+ "device": int(getattr(st, "st_dev", 0)),
+ "inode": int(getattr(st, "st_ino", 0)),
+ "size": int(st.st_size), "mode": stat.S_IMODE(st.st_mode),
+ }
+ if digest:
+ position = os.lseek(fd, 0, os.SEEK_CUR)
+ os.lseek(fd, 0, os.SEEK_SET)
+ h = hashlib.sha256()
+ while True:
+ chunk = os.read(fd, 1024 * 1024)
+ if not chunk:
+ break
+ h.update(chunk)
+ os.lseek(fd, position, os.SEEK_SET)
+ result["sha256"] = h.hexdigest()
+ return result
+
+
+def _measure_executable_path(path: str | Path) -> dict[str, Any]:
+ """Classify and hash an allowlist entry without following links."""
+ target = Path(path)
+ # A regular leaf under a linked directory is just as much a pathname
+ # indirection as a symlink leaf. Walk the existing components first.
+ cur = target
+ components: list[Path] = []
+ while True:
+ components.append(cur)
+ if cur.parent == cur:
+ break
+ cur = cur.parent
+ for component in reversed(components):
+ cst = component.lstat()
+ attrs = int(getattr(cst, "st_file_attributes", 0))
+ if stat.S_ISLNK(cst.st_mode) or attrs & 0x400:
+ raise PermissionError("executable path contains a link or reparse point")
+ st = target.lstat()
+ attrs = int(getattr(st, "st_file_attributes", 0))
+ if stat.S_ISLNK(st.st_mode) or attrs & 0x400 or not stat.S_ISREG(st.st_mode):
+ raise PermissionError("executable allowlist entry is a link, reparse point, or non-regular file")
+ if st.st_nlink != 1:
+ raise PermissionError("executable allowlist entry is hardlinked")
+ if os.name == "posix" and not (st.st_mode & 0o111):
+ raise PermissionError("executable allowlist entry is not executable")
+ fd = os.open(str(target), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
+ try:
+ measured = _identity_from_fd(fd, target)
+ finally:
+ os.close(fd)
+ # The descriptor, not a later pathname lookup, determines startup identity.
+ measured["path"] = str(target.absolute()) if target.is_absolute() else str(target.resolve())
+ measured["classification"] = "regular-executable"
+ return measured
+
+
+def _file_identity(path: str | Path) -> dict[str, Any]:
+ """Return measured identity; fail closed if the pathname is unsafe."""
+ target = Path(path)
+ fd = os.open(str(target), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
+ try:
+ return _identity_from_fd(fd, target)
+ finally:
+ os.close(fd)
+
+
+def _workspace_identity(path: Path, fd: int | None = None) -> dict[str, Any]:
+ """Derive workspace identity from the pinned descriptor when available."""
+ if fd is not None:
+ st = os.fstat(fd)
+ else:
+ st = path.stat()
+ if not stat.S_ISDIR(st.st_mode):
+ raise PermissionError("workspace identity is not a directory")
+ return {"path": str(path), "device": int(getattr(st, "st_dev", 0)),
+ "inode": int(getattr(st, "st_ino", 0))}
def _bounded_text(value: object, limit: int = MAX_ERROR_TEXT) -> str:
@@ -193,6 +406,19 @@ class ExecutionBroker:
def __init__(self, policy: BrokerPolicy):
self.policy = policy
self._writes_unlocked = False
+ # Request IDs are scoped to this broker connection. Entries are
+ # removed when execution finishes so a later request cannot inherit a
+ # stale cancellation signal.
+ self._active_requests: dict[str, _ActiveRequest] = {}
+ # A cancellation notification can overtake a worker thread before it
+ # enters execute_command. Tombstones close that registration race;
+ # request IDs are single-use for the lifetime of this connection.
+ self._cancelled_request_keys: dict[str, float] = {}
+ self._cancelled_request_bytes = 0
+ self._active_requests_lock = threading.Lock()
+ self._workspace_quota_thread_lock = threading.Lock()
+ self._workspace_written_bytes = 0
+ self._file_written_bytes: dict[str, int] = {}
self._workspace_fd: int | None = None
if os.name == "posix":
try:
@@ -216,17 +442,130 @@ def __del__(self) -> None:
except OSError:
pass
+ @staticmethod
+ def _request_key(request_id: Any) -> str:
+ """Canonicalize a JSON-RPC/action request ID for registry lookup."""
+ if isinstance(request_id, bool) or request_id is None:
+ raise ValueError("request_id must be a string or number")
+ if not isinstance(request_id, (str, int, float)):
+ raise ValueError("request_id must be a string or number")
+ # Empty strings are valid JSON-RPC string IDs (adapters generate
+ # non-empty UUIDs by default).
+ if isinstance(request_id, float) and not math.isfinite(request_id):
+ raise ValueError("request_id must be a finite number")
+ key = json.dumps(request_id, sort_keys=True, separators=(",", ":"))
+ if len(key.encode("utf-8")) > MAX_CANCEL_REQUEST_ID_BYTES:
+ raise ValueError("request_id exceeds cancellation ID size limit")
+ return key
+
+ def _cleanup_cancel_tombstones_locked(self, now: float | None = None) -> None:
+ now = time.monotonic() if now is None else now
+ expired = [key for key, expiry in self._cancelled_request_keys.items()
+ if expiry <= now]
+ for key in expired:
+ self._cancelled_request_keys.pop(key, None)
+ self._cancelled_request_bytes = max(
+ 0, self._cancelled_request_bytes - len(key.encode("utf-8")))
+
+ def _add_cancel_tombstone_locked(self, key: str, now: float | None = None) -> bool:
+ now = time.monotonic() if now is None else now
+ self._cleanup_cancel_tombstones_locked(now)
+ key_bytes = len(key.encode("utf-8"))
+ if key in self._cancelled_request_keys:
+ self._cancelled_request_keys[key] = now + CANCEL_TOMBSTONE_TTL_SECONDS
+ return True
+ # Evict oldest-expiring tombstones until both independent bounds hold.
+ while (len(self._cancelled_request_keys) >= MAX_CANCEL_TOMBSTONES or
+ self._cancelled_request_bytes + key_bytes > MAX_CANCEL_TOMBSTONE_BYTES):
+ if not self._cancelled_request_keys:
+ return False
+ victim = min(self._cancelled_request_keys,
+ key=lambda item: self._cancelled_request_keys[item])
+ self._cancelled_request_keys.pop(victim, None)
+ self._cancelled_request_bytes = max(
+ 0, self._cancelled_request_bytes - len(victim.encode("utf-8")))
+ self._cancelled_request_keys[key] = now + CANCEL_TOMBSTONE_TTL_SECONDS
+ self._cancelled_request_bytes += key_bytes
+ return True
+
+ def _register_request(self, request_id: Any) -> _ActiveRequest | None:
+ if request_id is None:
+ return None
+ key = self._request_key(request_id)
+ active = _ActiveRequest(request_id=request_id)
+ with self._active_requests_lock:
+ self._cleanup_cancel_tombstones_locked()
+ if key in self._active_requests:
+ raise ValueError(f"request_id is already active: {request_id}")
+ if key in self._cancelled_request_keys:
+ active.cancel_event.set()
+ self._active_requests[key] = active
+ return active
+
+ def _unregister_request(self, active: _ActiveRequest | None) -> None:
+ if active is None:
+ return
+ key = self._request_key(active.request_id)
+ with self._active_requests_lock:
+ if self._active_requests.get(key) is active:
+ del self._active_requests[key]
+ if key in self._cancelled_request_keys:
+ self._cancelled_request_keys.pop(key, None)
+ self._cancelled_request_bytes = max(
+ 0, self._cancelled_request_bytes - len(key.encode("utf-8")))
+ self._cleanup_cancel_tombstones_locked()
+
+ @staticmethod
+ def _terminate_process(process: subprocess.Popen[bytes]) -> None:
+ """Terminate one registered process without raising a cancellation error."""
+ if process.poll() is not None:
+ return
+ try:
+ if os.name == "posix":
+ os.killpg(process.pid, signal.SIGKILL)
+ else:
+ process.kill()
+ except (ProcessLookupError, PermissionError):
+ pass
+
+ def _cancel_request(self, request_id: Any) -> dict[str, Any]:
+ key = self._request_key(request_id)
+ with self._active_requests_lock:
+ self._cleanup_cancel_tombstones_locked()
+ active = self._active_requests.get(key)
+ if active is None or active.finished:
+ # Keep a bounded, expiring tombstone so an in-flight worker
+ # that has not registered yet cannot start after cancellation.
+ if active is None:
+ self._add_cancel_tombstone_locked(key)
+ return {"request_id": request_id, "cancelled": False}
+ # Set the event before taking the process snapshot. If the
+ # execution thread is between registration and Popen, it checks
+ # this event immediately after publishing its process handle.
+ active.cancel_event.set()
+ process = active.process
+ if process is not None:
+ self._terminate_process(process)
+ return {"request_id": request_id, "cancelled": True}
+
def probe(self) -> dict[str, Any]:
available = list(self.policy.resolved_executables)
+ capabilities = ["probe_capabilities"]
+ if os.name != "nt":
+ capabilities += ["execute_command", "inspect_files", "write_file"]
return {
"host": "fable-execution-broker",
- "capabilities": [
- "execute_command", "inspect_files", "probe_capabilities", "write_file"
- ],
+ "capabilities": capabilities,
"available_executables": available,
+ "executable_identities": {key: dict(value) for key, value in
+ self.policy.resolved_executable_identities.items()},
+ "execution_binding": ("macos-revalidated-path" if sys.platform == "darwin"
+ else "posix-open-descriptor" if os.name == "posix"
+ else "unavailable-on-windows"),
"writes_enabled": self._writes_unlocked,
"read_locked_interpreters": sorted(self.READ_LOCKED_INTERPRETERS),
"workspace": str(self.policy.workspace),
+ "workspace_identity": _workspace_identity(self.policy.workspace, self._workspace_fd),
}
def _safe_path(self, relative_path: str) -> Path:
@@ -284,6 +623,8 @@ def _pinned_parent(self, relative_path: str, *, create: bool = False) -> tuple[i
def inspect_files(self, relative_path: str, max_bytes: int | None = None) -> dict[str, Any]:
"""Read one workspace file through a pinned directory descriptor."""
+ if os.name == "nt":
+ raise PermissionError("safe handle-relative file inspection is unavailable on Windows")
limit = self.policy.max_output_bytes if max_bytes is None else int(max_bytes)
if limit < 1:
raise ValueError("max_bytes must be positive")
@@ -328,7 +669,198 @@ def inspect_files(self, relative_path: str, max_bytes: int | None = None) -> dic
"truncated": truncated,
}
+ @contextmanager
+ def _workspace_quota_guard(self):
+ """Lock aggregate write accounting across broker processes where possible."""
+ with self._workspace_quota_thread_lock:
+ if os.name == "posix" and hasattr(os, "O_NOFOLLOW"):
+ if self._workspace_fd is None:
+ raise PermissionError("descriptor-relative workspace operations require POSIX")
+ # Keep the lock beside the ledger in the already-pinned root;
+ # opening an absolute path would reintroduce a workspace swap
+ # race between broker startup and accounting.
+ fd = os.open(WORKSPACE_QUOTA_LOCK_NAME,
+ os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600,
+ dir_fd=self._workspace_fd)
+ try:
+ import fcntl as _fcntl
+ _fcntl.flock(fd, _fcntl.LOCK_EX)
+ yield
+ finally:
+ try:
+ _fcntl.flock(fd, _fcntl.LOCK_UN)
+ finally:
+ os.close(fd)
+ else:
+ # No portable stdlib handle lock exists on every Windows
+ # version; the process/thread lock is intentionally best effort.
+ yield
+
+ def _workspace_usage_bytes(self) -> int:
+ total = 0
+ stack = [self.policy.workspace]
+ while stack:
+ directory = stack.pop()
+ try:
+ entries = list(os.scandir(directory))
+ except OSError as exc:
+ raise PermissionError("workspace quota cannot be measured safely") from exc
+ for entry in entries:
+ if (directory == self.policy.workspace and
+ entry.name in {WORKSPACE_QUOTA_LOCK_NAME, WORKSPACE_QUOTA_LEDGER_NAME}):
+ continue
+ try:
+ st = entry.stat(follow_symlinks=False)
+ except OSError as exc:
+ raise PermissionError("workspace quota cannot be measured safely") from exc
+ if stat.S_ISLNK(st.st_mode) or int(getattr(st, "st_file_attributes", 0)) & 0x400:
+ raise PermissionError("workspace contains an unsafe link")
+ if stat.S_ISDIR(st.st_mode):
+ stack.append(Path(entry.path))
+ elif stat.S_ISREG(st.st_mode):
+ total += int(st.st_size)
+ else:
+ raise PermissionError("workspace contains an unsafe special file")
+ return total
+
+ def _read_workspace_quota_ledger(self) -> dict[str, Any]:
+ """Read the bounded, broker-owned attempted-write accounting ledger."""
+ if self._workspace_fd is None:
+ raise PermissionError("descriptor-relative workspace operations require POSIX")
+ try:
+ fd = os.open(WORKSPACE_QUOTA_LEDGER_NAME, _no_follow_flags(),
+ dir_fd=self._workspace_fd)
+ except FileNotFoundError:
+ return {"version": 1, "attempted_bytes": 0, "files": {}}
+ except OSError as exc:
+ raise PermissionError("workspace quota ledger cannot be opened safely") from exc
+ try:
+ st = os.fstat(fd)
+ if (not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or
+ st.st_size > MAX_WORKSPACE_QUOTA_LEDGER_BYTES):
+ raise PermissionError("workspace quota ledger is unsafe or oversized")
+ raw = os.read(fd, MAX_WORKSPACE_QUOTA_LEDGER_BYTES + 1)
+ finally:
+ os.close(fd)
+ try:
+ value = json.loads(raw.decode("utf-8"))
+ except (UnicodeDecodeError, ValueError, json.JSONDecodeError, RecursionError) as exc:
+ raise PermissionError("workspace quota ledger is invalid") from exc
+ if not isinstance(value, dict) or value.get("version") != 1:
+ raise PermissionError("workspace quota ledger has an unsupported version")
+ attempted = value.get("attempted_bytes")
+ files = value.get("files")
+ if (isinstance(attempted, bool) or not isinstance(attempted, int) or attempted < 0
+ or not isinstance(files, dict) or len(files) > MAX_WORKSPACE_QUOTA_LEDGER_ENTRIES):
+ raise PermissionError("workspace quota ledger has invalid accounting")
+ checked: dict[str, int] = {}
+ for key, amount in files.items():
+ if (not isinstance(key, str) or not key or
+ isinstance(amount, bool) or not isinstance(amount, int) or amount < 0):
+ raise PermissionError("workspace quota ledger has invalid accounting")
+ checked[key] = amount
+ if sum(checked.values()) != attempted:
+ raise PermissionError("workspace quota ledger accounting is inconsistent")
+ return {"version": 1, "attempted_bytes": attempted, "files": checked}
+
+ def _persist_workspace_quota_ledger(self, ledger: dict[str, Any]) -> None:
+ """Atomically persist accounting while the workspace quota lock is held."""
+ if self._workspace_fd is None:
+ raise PermissionError("descriptor-relative workspace operations require POSIX")
+ raw = json.dumps(ledger, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()
+ if len(raw) > MAX_WORKSPACE_QUOTA_LEDGER_BYTES:
+ raise PermissionError("workspace quota ledger is oversized")
+ if not hasattr(os, "O_NOFOLLOW"):
+ raise PermissionError("safe quota ledger persistence is unavailable")
+ temporary_name = f".fable-quota-{os.getpid()}-{threading.get_ident()}-{os.urandom(8).hex()}"
+ temp_fd: int | None = None
+ try:
+ temp_fd = os.open(temporary_name,
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
+ 0o600, dir_fd=self._workspace_fd)
+ view = memoryview(raw)
+ while view:
+ written = os.write(temp_fd, view)
+ view = view[written:]
+ os.fsync(temp_fd)
+ os.close(temp_fd)
+ temp_fd = None
+ os.replace(temporary_name, WORKSPACE_QUOTA_LEDGER_NAME,
+ src_dir_fd=self._workspace_fd, dst_dir_fd=self._workspace_fd)
+ try:
+ os.fsync(self._workspace_fd)
+ except OSError:
+ # The ledger replacement is still atomic; some filesystems do
+ # not permit fsync on directory descriptors.
+ pass
+ finally:
+ if temp_fd is not None:
+ os.close(temp_fd)
+ try:
+ os.unlink(temporary_name, dir_fd=self._workspace_fd)
+ except (FileNotFoundError, OSError):
+ pass
+
+ def _check_write_quota(self, relative_path: str, content_bytes: int) -> tuple[str, dict[str, Any]]:
+ if content_bytes > self.policy.max_file_write_bytes:
+ raise PermissionError("per-file workspace write quota exceeded")
+ parts = _path_parts(relative_path)
+ if len(parts) == 1 and parts[0] in {WORKSPACE_QUOTA_LOCK_NAME, WORKSPACE_QUOTA_LEDGER_NAME}:
+ raise PermissionError("workspace quota metadata is broker-owned")
+ # Charge attempted writes, not just final file size. The ledger is
+ # shared by broker instances and survives their lifetimes, so repeated
+ # replacement of one small file cannot provide an unbounded stream.
+ key = "/".join(parts)
+ ledger = self._read_workspace_quota_ledger()
+ files = ledger["files"]
+ previous_file_bytes = files.get(key, 0)
+ if (previous_file_bytes + content_bytes > self.policy.max_file_write_bytes):
+ raise PermissionError("per-file aggregate write quota exceeded")
+ if (ledger["attempted_bytes"] + content_bytes
+ > self.policy.max_workspace_write_bytes):
+ raise PermissionError("aggregate workspace write quota exceeded")
+ if key not in files and len(files) >= MAX_WORKSPACE_QUOTA_LEDGER_ENTRIES:
+ raise PermissionError("workspace quota ledger entry limit exceeded")
+ # Validate the current target before mutation. This also prevents an
+ # atomic replacement from silently converting a hardlink/special node.
+ target = self._safe_path(relative_path)
+ old_size = 0
+ try:
+ st = target.lstat()
+ except FileNotFoundError:
+ st = None
+ if st is not None:
+ if (not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or
+ int(getattr(st, "st_file_attributes", 0)) & 0x400):
+ raise PermissionError("write target must be a private regular file")
+ old_size = int(st.st_size)
+ # Include the resulting workspace footprint so separate broker
+ # instances/processes cannot bypass the aggregate limit by each using
+ # a fresh in-memory counter.
+ if (self._workspace_usage_bytes() - old_size + content_bytes
+ > self.policy.max_workspace_write_bytes):
+ raise PermissionError("aggregate workspace footprint quota exceeded")
+ return key, ledger
+
def write_file(self, relative_path: str, content: str) -> dict[str, Any]:
+ if os.name == "nt":
+ raise PermissionError("safe handle-relative workspace writes are unavailable on Windows")
+ self._authorize_write()
+ if not isinstance(content, str):
+ raise ValueError("file content must be text")
+ content_bytes = len(content.encode("utf-8"))
+ with self._workspace_quota_guard():
+ key, ledger = self._check_write_quota(relative_path, content_bytes)
+ ledger["attempted_bytes"] += content_bytes
+ ledger["files"][key] = ledger["files"].get(key, 0) + content_bytes
+ # Reserve before publication. A failed publication remains
+ # charged because this quota measures attempted writes.
+ self._persist_workspace_quota_ledger(ledger)
+ self._workspace_written_bytes = ledger["attempted_bytes"]
+ self._file_written_bytes = dict(ledger["files"])
+ return self._write_file_unlocked(relative_path, content)
+
+ def _write_file_unlocked(self, relative_path: str, content: str) -> dict[str, Any]:
self._authorize_write()
if not isinstance(content, str):
raise ValueError("file content must be text")
@@ -412,11 +944,76 @@ def validate_causal_boundaries(self, command: Iterable[str], cwd: str | None = N
"dag": dag.to_dict(),
}
+ def _classify_script(self, executable_path: str) -> tuple[str, dict[str, Any] | None]:
+ """Classify an executable and bind a script shebang to an allowlist identity."""
+ try:
+ descriptor_alias = executable_path.startswith(("/proc/self/fd/", "/dev/fd/"))
+ flags = os.O_RDONLY if descriptor_alias else os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
+ fd = os.open(executable_path, flags)
+ try:
+ header = os.read(fd, 4096)
+ finally:
+ os.close(fd)
+ except OSError as exc:
+ raise PermissionError("executable cannot be inspected safely") from exc
+ if not header.startswith(b"#!"):
+ return "regular-executable", None
+ try:
+ line = header.splitlines()[0][2:].decode("utf-8", "strict").strip()
+ words = shlex.split(line, posix=(os.name != "nt"))
+ except (UnicodeDecodeError, ValueError) as exc:
+ raise PermissionError("script has an invalid shebang") from exc
+ # /usr/bin/env introduces a second mutable PATH lookup. It is never
+ # accepted by the broker's locked execution boundary.
+ if not words or Path(words[0]).name.lower() == "env":
+ raise PermissionError("script shebang interpreter is unpinned")
+ if len(words) != 1:
+ raise PermissionError("script shebang interpreter arguments are not pinned")
+ interpreter = Path(words[0])
+ if not interpreter.is_absolute():
+ resolved = Path(shutil.which(str(interpreter)) or "")
+ if not resolved:
+ raise PermissionError("script shebang interpreter is unavailable")
+ interpreter = resolved
+ try:
+ identity = _measure_executable_path(interpreter)
+ except (OSError, ValueError, PermissionError) as exc:
+ raise PermissionError("script shebang interpreter is unsafe") from exc
+ expected = None
+ for allowed in self.policy.resolved_executable_identities.values():
+ if all(identity.get(key) == allowed.get(key)
+ for key in ("device", "inode", "size", "mode", "sha256")):
+ expected = dict(allowed)
+ break
+ if expected is None:
+ raise PermissionError("script shebang interpreter is not allowlisted")
+ return "script", expected
+
def execute_command(
self,
command: Iterable[str],
cwd: str | None = None,
timeout_seconds: float = 120.0,
+ session_id: str = "broker-session",
+ request_id: Any = None,
+ ) -> dict[str, Any]:
+ """Execute a command and optionally register it for cancellation."""
+ active = self._register_request(request_id)
+ try:
+ return self._execute_command(
+ command, cwd=cwd, timeout_seconds=timeout_seconds,
+ session_id=session_id, active=active,
+ )
+ finally:
+ self._unregister_request(active)
+
+ def _execute_command(
+ self,
+ command: Iterable[str],
+ cwd: str | None = None,
+ timeout_seconds: float = 120.0,
+ session_id: str = "broker-session",
+ active: _ActiveRequest | None = None,
) -> dict[str, Any]:
argv = tuple(command)
if not argv or any(not isinstance(item, str) or not item for item in argv):
@@ -437,8 +1034,12 @@ def execute_command(
or executable_key.startswith("python")
)
registered_path = self.policy.resolved_executables.get(os.path.normcase(executable))
- if not registered_path:
+ registered_identity = self.policy.resolved_executable_identities.get(os.path.normcase(executable))
+ if not registered_path or not registered_identity:
raise PermissionError(f"executable is not allowlisted: {executable}")
+ # Compare the requested spelling without resolving it through an
+ # attacker-controlled link. Relative requests are resolved only by
+ # PATH and then checked against the startup-pinned path.
requested_path = requested_executable if requested_executable.is_absolute() else Path(
shutil.which(str(requested_executable)) or ""
)
@@ -446,6 +1047,11 @@ def execute_command(
raise PermissionError(f"executable cannot be resolved: {argv[0]}")
if os.path.normcase(str(requested_path.resolve())) != os.path.normcase(registered_path):
raise PermissionError("executable path does not match its startup registration")
+ script_classification, shebang_identity = self._classify_script(registered_path)
+ if script_classification == "script" and not self._writes_unlocked:
+ # A script is executable code too; only a shebang measured against
+ # the broker's startup allowlist may cross the locked boundary.
+ raise PermissionError("scripts are blocked while workspace writes are locked")
if is_interpreter and not self._writes_unlocked:
raise PermissionError(
"general interpreters and shells are blocked while workspace writes are locked"
@@ -506,25 +1112,104 @@ def execute_command(
if val:
env[posix_key] = val
env["PYTHONUNBUFFERED"] = "1"
+ # A pathname hash followed by Popen(pathname) is a classic TOCTOU.
+ # POSIX can bind execution to the measured descriptor via procfs. The
+ # stdlib has no equivalent Windows handle-relative CreateProcess API;
+ # rather than make a false attestation, refuse command execution there.
+ exec_fd: int | None = None
+ interpreter_fd: int | None = None
+ spawn_argv = (registered_path, *argv[1:])
+ if os.name == "posix" and sys.platform == "darwin":
+ # macOS exposes /dev/fd, but Python's O_RDONLY descriptors are
+ # not executable there. Re-measure immediately before spawning
+ # and report the weaker, explicit binding rather than claiming
+ # descriptor execution. The strict Windows path remains closed.
+ try:
+ executable_identity = _measure_executable_path(registered_path)
+ expected_identity = {k: registered_identity.get(k) for k in
+ ("device", "inode", "size", "mode", "sha256")}
+ actual_identity = {k: executable_identity.get(k) for k in expected_identity}
+ if actual_identity != expected_identity:
+ raise PermissionError("allowlisted executable changed since broker startup")
+ spawn_argv = (registered_path, *argv[1:])
+ except Exception:
+ raise
+ elif os.name == "posix":
+ if not hasattr(os, "O_NOFOLLOW"):
+ raise PermissionError("safe descriptor-pinned execution is unavailable")
+ try:
+ exec_fd = os.open(registered_path, os.O_RDONLY | os.O_NOFOLLOW)
+ executable_identity = _identity_from_fd(exec_fd, registered_path)
+ expected_identity = {k: registered_identity.get(k) for k in
+ ("device", "inode", "size", "mode", "sha256")}
+ actual_identity = {k: executable_identity.get(k) for k in expected_identity}
+ if actual_identity != expected_identity:
+ raise PermissionError("allowlisted executable changed since broker startup")
+ fd_root = "/proc/self/fd" if os.path.isdir("/proc/self/fd") else "/dev/fd"
+ descriptor_path = f"{fd_root}/{exec_fd}"
+ if not os.path.exists(descriptor_path):
+ raise PermissionError("descriptor-pinned execution path is unavailable")
+ if script_classification == "script":
+ # Re-read the shebang from the same descriptor that will
+ # be executed, closing replacement between initial discovery
+ # and process creation. A pathname shebang still causes
+ # the kernel to look up its interpreter by name, so execute
+ # the pinned interpreter descriptor explicitly instead.
+ _kind, revalidated_shebang = self._classify_script(descriptor_path)
+ if revalidated_shebang != shebang_identity:
+ raise PermissionError("script interpreter changed before execution")
+ interpreter_path = shebang_identity.get("path") if shebang_identity else None
+ if not isinstance(interpreter_path, str) or not interpreter_path:
+ raise PermissionError("script interpreter cannot be pinned")
+ interpreter_fd = os.open(interpreter_path, os.O_RDONLY | os.O_NOFOLLOW)
+ interpreter_identity = _identity_from_fd(interpreter_fd, interpreter_path)
+ interpreter_expected = {k: shebang_identity.get(k) for k in
+ ("device", "inode", "size", "mode", "sha256")}
+ interpreter_actual = {k: interpreter_identity.get(k) for k in interpreter_expected}
+ if interpreter_actual != interpreter_expected:
+ raise PermissionError("script interpreter changed before execution")
+ # Recursively interpreted scripts would reintroduce a
+ # pathname shebang lookup. Reject them rather than claim
+ # descriptor pinning we cannot provide for an arbitrary
+ # interpreter chain.
+ interpreter_header = os.read(interpreter_fd, 4096)
+ if interpreter_header.startswith(b"#!"):
+ raise PermissionError("script interpreter cannot be pinned safely")
+ interpreter_descriptor = f"{fd_root}/{interpreter_fd}"
+ if not os.path.exists(interpreter_descriptor):
+ raise PermissionError("descriptor-pinned interpreter path is unavailable")
+ spawn_argv = (interpreter_descriptor, descriptor_path, *argv[1:])
+ else:
+ spawn_argv = (descriptor_path, *argv[1:])
+ except Exception:
+ if interpreter_fd is not None:
+ try:
+ os.close(interpreter_fd)
+ except OSError:
+ pass
+ interpreter_fd = None
+ if exec_fd is not None:
+ try:
+ os.close(exec_fd)
+ except OSError:
+ pass
+ exec_fd = None
+ raise
+ else:
+ raise PermissionError("descriptor-pinned command execution is unavailable on Windows")
process: subprocess.Popen[bytes] | None = None
output_limit = self.policy.max_output_bytes
captured = {"stdout": bytearray(), "stderr": bytearray()}
output_limited = threading.Event()
+ capture_lock = threading.Lock()
+ captured_total = [0]
kill_lock = threading.Lock()
def stop_process() -> None:
if process is None:
return
with kill_lock:
- if process.poll() is not None:
- return
- try:
- if os.name == "posix":
- os.killpg(process.pid, signal.SIGKILL)
- else:
- process.kill()
- except (ProcessLookupError, PermissionError):
- pass
+ self._terminate_process(process)
def drain(name: str, stream: Any) -> None:
bucket = captured[name]
@@ -532,27 +1217,33 @@ def drain(name: str, stream: Any) -> None:
chunk = stream.read(64 * 1024)
if not chunk:
return
- remaining = output_limit - len(bucket)
- if remaining <= 0:
+ with capture_lock:
+ remaining = output_limit - captured_total[0]
+ take = max(0, min(len(chunk), remaining))
+ if take:
+ bucket.extend(chunk[:take])
+ captured_total[0] += take
+ exceeded = take < len(chunk) or remaining <= 0
+ if exceeded:
output_limited.set()
stop_process()
while stream.read(64 * 1024):
pass
return
- if len(chunk) > remaining:
- bucket.extend(chunk[:remaining])
- output_limited.set()
- stop_process()
- while stream.read(64 * 1024):
- pass
- return
- else:
- bucket.extend(chunk)
executed_argv = (registered_path, *argv[1:])
+ started_at = utc_now()
+ # Both identities are measured from pinned objects. If either cannot be
+ # measured, no process is launched and no receipt can be fabricated.
+ workspace_identity = _workspace_identity(self.policy.workspace, self._workspace_fd)
+ input_hash = canonical_hash({
+ "command": list(argv), "cwd": cwd_display,
+ "timeout_seconds": timeout_seconds,
+ })
+ receipt_id = uuid.uuid4().hex
try:
process = subprocess.Popen(
- executed_argv,
+ spawn_argv,
cwd=directory,
env=env,
stdin=subprocess.DEVNULL,
@@ -562,7 +1253,18 @@ def drain(name: str, stream: Any) -> None:
start_new_session=(os.name == "posix"),
creationflags=(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
if os.name == "nt" else 0),
+ pass_fds=tuple(fd for fd in (exec_fd, interpreter_fd)
+ if fd is not None),
)
+ if active is not None:
+ # Publish the process while holding the same lock used by
+ # cancellation. The event check closes the registration /
+ # Popen race without allowing a cancelled process to escape.
+ with self._active_requests_lock:
+ active.process = process
+ cancel_requested = active.cancel_event.is_set()
+ if cancel_requested:
+ stop_process()
assert process.stdout is not None and process.stderr is not None
readers = [
threading.Thread(target=drain, args=("stdout", process.stdout), daemon=True),
@@ -582,12 +1284,26 @@ def drain(name: str, stream: Any) -> None:
if process.poll() is None:
stop_process()
process.wait(timeout=5)
+ if active is not None:
+ with self._active_requests_lock:
+ active.process = None
+ active.finished = True
finally:
if process is not None:
if process.stdout is not None:
process.stdout.close()
if process.stderr is not None:
process.stderr.close()
+ if interpreter_fd is not None:
+ try:
+ os.close(interpreter_fd)
+ except OSError:
+ pass
+ if exec_fd is not None:
+ try:
+ os.close(exec_fd)
+ except OSError:
+ pass
if cwd_fd is not None:
try:
os.close(cwd_fd)
@@ -605,7 +1321,8 @@ def truncate(value: str) -> str:
return value
return encoded[:output_limit].decode("utf-8", errors="ignore") + "\n[truncated]"
- return {
+ request_cancelled = active is not None and active.cancel_event.is_set()
+ result = {
"command": list(executed_argv),
"cwd": cwd_display,
"exit_code": exit_code,
@@ -613,8 +1330,35 @@ def truncate(value: str) -> str:
"output_limited": output_limited.is_set(),
"stdout": truncate(stdout),
"stderr": truncate(stderr),
- "success": exit_code == 0 and not timed_out and not output_limited.is_set(),
+ "success": exit_code == 0 and not timed_out and not output_limited.is_set()
+ and not request_cancelled,
}
+ if active is not None:
+ result["request_id"] = active.request_id
+ finished_at = utc_now()
+ cancelled = bool(request_cancelled or timed_out or output_limited.is_set())
+ cancellation_status = (
+ "cancelled" if request_cancelled else
+ "timed_out" if timed_out else
+ "output_limit" if output_limited.is_set() else "not_cancelled"
+ )
+ receipt = BrokerReceipt(
+ receipt_id=receipt_id, session_id=str(session_id), action="execute_command",
+ capability="execute_command", tool_name=Path(registered_path).name,
+ input_hash=input_hash, output_hash=canonical_hash(result),
+ executable_identity=executable_identity,
+ workspace_identity=workspace_identity, started_at=started_at,
+ finished_at=finished_at, success=bool(result["success"]),
+ cancelled=cancelled, cancellation_status=cancellation_status,
+ timed_out=bool(timed_out), metadata={"argv": list(argv)},
+ )
+ result["receipt"] = receipt.to_dict()
+ # Keep common fields at the top level for simple host adapters.
+ result.update({"receipt_id": receipt.receipt_id, "session_id": receipt.session_id,
+ "input_hash": receipt.input_hash, "output_hash": receipt.output_hash,
+ "cancelled": receipt.cancelled,
+ "cancellation_status": receipt.cancellation_status})
+ return result
def handle(self, request: dict[str, Any]) -> dict[str, Any]:
action = request.get("action")
@@ -630,7 +1374,14 @@ def handle(self, request: dict[str, Any]) -> dict[str, Any]:
request.get("command", ()),
cwd=request.get("cwd"),
timeout_seconds=float(request.get("timeout_seconds", 120.0)),
+ session_id=str(request.get("session_id", "broker-session")),
+ request_id=request.get("request_id"),
)
+ if action == "cancel":
+ # Cancellation is deliberately a separate control operation. It
+ # never accepts a process handle or token from the model channel;
+ # only a currently registered request ID can be cancelled.
+ return self._cancel_request(request.get("request_id"))
if action == "write_file":
# No authorization token is accepted on the model JSON channel.
return self.write_file(request.get("path", ""), request.get("content", ""))
@@ -656,29 +1407,315 @@ def _serve_admin_fd(broker: ExecutionBroker, fd: int) -> None:
f"{_bounded_text(exc)}", file=sys.stderr)
-def serve(broker: ExecutionBroker, admin_fd: int | None = None) -> None:
+def _valid_rpc_id(value: Any) -> bool:
+ """JSON-RPC IDs are strings or finite numbers; booleans are not IDs."""
+ return (not isinstance(value, bool) and value is not None
+ and isinstance(value, (str, int, float))
+ and (not isinstance(value, float) or math.isfinite(value)))
+
+
+def _rpc_error(msg_id: Any, code: int, message: str,
+ data: Mapping[str, Any] | None = None) -> dict[str, Any]:
+ # ``ok``/``message`` are harmless extension members and make malformed
+ # frames diagnosable to legacy action-protocol callers as well.
+ text = _bounded_text(message)
+ error: dict[str, Any] = {"code": code, "message": text}
+ if data is not None:
+ error["data"] = dict(data)
+ return {"jsonrpc": "2.0", "id": msg_id, "ok": False, "message": text,
+ "error": error}
+
+
+def _rpc_overload(msg_id: Any, *, active_workers: int,
+ max_workers: int) -> dict[str, Any]:
+ """Return a machine-readable, retryable MCP admission failure."""
+ return _rpc_error(
+ msg_id, MCP_OVERLOAD_ERROR_CODE, "MCP worker capacity exhausted",
+ {"type": "overloaded", "reason": "mcp_worker_limit",
+ "active_workers": active_workers, "max_workers": max_workers,
+ "retryable": True},
+ )
+
+
+@dataclass
+class MCPConnectionState:
+ """Per-stdio-connection MCP handshake, version, and worker state."""
+ initialized: bool = False
+ client_ready: bool = False
+ negotiated_version: str | None = None
+ max_workers: int = DEFAULT_MAX_MCP_WORKERS
+
+ def __post_init__(self) -> None:
+ if (isinstance(self.max_workers, bool)
+ or not isinstance(self.max_workers, int)
+ or not 1 <= self.max_workers <= MAX_MCP_WORKERS):
+ raise ValueError(
+ f"max_workers must be an integer from 1 to {MAX_MCP_WORKERS}"
+ )
+
+
+def _broker_tools() -> list[dict[str, Any]]:
+ actions = ["probe", "probe_capabilities"]
+ if os.name != "nt":
+ actions += ["inspect_files", "execute_command", "cancel", "write_file"]
+ return [{
+ "name": action,
+ "description": f"Fable execution broker {action} operation.",
+ "inputSchema": {"type": "object", "properties": {},
+ "additionalProperties": True},
+ } for action in actions]
+
+
+def _dispatch_jsonrpc(broker: ExecutionBroker, request: dict[str, Any],
+ state: MCPConnectionState | None = None) -> dict[str, Any] | None:
+ """Dispatch one MCP request, returning None for notifications.
+
+ ``state`` is deliberately connection-scoped. Keeping it out of the
+ broker object prevents one host connection from inheriting another host's
+ handshake or negotiated version.
+ """
+ state = state or MCPConnectionState()
+ has_id = "id" in request
+ msg_id = request.get("id")
+ is_notification = not has_id
+ if request.get("jsonrpc") != "2.0" or not isinstance(request.get("method"), str):
+ return _rpc_error(msg_id if has_id and _valid_rpc_id(msg_id) else None,
+ -32600, "Invalid Request")
+ if has_id and not _valid_rpc_id(msg_id):
+ return _rpc_error(None, -32600, "Invalid Request: invalid id")
+ if "params" in request and not isinstance(request["params"], dict):
+ return None if is_notification else _rpc_error(msg_id, -32600,
+ "Invalid Request")
+ method = request["method"]
+ params = request.get("params", {})
+
+ # MCP notification methods never produce responses, even if a broken peer
+ # attaches an ID. They are accepted only after a successful initialize;
+ # in particular, initialized must not be a substitute handshake.
+ if method in {"notifications/cancelled", "notifications/initialized"}:
+ if not state.initialized:
+ return None
+ if method == "notifications/cancelled":
+ request_id = params.get("requestId")
+ if _valid_rpc_id(request_id):
+ try:
+ broker.handle({"action": "cancel", "request_id": request_id})
+ except (ValueError, PermissionError):
+ pass
+ else:
+ state.client_ready = True
+ return None
+
+ if method == "initialize":
+ if not has_id:
+ # A notification cannot establish a successful handshake because
+ # there is no response that confirms negotiated capabilities.
+ return None
+ if state.initialized:
+ return None if is_notification else _rpc_error(msg_id, -32600,
+ "Server already initialized")
+ requested = params.get("protocolVersion", SERVER_PROTOCOL_VERSION)
+ if not isinstance(requested, str):
+ return None if is_notification else _rpc_error(msg_id, -32602,
+ "protocolVersion must be a string")
+ if requested not in SUPPORTED_PROTOCOL_VERSIONS:
+ return None if is_notification else _rpc_error(msg_id, -32602,
+ "Unsupported protocol version")
+ state.initialized = True
+ state.negotiated_version = requested
+ result = {
+ "protocolVersion": requested,
+ "capabilities": {"tools": {"listChanged": False}},
+ "serverInfo": {"name": "fable-execution-broker", "version": "2.0"},
+ }
+ return None if is_notification else {"jsonrpc": "2.0", "id": msg_id,
+ "result": result}
+
+ # All MCP operations other than the handshake are unavailable until the
+ # initialize request succeeds. Notifications remain silent.
+ if not state.initialized:
+ return None if is_notification else _rpc_error(msg_id, -32002,
+ "Server not initialized")
+ if method == "ping":
+ return None if is_notification else {"jsonrpc": "2.0", "id": msg_id,
+ "result": {}}
+ if method == "tools/list":
+ return None if is_notification else {"jsonrpc": "2.0", "id": msg_id,
+ "result": {"tools": _broker_tools()}}
+ if method == "tools/call":
+ name = params.get("name")
+ arguments = params.get("arguments", {})
+ if not isinstance(name, str) or not isinstance(arguments, dict):
+ return None if is_notification else _rpc_error(msg_id, -32602,
+ "tools/call requires string name and object arguments")
+ if name not in {item["name"] for item in _broker_tools()}:
+ return None if is_notification else _rpc_error(msg_id, -32601,
+ f"Method not found: {name}")
+ action_request = dict(arguments)
+ action_request["action"] = name
+ # The outer JSON-RPC ID is authoritative. This closes the old hole
+ # where a nested request_id could not be cancelled by the peer.
+ if has_id:
+ action_request["request_id"] = msg_id
+ try:
+ result = broker.handle(action_request)
+ tool_result = {"content": [{"type": "text",
+ "text": json.dumps(result, ensure_ascii=False)}],
+ "structuredContent": result, "isError": False}
+ except Exception as exc:
+ tool_result = {"content": [{"type": "text", "text": _bounded_text(exc)}],
+ "isError": True}
+ return None if is_notification else {"jsonrpc": "2.0", "id": msg_id,
+ "result": tool_result}
+ if is_notification:
+ return None
+ return _rpc_error(msg_id, -32601, f"Method not found: {method}")
+
+
+def _emit_response(response: dict[str, Any]) -> None:
+ """Write one bounded response; never let a huge result escape the pipe."""
+ raw = json.dumps(response, ensure_ascii=False, separators=(",", ":"))
+ if len(raw.encode("utf-8")) > MAX_RESPONSE_BYTES:
+ if response.get("jsonrpc") == "2.0":
+ response = _rpc_error(response.get("id"), -32003,
+ "Response exceeds maximum size")
+ else:
+ response = _protocol_error("ResponseTooLarge", "response exceeds maximum size")
+ raw = json.dumps(response, ensure_ascii=False, separators=(",", ":"))
+ sys.stdout.write(raw + "\n")
+ sys.stdout.flush()
+
+
+def serve(broker: ExecutionBroker, admin_fd: int | None = None,
+ max_workers: int = DEFAULT_MAX_MCP_WORKERS) -> None:
+ """Serve one broker connection with bounded MCP tool-call concurrency.
+
+ ``max_workers`` applies only to this stdio connection. Calls that arrive
+ while all slots are occupied are rejected before starting a thread and
+ receive a structured, retryable JSON-RPC overload error. Finished
+ workers remove themselves from the registry; this keeps long-lived
+ connections from accumulating thread objects and makes cancellation and
+ completion release admission slots promptly.
+ """
+ state = MCPConnectionState(max_workers=max_workers)
if admin_fd is not None:
if os.name == "nt":
raise ValueError("--admin-fd currently requires a POSIX inherited pipe")
threading.Thread(target=_serve_admin_fd, args=(broker, admin_fd), daemon=True).start()
- # Do not use ``for line in sys.stdin``: TextIOWrapper iteration calls an
- # unbounded readline and can wait for EOF on an otherwise healthy client.
+ output_lock = threading.Lock()
+ workers: dict[object, threading.Thread] = {}
+ workers_lock = threading.Lock()
+
+ def dispatch_and_emit(request: dict[str, Any], token: object) -> None:
+ try:
+ try:
+ result = _dispatch_jsonrpc(broker, request, state)
+ except Exception:
+ result = _rpc_error(
+ request.get("id") if _valid_rpc_id(request.get("id")) else None,
+ -32600, "Invalid Request")
+ if result is not None:
+ with output_lock:
+ _emit_response(result)
+ finally:
+ # The worker, rather than the reader loop, performs removal so a
+ # completed or cancelled call releases its slot immediately.
+ with workers_lock:
+ workers.pop(token, None)
+
+ def admit(request: dict[str, Any]) -> bool:
+ token = object()
+ with workers_lock:
+ # Reap any thread that exited before its finally block acquired
+ # this lock (normally workers remove themselves immediately).
+ for old_token, worker in tuple(workers.items()):
+ if not worker.is_alive():
+ workers.pop(old_token, None)
+ active = len(workers)
+ if active >= state.max_workers:
+ # JSON-RPC notifications never receive a response, including
+ # on overload. They are dropped rather than bypassing the
+ # bound or violating notification semantics.
+ response = (_rpc_overload(
+ request.get("id"), active_workers=active,
+ max_workers=state.max_workers) if "id" in request else None)
+ admitted = False
+ else:
+ worker = threading.Thread(
+ target=dispatch_and_emit, args=(request, token), daemon=True)
+ # Register before start so a very fast worker cannot be
+ # observed as untracked and admission remains atomic.
+ workers[token] = worker
+ response = None
+ admitted = True
+ if not admitted:
+ if response is not None:
+ with output_lock:
+ _emit_response(response)
+ return False
+ try:
+ worker.start()
+ except BaseException:
+ with workers_lock:
+ workers.pop(token, None)
+ raise
+ return True
+
for raw_line, oversized in _bounded_lines(sys.stdin, MAX_FRAME_BYTES):
if oversized:
- response = _protocol_error("InvalidFrame", "request frame exceeds maximum size")
- elif not raw_line.strip():
+ with output_lock:
+ _emit_response(_rpc_error(None, -32600, "Invalid Request: frame too large"))
continue
- else:
+ if not raw_line.strip():
+ continue
+ try:
+ request = json.loads(raw_line.decode("utf-8", "replace"))
+ except (ValueError, json.JSONDecodeError, RecursionError):
+ with output_lock:
+ _emit_response(_rpc_error(None, -32700, "Parse error"))
+ continue
+ if not isinstance(request, dict):
+ with output_lock:
+ _emit_response(_rpc_error(None, -32600, "Invalid Request"))
+ continue
+ # Every MCP tool call, including a notification, is admitted through
+ # the same bound. Notifications have no response by protocol and are
+ # simply dropped when overloaded; they must not bypass the limit.
+ if request.get("jsonrpc") == "2.0" and request.get("method") == "tools/call":
try:
- request = json.loads(raw_line.decode("utf-8", "replace"))
- if not isinstance(request, dict):
- raise ValueError("request must be a JSON object")
- response = {"ok": True, "result": broker.handle(request)}
- except Exception as exc: # protocol boundary: never crash the broker loop
- response = _protocol_error(type(exc).__name__, exc)
- sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
- sys.stdout.flush()
-
+ admit(request)
+ except Exception:
+ # A notification remains response-less even if local worker
+ # creation fails; requests get a bounded protocol error.
+ if "id" in request:
+ with output_lock:
+ _emit_response(_rpc_error(
+ request.get("id") if _valid_rpc_id(request.get("id")) else None,
+ -32600, "Invalid Request"))
+ continue
+ try:
+ if "jsonrpc" in request or "method" in request:
+ # Handshake and control notifications remain synchronous, so
+ # initialize/list/cancellation ordering is deterministic.
+ dispatch_and_emit(request, object())
+ else:
+ # Explicit legacy action protocol: no JSON-RPC handshake,
+ # no notification semantics, and stable {ok,result} shape.
+ try:
+ response = {"ok": True, "result": broker.handle(request)}
+ except Exception as exc:
+ response = _protocol_error(type(exc).__name__, exc)
+ with output_lock:
+ _emit_response(response)
+ except Exception as exc:
+ with output_lock:
+ _emit_response(_rpc_error(None, -32600, _bounded_text(exc)))
+ # EOF does not cancel in-flight calls; give them the same bounded cleanup
+ # window as the prior implementation, while avoiding unbounded tracking.
+ with workers_lock:
+ remaining = tuple(workers.values())
+ for worker in remaining:
+ worker.join(timeout=MAX_TIMEOUT_SECONDS + 5)
def _load_write_token_digest() -> str | None:
"""Load write authorization from administrator-controlled configuration."""
@@ -703,6 +1740,11 @@ def main(argv: list[str] | None = None) -> int:
"--admin-fd", type=int,
help="POSIX inherited one-way admin control FD; never expose to a model",
)
+ parser.add_argument(
+ "--max-mcp-workers", "--max-workers", dest="max_mcp_workers",
+ type=int, default=DEFAULT_MAX_MCP_WORKERS,
+ help=f"maximum concurrent MCP tool calls for this connection (1-{MAX_MCP_WORKERS})",
+ )
args = parser.parse_args(argv)
allowed = tuple(args.allow_executable) or BrokerPolicy.allowed_executables
policy = BrokerPolicy(
@@ -710,7 +1752,8 @@ def main(argv: list[str] | None = None) -> int:
allowed_executables=allowed,
write_token_digest=_load_write_token_digest(),
)
- serve(ExecutionBroker(policy), admin_fd=args.admin_fd)
+ serve(ExecutionBroker(policy), admin_fd=args.admin_fd,
+ max_workers=args.max_mcp_workers)
return 0
diff --git a/fable_v2/protocol.py b/fable_v2/protocol.py
index 0826494..e6c956c 100644
--- a/fable_v2/protocol.py
+++ b/fable_v2/protocol.py
@@ -12,23 +12,80 @@
import copy
import hashlib
import json
+import math
from typing import Any, Mapping
+from enum import Enum
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
+CANONICAL_SERIALIZATION_VERSION = "fable-json-c14n-v1"
+MAX_TEXT_FIELD = 16_384
+MAX_COLLECTION_ITEMS = 10_000
+CANONICAL_HASH_VECTORS = (
+ {"value": {"a": 1, "b": "é"}, "canonical": '{"a":1,"b":"é"}',
+ "sha256": "09ad9fd2fb648cb2f62141215828ea00a62c299db05d20aa9ade2f527a301cc6"},
+ {"value": {"array": [1, True, None], "nested": {"a": "x"}},
+ "canonical": '{"array":[1,true,null],"nested":{"a":"x"}}',
+ "sha256": "e7ca8c5b02ccfc4bb763dc948bbd95f984138477d708ab6f77582f0380d3c7cc"},
+)
+
+def _validate_json_value(value: Any, field_name: str = "value") -> None:
+ """Reject values that Python's permissive JSON encoder would normalize.
+
+ JSON objects have string keys and JSON has no representation for sets,
+ bytes, or non-finite IEEE-754 values. Keeping this check recursive is
+ important: a hostile value can otherwise hide several levels down in
+ receipt/candidate metadata or System 3 telemetry.
+ """
+ if value is None or isinstance(value, (str, bool, int)):
+ return
+ if isinstance(value, float):
+ if not math.isfinite(value):
+ raise ValueError(f"{field_name} contains a non-finite number")
+ return
+ if isinstance(value, Mapping):
+ for key, child in value.items():
+ if not isinstance(key, str):
+ raise TypeError(f"{field_name} contains a non-string JSON object key")
+ _validate_json_value(child, f"{field_name}.{key}")
+ return
+ # Tuples are retained for the protocol's typed collection fields and are
+ # encoded as arrays by json.dumps. Sets/bytes/custom objects are not JSON.
+ if isinstance(value, (list, tuple)):
+ for index, child in enumerate(value):
+ _validate_json_value(child, f"{field_name}[{index}]")
+ return
+ raise TypeError(f"{field_name} contains a non-standard JSON value: {type(value).__name__}")
+
+
+def canonical_dumps(value: Any) -> str:
+ _validate_json_value(value)
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
+
def canonical_hash(value: Any) -> str:
- """Return a stable SHA-256 hash for a JSON-compatible value."""
- payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
- return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+ return hashlib.sha256(canonical_dumps(value).encode("utf-8")).hexdigest()
+
+def _strict_bool(value: Any, field_name: str) -> bool:
+ if type(value) is not bool:
+ raise TypeError(f"{field_name} must be a boolean")
+ return value
+
+def _finite_number(value: Any, field_name: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
+ raise ValueError(f"{field_name} must be a finite number")
+ return float(value)
def _required_text(value: str, field_name: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field_name} must be a non-empty string")
- return value.strip()
+ value = value.strip()
+ if len(value) > MAX_TEXT_FIELD:
+ raise ValueError(f"{field_name} exceeds the maximum length")
+ return value
def _parse_timestamp(value: str, field_name: str) -> datetime:
@@ -43,6 +100,19 @@ def _parse_timestamp(value: str, field_name: str) -> datetime:
return parsed
+class VerificationStatus(str, Enum):
+ """The three outcomes a verifier may honestly report.
+
+ ``UNKNOWN`` is intentionally distinct from failure: it means the check
+ could not establish either truth or falsity and must not be upgraded to a
+ pass by aggregation code.
+ """
+
+ PASS = "PASS"
+ FAIL = "FAIL"
+ UNKNOWN = "UNKNOWN"
+
+
@dataclass(frozen=True)
class VerificationPolicy:
"""Acceptance policy declared by the task, not by the model."""
@@ -57,6 +127,8 @@ class VerificationPolicy:
# Production deployments should require ``process_attested`` and supply
# results from an isolated broker.
minimum_trust_boundary: str = "in_process"
+ require_evidence_diversity: bool = False
+ minimum_evidence_sources: int = 2
TRUST_BOUNDARY_RANK = {"in_process": 0, "process_attested": 1}
@@ -65,13 +137,19 @@ def __post_init__(self) -> None:
raise ValueError("unsupported minimum_trust_boundary")
if not self.required_verifier_classes:
raise ValueError("verification policy must require at least one verifier class")
+ _strict_bool(self.require_independent, "require_independent")
+ _strict_bool(self.require_evidence_diversity, "require_evidence_diversity")
+ if self.minimum_evidence_sources < 1 or self.minimum_evidence_sources > MAX_COLLECTION_ITEMS:
+ raise ValueError("minimum_evidence_sources is out of bounds")
if self.minimum_passing_verifiers < 1:
raise ValueError("minimum_passing_verifiers must be at least 1")
for item in self.required_verifier_classes:
_required_text(item, "verifier class")
+ if len(set(self.required_verifier_classes)) != len(self.required_verifier_classes):
+ raise ValueError("verification policy contains duplicate verifier classes")
def to_dict(self) -> dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@dataclass(frozen=True)
@@ -92,12 +170,17 @@ def __post_init__(self) -> None:
_required_text(self.objective, "objective")
if not self.definition_of_done:
raise ValueError("definition_of_done must contain at least one condition")
- for item in (*self.constraints, *self.definition_of_done,
- *self.required_capabilities, *self.required_evidence):
- _required_text(item, "task contract item")
+ for collection in (self.constraints, self.definition_of_done, self.required_capabilities, self.required_evidence):
+ if len(collection) > MAX_COLLECTION_ITEMS:
+ raise ValueError("task contract contains too many items")
+ for item in collection:
+ _required_text(item, "task contract item")
+ metadata_copy = copy.deepcopy(dict(self.metadata))
+ _validate_json_value(metadata_copy, "task.metadata")
+ object.__setattr__(self, "metadata", metadata_copy)
def to_dict(self) -> dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@dataclass(frozen=True)
@@ -119,19 +202,45 @@ class ToolReceipt:
finished_at: str
metadata: Mapping[str, Any] = field(default_factory=dict)
output: Any = None
+ canonicalization: str = CANONICAL_SERIALIZATION_VERSION
+ executable_identity: Mapping[str, Any] = field(default_factory=dict)
+ workspace_identity: Mapping[str, Any] = field(default_factory=dict)
+ # Receipt provenance is deliberately explicit. ``self_minted`` is the
+ # safe default for objects constructed by application code; only a host or
+ # external boundary may mark a receipt as trusted. This is a provenance
+ # classification, not a cryptographic signature.
+ trust_boundary: str = "self_minted"
+
+ TRUSTED_BOUNDARIES = frozenset({"host", "external", "process_attested"})
+ ALL_BOUNDARIES = TRUSTED_BOUNDARIES | {"self_minted"}
def __post_init__(self) -> None:
for name in ("receipt_id", "session_id", "capability", "tool_name",
"input_hash", "output_hash", "started_at", "finished_at"):
_required_text(getattr(self, name), name)
+ if self.canonicalization != CANONICAL_SERIALIZATION_VERSION:
+ raise ValueError("unsupported canonicalization policy")
+ if self.trust_boundary not in self.ALL_BOUNDARIES:
+ raise ValueError("unsupported receipt trust boundary")
+ _strict_bool(self.success, "success")
started = _parse_timestamp(self.started_at, "started_at")
finished = _parse_timestamp(self.finished_at, "finished_at")
if finished < started:
raise ValueError("finished_at cannot be earlier than started_at")
# Snapshot mutable host payloads so later caller mutation cannot change
# what the receipt hashes or what evidence derives from it.
- object.__setattr__(self, "output", copy.deepcopy(self.output))
- object.__setattr__(self, "metadata", copy.deepcopy(dict(self.metadata)))
+ output_copy = copy.deepcopy(self.output)
+ metadata_copy = copy.deepcopy(dict(self.metadata))
+ executable_copy = copy.deepcopy(dict(self.executable_identity))
+ workspace_copy = copy.deepcopy(dict(self.workspace_identity))
+ _validate_json_value(output_copy, "receipt.output")
+ _validate_json_value(metadata_copy, "receipt.metadata")
+ _validate_json_value(executable_copy, "receipt.executable_identity")
+ _validate_json_value(workspace_copy, "receipt.workspace_identity")
+ object.__setattr__(self, "output", output_copy)
+ object.__setattr__(self, "metadata", metadata_copy)
+ object.__setattr__(self, "executable_identity", executable_copy)
+ object.__setattr__(self, "workspace_identity", workspace_copy)
if canonical_hash(self.output) != self.output_hash:
raise ValueError("output_hash does not match the actual receipt output")
@@ -149,6 +258,9 @@ def from_result(
started_at: str | None = None,
finished_at: str | None = None,
metadata: Mapping[str, Any] | None = None,
+ executable_identity: Mapping[str, Any] | None = None,
+ workspace_identity: Mapping[str, Any] | None = None,
+ trust_boundary: str = "self_minted",
) -> "ToolReceipt":
start = started_at or utc_now()
finish = finished_at or start
@@ -159,15 +271,22 @@ def from_result(
tool_name=tool_name,
input_hash=canonical_hash(tool_input),
output_hash=canonical_hash(tool_output),
- success=bool(success),
+ success=_strict_bool(success, "success"),
started_at=start,
finished_at=finish,
metadata=metadata or {},
output=tool_output,
+ executable_identity=executable_identity or {},
+ workspace_identity=workspace_identity or {},
+ trust_boundary=trust_boundary,
)
+ @property
+ def trusted(self) -> bool:
+ return self.trust_boundary in self.TRUSTED_BOUNDARIES
+
def to_dict(self) -> dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@dataclass(frozen=True)
@@ -193,8 +312,12 @@ def __post_init__(self) -> None:
for name in ("evidence_id", "session_id", "claim", "kind", "source",
"receipt_id", "content_hash", "source_output_hash"):
_required_text(getattr(self, name), name)
- object.__setattr__(self, "content", copy.deepcopy(self.content))
- object.__setattr__(self, "metadata", copy.deepcopy(dict(self.metadata)))
+ content_copy = copy.deepcopy(self.content)
+ metadata_copy = copy.deepcopy(dict(self.metadata))
+ _validate_json_value(content_copy, "evidence.content")
+ _validate_json_value(metadata_copy, "evidence.metadata")
+ object.__setattr__(self, "content", content_copy)
+ object.__setattr__(self, "metadata", metadata_copy)
if canonical_hash(self.content) != self.content_hash:
raise ValueError("content_hash does not match the evidence content")
@@ -239,7 +362,7 @@ def from_receipt(
)
def to_dict(self) -> dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@dataclass(frozen=True)
@@ -257,9 +380,19 @@ class Candidate:
def __post_init__(self) -> None:
for name in ("candidate_id", "session_id", "approach"):
_required_text(getattr(self, name), name)
+ artifact_copy = copy.deepcopy(self.artifact)
+ metadata_copy = copy.deepcopy(dict(self.metadata))
+ _validate_json_value(artifact_copy, "candidate.artifact")
+ _validate_json_value(metadata_copy, "candidate.metadata")
+ object.__setattr__(self, "artifact", artifact_copy)
+ if len(self.receipt_ids) > MAX_COLLECTION_ITEMS or len(self.evidence_ids) > MAX_COLLECTION_ITEMS:
+ raise ValueError("candidate references too many objects")
+ object.__setattr__(self, "receipt_ids", tuple(_required_text(x, "receipt_id") for x in self.receipt_ids))
+ object.__setattr__(self, "evidence_ids", tuple(_required_text(x, "evidence_id") for x in self.evidence_ids))
+ object.__setattr__(self, "metadata", metadata_copy)
def to_dict(self) -> dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@dataclass(frozen=True)
@@ -291,12 +424,240 @@ class VerificationResult:
# The runtime populates this before computing ``runtime_attestation``.
candidate_graph_hash: str = ""
runtime_attestation: str = ""
+ # New verifier-slice fields are appended to preserve positional
+ # compatibility with V1/V2 callers that construct this record directly.
+ status: VerificationStatus | str = ""
+ claim_ids: tuple[str, ...] = ()
+ provenance_ids: tuple[str, ...] = ()
+ counterexample_ids: tuple[str, ...] = ()
+ false_positive_rate: float | None = None
+ false_negative_rate: float | None = None
+ mutation_detection_rate: float | None = None
+ # Scope and claim/provenance commitments are part of the verdict identity.
+ # They are appended to preserve positional compatibility with older callers.
+ scope: str = ""
def __post_init__(self) -> None:
for name in ("verification_id", "session_id", "candidate_id", "verifier"):
_required_text(getattr(self, name), name)
- if self.score is not None and not 0 <= self.score <= 1:
- raise ValueError("score must be between 0 and 1")
+ if self.scope:
+ _required_text(self.scope, "scope")
+ elif not isinstance(self.scope, str):
+ raise TypeError("scope must be a string")
+ if self.score is not None:
+ score = _finite_number(self.score, "score")
+ if not 0 <= score <= 1:
+ raise ValueError("score must be between 0 and 1")
+ _strict_bool(self.passed, "passed")
+ # Empty status is the legacy representation; normalize it so every
+ # newly-created record has an explicit three-valued interpretation.
+ raw_status = self.status.value if isinstance(self.status, VerificationStatus) else self.status
+ raw_status = raw_status or (VerificationStatus.PASS.value if self.passed else VerificationStatus.FAIL.value)
+ try:
+ normalized_status = VerificationStatus(str(raw_status).upper())
+ except ValueError as exc:
+ raise ValueError("status must be PASS, FAIL, or UNKNOWN") from exc
+ if (normalized_status is VerificationStatus.PASS) != self.passed:
+ # PermissionError preserves the runtime's existing tamper/error
+ # contract when a serialized verdict is edited before restore.
+ raise PermissionError("passed must agree with status")
+ object.__setattr__(self, "status", normalized_status)
+ for field_name in ("false_positive_rate", "false_negative_rate", "mutation_detection_rate"):
+ value = getattr(self, field_name)
+ if value is not None:
+ rate = _finite_number(value, field_name)
+ if not 0 <= rate <= 1:
+ raise ValueError(f"{field_name} must be between 0 and 1")
+ claim_ids = tuple(_required_text(x, "claim_id") for x in self.claim_ids)
+ provenance_ids = tuple(_required_text(x, "provenance_id") for x in self.provenance_ids)
+ counterexample_ids = tuple(_required_text(x, "counterexample_id") for x in self.counterexample_ids)
+ if len(claim_ids) > MAX_COLLECTION_ITEMS or len(provenance_ids) > MAX_COLLECTION_ITEMS:
+ raise ValueError("verification references too many objects")
+ object.__setattr__(self, "claim_ids", claim_ids)
+ object.__setattr__(self, "provenance_ids", provenance_ids)
+ object.__setattr__(self, "counterexample_ids", counterexample_ids)
+ reasons_copy = tuple(self.reasons)
+ _validate_json_value(reasons_copy, "verification.reasons")
+ metadata_copy = copy.deepcopy(dict(self.metadata))
+ _validate_json_value(metadata_copy, "verification.metadata")
+ object.__setattr__(self, "reasons", reasons_copy)
+ object.__setattr__(self, "evidence_ids", tuple(_required_text(x, "evidence_id") for x in self.evidence_ids))
+ object.__setattr__(self, "metadata", metadata_copy)
+
+ def to_dict(self) -> dict[str, Any]:
+ return copy.deepcopy(asdict(self))
+
+# Typed System 3 lifecycle records. These are intentionally separate from
+# ToolReceipt: hypotheses cannot masquerade as actual execution outcomes.
+_BOILERPLATE_PREDICTION_TEXT = {
+ "ok", "done", "success", "it works", "should work", "looks good",
+ "no issues", "pass", "passed", "works", "true", "yes",
+}
+
+
+def _meaningful(value: Any, field_name: str) -> Any:
+ if value is None or (isinstance(value, str) and not value.strip()):
+ raise ValueError(f"{field_name} must be a non-empty observation")
+ if isinstance(value, (Mapping, list, tuple, set)) and not value:
+ raise ValueError(f"{field_name} must be a non-empty observation")
+ canonical_hash(value) # rejects non-finite and non-JSON values early
+ return copy.deepcopy(value)
+
+
+@dataclass(frozen=True)
+class Prediction:
+ """A hash-bound, falsifiable System 3 prediction for one action."""
+ prediction_id: str
+ run_id: str = ""
+ candidate_id: str = ""
+ action: str = ""
+ observation: Any = None
+ predicted_outcome: Any = None
+ confidence: float = 0.0
+ falsification_condition: str = ""
+ created_at: str = field(default_factory=utc_now)
+ observation_hash: str = ""
+ predicted_outcome_hash: str = ""
+ policy_id: str = ""
+ belief_revision: str = ""
+ record_hash: str = ""
+ metadata: Mapping[str, Any] = field(default_factory=dict)
+ session_id: str = ""
+
+ def __post_init__(self) -> None:
+ run_id = self.run_id or self.session_id
+ object.__setattr__(self, "run_id", run_id)
+ for name in ("prediction_id", "run_id", "candidate_id", "action", "falsification_condition"):
+ _required_text(getattr(self, name), name)
+ if self.session_id and self.session_id != self.run_id:
+ raise ValueError("session_id and run_id must agree")
+ _parse_timestamp(self.created_at, "created_at")
+ confidence = _finite_number(self.confidence, "confidence")
+ if not 0.0 <= confidence <= 1.0:
+ raise ValueError("confidence must be between 0 and 1")
+ if self.falsification_condition.strip().lower() in _BOILERPLATE_PREDICTION_TEXT:
+ raise ValueError("falsification_condition must be specific and falsifiable")
+ observation = _meaningful(self.observation, "observation")
+ predicted = _meaningful(self.predicted_outcome, "predicted_outcome")
+ if isinstance(predicted, str) and predicted.strip().lower() in _BOILERPLATE_PREDICTION_TEXT:
+ raise ValueError("predicted_outcome must be specific, not boilerplate")
+ obs_hash, pred_hash = canonical_hash(observation), canonical_hash(predicted)
+ if self.observation_hash and self.observation_hash != obs_hash:
+ raise ValueError("observation_hash does not match observation")
+ if self.predicted_outcome_hash and self.predicted_outcome_hash != pred_hash:
+ raise ValueError("predicted_outcome_hash does not match predicted_outcome")
+ object.__setattr__(self, "observation", observation)
+ object.__setattr__(self, "predicted_outcome", predicted)
+ object.__setattr__(self, "observation_hash", obs_hash)
+ object.__setattr__(self, "predicted_outcome_hash", pred_hash)
+ metadata_copy = copy.deepcopy(dict(self.metadata))
+ _validate_json_value(metadata_copy, "prediction.metadata")
+ if "success" in metadata_copy:
+ raise PermissionError("prediction success must be tested by an actual receipt")
+ object.__setattr__(self, "metadata", metadata_copy)
+ object.__setattr__(self, "session_id", self.run_id)
+ payload = self.to_dict(); payload["record_hash"] = ""
+ expected = canonical_hash(payload)
+ if self.record_hash and self.record_hash != expected:
+ raise ValueError("prediction record_hash does not match record")
+ object.__setattr__(self, "record_hash", expected)
+ object.__setattr__(self, "session_id", self.run_id)
+
+ @property
+ def timestamp(self) -> str:
+ return self.created_at
+
+ @property
+ def predicted_value(self) -> Any:
+ return self.predicted_outcome
+
+ @property
+ def falsification(self) -> str:
+ return self.falsification_condition
+
+ @property
+ def hash(self) -> str:
+ return self.record_hash
+
+ @property
+ def prediction_hash(self) -> str:
+ return self.record_hash
+
+ def to_dict(self) -> dict[str, Any]:
+ return copy.deepcopy(asdict(self))
+
+ @classmethod
+ def from_dict(cls, data: Mapping[str, Any]) -> "Prediction":
+ return cls(**dict(data))
+
+
+@dataclass(frozen=True)
+class Outcome:
+ """An actual receipt-bound observation used to update a prediction."""
+ outcome_id: str
+ run_id: str = ""
+ candidate_id: str = ""
+ prediction_id: str = ""
+ action: str = ""
+ receipt_id: str = ""
+ observed_outcome: Any = None
+ observed_at: str = field(default_factory=utc_now)
+ outcome_hash: str = ""
+ receipt_output_hash: str = ""
+ record_hash: str = ""
+ metadata: Mapping[str, Any] = field(default_factory=dict)
+ success: Any = None
+ session_id: str = ""
+
+ def __post_init__(self) -> None:
+ run_id = self.run_id or self.session_id
+ object.__setattr__(self, "run_id", run_id)
+ for name in ("outcome_id", "run_id", "candidate_id", "prediction_id", "action", "receipt_id"):
+ _required_text(getattr(self, name), name)
+ if self.session_id and self.session_id != self.run_id:
+ raise ValueError("session_id and run_id must agree")
+ _parse_timestamp(self.observed_at, "observed_at")
+ metadata_copy = copy.deepcopy(dict(self.metadata))
+ _validate_json_value(metadata_copy, "outcome.metadata")
+ if self.success is not None or "success" in metadata_copy:
+ raise PermissionError("Outcome success must come from the actual ToolReceipt")
+ observed = _meaningful(self.observed_outcome, "observed_outcome")
+ observed_hash = canonical_hash(observed)
+ if self.outcome_hash and self.outcome_hash != observed_hash:
+ raise ValueError("outcome_hash does not match observed_outcome")
+ if self.receipt_output_hash and self.receipt_output_hash != observed_hash:
+ raise ValueError("receipt_output_hash does not match observed_outcome")
+ object.__setattr__(self, "observed_outcome", observed)
+ object.__setattr__(self, "outcome_hash", observed_hash)
+ object.__setattr__(self, "receipt_output_hash", observed_hash)
+ object.__setattr__(self, "metadata", metadata_copy)
+ object.__setattr__(self, "session_id", self.run_id)
+ payload = self.to_dict(); payload["record_hash"] = ""
+ expected = canonical_hash(payload)
+ if self.record_hash and self.record_hash != expected:
+ raise ValueError("outcome record_hash does not match record")
+ object.__setattr__(self, "record_hash", expected)
+ object.__setattr__(self, "session_id", self.run_id)
+
+ @property
+ def timestamp(self) -> str:
+ return self.observed_at
+
+ @property
+ def actual_outcome(self) -> Any:
+ return self.observed_outcome
+
+ @property
+ def hash(self) -> str:
+ return self.record_hash
+
+ @property
+ def integrity_hash(self) -> str:
+ return self.record_hash
def to_dict(self) -> dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
+
+ @classmethod
+ def from_dict(cls, data: Mapping[str, Any]) -> "Outcome":
+ return cls(**dict(data))
diff --git a/fable_v2/runtime.py b/fable_v2/runtime.py
index bccfa8c..deea51a 100644
--- a/fable_v2/runtime.py
+++ b/fable_v2/runtime.py
@@ -7,24 +7,67 @@
from __future__ import annotations
-from dataclasses import dataclass, field, replace
+from dataclasses import dataclass, field, fields, is_dataclass, replace
from enum import Enum
import copy
import hashlib
import hmac
import secrets
import threading
-from typing import Any, Iterable, Protocol
+from typing import Any, Iterable, Mapping, Protocol, Callable
+
+
+class _FrozenDict(dict):
+ """Deeply immutable dict used for terminal public snapshots."""
+ def __deepcopy__(self, memo: dict[int, Any]) -> dict[Any, Any]:
+ return {copy.deepcopy(k, memo): copy.deepcopy(v, memo) for k, v in self.items()}
+ def _immutable(self, *args: Any, **kwargs: Any) -> None:
+ raise TypeError("terminal run snapshot is immutable")
+ __setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = _immutable
+
+
+class _FrozenList(list):
+ """Deeply immutable list used for terminal public snapshots."""
+ def __deepcopy__(self, memo: dict[int, Any]) -> list[Any]:
+ return [copy.deepcopy(v, memo) for v in self]
+ def _immutable(self, *args: Any, **kwargs: Any) -> None:
+ raise TypeError("terminal run snapshot is immutable")
+ __setitem__ = __delitem__ = append = extend = insert = pop = remove = reverse = sort = _immutable
+
+
+def _freeze_snapshot(value: Any) -> Any:
+ if is_dataclass(value) and not isinstance(value, type):
+ clone = copy.copy(value)
+ for item in fields(value):
+ object.__setattr__(clone, item.name, _freeze_snapshot(getattr(value, item.name)))
+ return clone
+ if isinstance(value, dict):
+ return _FrozenDict({k: _freeze_snapshot(v) for k, v in value.items()})
+ if isinstance(value, list):
+ return _FrozenList(_freeze_snapshot(v) for v in value)
+ if isinstance(value, tuple):
+ return tuple(_freeze_snapshot(v) for v in value)
+ if isinstance(value, set):
+ return frozenset(_freeze_snapshot(v) for v in value)
+ return value
from .protocol import (
Candidate,
Evidence,
+ Prediction,
+ Outcome,
TaskSpec,
ToolReceipt,
VerificationPolicy,
VerificationResult,
+ VerificationStatus,
canonical_hash,
utc_now,
+ _parse_timestamp,
+)
+from .verifiers import (
+ ClaimGraph, Counterexample, CounterexampleStore, ThreeValuedAdjudicator,
+ VerifierPlanner, Adjudication,
)
from .system3 import (
ActiveInferenceEngine,
@@ -57,6 +100,7 @@
TriLevelArbitrator,
System3Executive,
)
+from .system3.free_energy import prediction_error, revise_belief, policy_revision
class RegisteredVerifier(Protocol):
@@ -76,6 +120,29 @@ def verify(self, candidate: Candidate) -> VerificationResult:
...
+class _VerifierResultAdapter:
+ """Planner view of an already executed, runtime-attested result."""
+ def __init__(self, result: VerificationResult) -> None:
+ self.result = result
+ self.name = result.verifier
+ self.verifier_class = result.verifier_class
+ self.independent = result.independent
+ self.trust_boundary = result.trust_boundary
+ self.claim_ids = result.claim_ids
+ self.claims_supported = result.claim_ids
+ self.uncertainty = 0.0
+ self.expected_information_gain = 1.0
+ self.cost = 1.0
+
+ def verify(self, candidate: Candidate) -> VerificationResult:
+ return self.result
+
+
+# Deliberately explicit: no authenticated persisted self-model is currently
+# implemented, so state is never adapted or trusted across sessions.
+CROSS_SESSION_ADAPTATION_IMPLEMENTED = False
+
+
class RunState(str, Enum):
CREATED = "created"
ACTIVE = "active"
@@ -84,6 +151,145 @@ class RunState(str, Enum):
REJECTED = "rejected"
+@dataclass(frozen=True)
+class ProcessAttestation:
+ """Externally supplied statement binding a verdict to a measured process.
+
+ This object is intentionally not self-authenticating. ``record_...``
+ requires a separately configured verifier and verification material from a
+ trusted channel; absent that verifier the runtime fails closed.
+ """
+ attestation_id: str
+ session_id: str
+ candidate_id: str
+ verifier: str
+ verifier_class: str
+ passed: bool
+ candidate_hash: str
+ candidate_graph_hash: str
+ evidence_ids: tuple[str, ...] = ()
+ receipt_id: str = ""
+ executable_identity: Mapping[str, Any] = field(default_factory=dict)
+ workspace_identity: Mapping[str, Any] = field(default_factory=dict)
+ input_hash: str = ""
+ output_hash: str = ""
+ issued_at: str = field(default_factory=utc_now)
+ signature: str = ""
+ metadata: Mapping[str, Any] = field(default_factory=dict)
+ # These fields are signed as part of payload(); a signature over only the
+ # candidate hash is insufficient to bind the adjudicated statement.
+ claim_ids: tuple[str, ...] = ()
+ provenance_ids: tuple[str, ...] = ()
+ counterexample_ids: tuple[str, ...] = ()
+ independent: bool = False
+ scope: str = ""
+
+ def __post_init__(self) -> None:
+ for name in ("attestation_id", "session_id", "candidate_id", "verifier",
+ "verifier_class", "candidate_hash", "candidate_graph_hash",
+ "receipt_id"):
+ if not isinstance(getattr(self, name), str) or not getattr(self, name).strip():
+ raise ValueError(f"{name} must be non-empty")
+ if type(self.passed) is not bool:
+ raise TypeError("passed must be a boolean")
+ if type(self.independent) is not bool:
+ raise TypeError("independent must be a boolean")
+ if self.scope and (not isinstance(self.scope, str) or not self.scope.strip()):
+ raise ValueError("scope must be a non-empty string when supplied")
+ if any(len(getattr(self, field_name)) > 10000
+ for field_name in ("evidence_ids", "claim_ids", "provenance_ids", "counterexample_ids")):
+ raise ValueError("attestation contains too many references")
+ object.__setattr__(self, "evidence_ids", tuple(str(x) for x in self.evidence_ids))
+ object.__setattr__(self, "claim_ids", tuple(str(x) for x in self.claim_ids))
+ object.__setattr__(self, "provenance_ids", tuple(str(x) for x in self.provenance_ids))
+ object.__setattr__(self, "counterexample_ids", tuple(str(x) for x in self.counterexample_ids))
+ for field_name in ("claim_ids", "provenance_ids", "counterexample_ids"):
+ if any(not item.strip() for item in getattr(self, field_name)):
+ raise ValueError(f"{field_name} cannot contain empty IDs")
+ object.__setattr__(self, "executable_identity", copy.deepcopy(dict(self.executable_identity)))
+ object.__setattr__(self, "workspace_identity", copy.deepcopy(dict(self.workspace_identity)))
+ object.__setattr__(self, "metadata", copy.deepcopy(dict(self.metadata)))
+
+ def payload(self) -> dict[str, Any]:
+ data = self.to_dict()
+ data.pop("signature", None)
+ return data
+
+ def to_dict(self) -> dict[str, Any]:
+ return copy.deepcopy({
+ "attestation_id": self.attestation_id, "session_id": self.session_id,
+ "candidate_id": self.candidate_id, "verifier": self.verifier,
+ "verifier_class": self.verifier_class, "passed": self.passed,
+ "candidate_hash": self.candidate_hash,
+ "candidate_graph_hash": self.candidate_graph_hash,
+ "evidence_ids": list(self.evidence_ids), "receipt_id": self.receipt_id,
+ "executable_identity": dict(self.executable_identity),
+ "workspace_identity": dict(self.workspace_identity),
+ "input_hash": self.input_hash, "output_hash": self.output_hash,
+ "issued_at": self.issued_at, "signature": self.signature,
+ "metadata": dict(self.metadata),
+ "claim_ids": list(self.claim_ids),
+ "provenance_ids": list(self.provenance_ids),
+ "counterexample_ids": list(self.counterexample_ids),
+ "independent": self.independent,
+ "scope": self.scope,
+ })
+
+ @classmethod
+ def from_dict(cls, data: Mapping[str, Any]) -> "ProcessAttestation":
+ values = dict(data)
+ values["evidence_ids"] = tuple(values.get("evidence_ids", ()))
+ return cls(**values)
+
+
+class ProcessAttestationVerifier(Protocol):
+ def verify(self, attestation: ProcessAttestation, verification_material: Any) -> bool:
+ """Verify externally supplied process identity/signature material."""
+
+
+class CheckpointAuthenticator(Protocol):
+ def sign(self, payload: Mapping[str, Any]) -> str:
+ ...
+
+ def verify(self, payload: Mapping[str, Any], signature: str) -> bool:
+ ...
+
+
+class HMACCheckpointAuthenticator:
+ """External checkpoint authenticator; its key is never part of a checkpoint."""
+ def __init__(self, key: bytes):
+ if not isinstance(key, bytes) or len(key) < 16:
+ raise ValueError("checkpoint key must be at least 128 bits")
+ self._key = bytes(key)
+
+ def sign(self, payload: Mapping[str, Any]) -> str:
+ return hmac.new(self._key, canonical_hash(payload).encode("ascii"), hashlib.sha256).hexdigest()
+
+ def verify(self, payload: Mapping[str, Any], signature: str) -> bool:
+ if not isinstance(signature, str):
+ return False
+ expected = self.sign(payload)
+ return hmac.compare_digest(signature, expected)
+
+
+class HMACProcessAttestationVerifier:
+ """Reference verifier for a host-controlled signed attestation channel."""
+ def __init__(self, key: bytes):
+ if not isinstance(key, bytes) or len(key) < 16:
+ raise ValueError("attestation key must be at least 128 bits")
+ self._key = bytes(key)
+
+ def verify(self, attestation: ProcessAttestation, verification_material: Any) -> bool:
+ supplied = verification_material
+ if isinstance(supplied, Mapping):
+ supplied = supplied.get("signature")
+ supplied = supplied or attestation.signature
+ if not isinstance(supplied, str):
+ return False
+ expected = hmac.new(self._key, canonical_hash(attestation.payload()).encode("ascii"), hashlib.sha256).hexdigest()
+ return hmac.compare_digest(supplied, expected)
+
+
@dataclass
class FableRun:
"""A single auditable task run."""
@@ -92,6 +298,10 @@ class FableRun:
task: TaskSpec
state: RunState = RunState.CREATED
started_at: str = field(default_factory=utc_now)
+ # Legacy tuple/boolean verifier adapters remain available only when this
+ # explicit compatibility switch is enabled. Strict claim adjudication is
+ # the default and cannot be inferred from a verifier's output shape.
+ compatibility_mode: bool = False
receipts: dict[str, ToolReceipt] = field(default_factory=dict)
candidates: dict[str, Candidate] = field(default_factory=dict)
evidence: dict[str, Evidence] = field(default_factory=dict)
@@ -101,24 +311,134 @@ class FableRun:
invalidated_verifiers: dict[str, str] = field(default_factory=dict)
# System 3 Meta-Cognitive Deliberation & Invariant Tracking
system3_free_energy: dict[str, Any] = field(default_factory=dict)
+ system3_active_inference: dict[str, Any] = field(default_factory=dict)
system3_kripke_invariants: dict[str, Any] = field(default_factory=dict)
system3_hyperbolic_embeddings: dict[str, Any] = field(default_factory=dict)
system3_meta_cycles: list[dict[str, Any]] = field(default_factory=list)
+ # Auditable observe -> predict -> act -> outcome -> update records.
+ system3_observations: dict[str, dict[str, Any]] = field(default_factory=dict)
+ predictions: dict[str, Prediction] = field(default_factory=dict)
+ system3_actions: dict[str, dict[str, Any]] = field(default_factory=dict)
+ outcomes: dict[str, Outcome] = field(default_factory=dict)
+ system3_updates: dict[str, dict[str, Any]] = field(default_factory=dict)
+ system3_beliefs: dict[str, dict[str, Any]] = field(default_factory=dict)
+ system3_policy_revisions: dict[str, dict[str, Any]] = field(default_factory=dict)
triz_repair_recommendations: list[dict[str, Any]] = field(default_factory=list)
+ # Configured only by the host that owns the external attestation channel.
+ process_attestation_verifier: ProcessAttestationVerifier | None = field(
+ default=None, repr=False, compare=False
+ )
+ _artifact_commits: dict[str, tuple[str, str]] = field(
+ default_factory=dict, repr=False, compare=False
+ )
+ _system3_commits: dict[str, str] = field(default_factory=dict, repr=False, compare=False)
+ # Process-local monotonic revocation ledger. It complements the committed
+ # event chain so deleting either the public map or an event cannot restore
+ # a verifier during this run.
+ _revocation_ledger: dict[str, str] = field(default_factory=dict, repr=False, compare=False)
+ _system3_loop_commits: dict[str, str] = field(default_factory=dict, repr=False, compare=False)
+ _process_attestation_materials: dict[str, Any] = field(
+ default_factory=dict, repr=False, compare=False
+ )
_attestation_secret: bytes = field(default_factory=lambda: secrets.token_bytes(32),
repr=False)
+ # A deserialized run is observationally useful but not trusted unless the
+ # complete checkpoint was authenticated by an external key.
+ _checkpoint_trusted: bool = field(default=True, repr=False, compare=False)
_lock: threading.RLock = field(default_factory=threading.RLock,
repr=False, compare=False)
+ # A restored active checkpoint is deliberately untrusted unless its whole
+ # payload was authenticated by the host. New runs are trusted locally;
+ # this flag distinguishes the two cases without claiming that local code
+ # is an isolated security boundary.
+ _restored_from_checkpoint: bool = field(default=False, repr=False, compare=False)
+ _task_commitment: str = field(default="", repr=False, compare=False)
+ _compatibility_commitment: str = field(default="", repr=False, compare=False)
+ _executed_verifiers: dict[str, RegisteredVerifier] = field(default_factory=dict, repr=False, compare=False)
+ _authenticated_receipt_ids: set[str] = field(default_factory=set, repr=False, compare=False)
+ # A signed checkpoint authenticates the serialized verdict commitments,
+ # but cannot carry the process-local HMAC secret used by live runs.
+ _checkpoint_verification_commitments: dict[str, str] = field(
+ default_factory=dict, repr=False, compare=False
+ )
+ _last_adjudication: Adjudication | None = field(default=None, repr=False, compare=False)
+
+ _SNAPSHOT_FIELDS = frozenset({
+ "task", "receipts", "candidates", "evidence", "verifications", "events",
+ "final_candidate_id", "invalidated_verifiers", "system3_free_energy",
+ "system3_active_inference", "system3_kripke_invariants",
+ "system3_hyperbolic_embeddings", "system3_meta_cycles",
+ "system3_observations", "predictions", "system3_actions", "outcomes",
+ "system3_updates", "system3_beliefs", "system3_policy_revisions",
+ "triz_repair_recommendations",
+ })
+
+ def __setattr__(self, name: str, value: Any) -> None:
+ # Terminal runs have a one-way public state boundary. Internal
+ # bookkeeping (underscore names) remains usable for serialization, but
+ # callers cannot replace state or a public collection after finality.
+ if name == "compatibility_mode" and "compatibility_mode" in self.__dict__:
+ if self.__dict__["compatibility_mode"] != value and not self.__dict__.get("_restoring", False):
+ raise RuntimeError("compatibility mode is immutable after run creation")
+ if name == "state" and "state" in self.__dict__:
+ old = self.__dict__["state"]
+ if ("_lock" in self.__dict__ and
+ old in (RunState.FINALIZED, RunState.REJECTED) and
+ not self.__dict__.get("_restoring", False)):
+ raise RuntimeError(f"{old.value} runs are immutable; create a new run")
+ elif name in self._SNAPSHOT_FIELDS and "state" in self.__dict__:
+ if ("_lock" in self.__dict__ and
+ self.__dict__["state"] in (RunState.FINALIZED, RunState.REJECTED) and
+ not self.__dict__.get("_restoring", False)):
+ raise RuntimeError(f"{self.__dict__['state'].value} runs are immutable; create a new run")
+ object.__setattr__(self, name, value)
+
+ def __getattribute__(self, name: str) -> Any:
+ # Expose defensive deep snapshots for all mutable public state after a
+ # terminal transition. Thus nested writes cannot mutate the run even
+ # if a caller obtains a dict/list several levels down.
+ if name != "_SNAPSHOT_FIELDS":
+ try:
+ state = object.__getattribute__(self, "__dict__").get("state")
+ fields = object.__getattribute__(self, "_SNAPSHOT_FIELDS")
+ if (state in (RunState.FINALIZED, RunState.REJECTED) and name in fields
+ and not object.__getattribute__(self, "__dict__").get("_restoring", False)):
+ return _freeze_snapshot(copy.deepcopy(object.__getattribute__(self, name)))
+ except (AttributeError, TypeError):
+ pass
+ return object.__getattribute__(self, name)
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.session_id, str) or not self.session_id.strip() or len(self.session_id) > 16384:
+ raise ValueError("session_id must be a bounded non-empty string")
+ if not isinstance(self.task, TaskSpec):
+ raise TypeError("task must be TaskSpec")
+ object.__setattr__(self, "_task_commitment", canonical_hash(self.task.to_dict()))
+ object.__setattr__(self, "_compatibility_commitment", canonical_hash(self.compatibility_mode))
+
+ def _ensure_mutable(self) -> None:
+ if self.state in (RunState.FINALIZED, RunState.REJECTED):
+ raise RuntimeError(f"{self.state.value} runs are immutable; create a new run")
def _event(self, event_type: str, **data: Any) -> None:
with self._lock:
+ if self.state in (RunState.FINALIZED, RunState.REJECTED):
+ raise RuntimeError(f"{self.state.value} runs are immutable")
event = {"type": event_type, "at": utc_now(), **data}
event["prev_hash"] = self.events[-1].get("event_hash", "0" * 64) if self.events else "0" * 64
event["event_hash"] = canonical_hash(event)
self.events.append(event)
- def validate_event_history(self) -> None:
+ def validate_event_history(self, *, expected_count: int | None = None,
+ expected_terminal_commitment: str | None = None,
+ validate_auxiliary: bool = True) -> None:
"""Reject edited, reordered, or truncated event history."""
+ if len(self.events) > 100_000:
+ raise ValueError("event history exceeds the maximum length")
+ if expected_count is not None and (not isinstance(expected_count, int) or expected_count < 0):
+ raise ValueError("invalid event count commitment")
+ if expected_count is not None and len(self.events) != expected_count:
+ raise ValueError("event history is truncated or has extra events")
previous = "0" * 64
for event in self.events:
if event.get("prev_hash") != previous:
@@ -128,6 +448,15 @@ def validate_event_history(self) -> None:
if supplied_hash != canonical_hash(body):
raise ValueError("event history contains a tampered event")
previous = supplied_hash
+ if expected_terminal_commitment is not None:
+ actual = canonical_hash({"count": len(self.events), "terminal_hash": previous})
+ if not hmac.compare_digest(str(expected_terminal_commitment), actual):
+ raise ValueError("event terminal commitment mismatch")
+ # History validity is meaningful only when it agrees with the object
+ # maps that the history is supposed to commit.
+ self._reconcile_event_state()
+ if validate_auxiliary:
+ self._validate_auxiliary_telemetry()
def start(self) -> None:
if self.state is not RunState.CREATED:
@@ -135,53 +464,123 @@ def start(self) -> None:
self.state = RunState.ACTIVE
self._event("run_started", session_id=self.session_id)
- def record_receipt(self, receipt: ToolReceipt) -> None:
+ def record_receipt(
+ self, receipt: ToolReceipt, *, trusted_boundary: str | None = None,
+ authenticated_boundary: Callable[[ToolReceipt], bool] | None = None,
+ ) -> None:
+ """Record a host result without allowing application code to mint trust.
+
+ Receipts constructed locally are retained for diagnostics but are
+ ``self_minted`` and cannot satisfy the receipt-backed System 3 loop.
+ A host adapter must explicitly classify the receipt at this boundary
+ (or construct it with a trusted boundary); the classification is still
+ only provenance metadata and must not be confused with a signature.
+ """
with self._lock:
+ self._ensure_mutable()
+ if not isinstance(receipt, ToolReceipt):
+ raise TypeError("receipt must be a ToolReceipt")
+ if trusted_boundary is not None:
+ if trusted_boundary not in ToolReceipt.TRUSTED_BOUNDARIES:
+ raise PermissionError("receipt trust may only be supplied by a host/external boundary")
+ receipt = replace(receipt, trust_boundary=trusted_boundary)
if receipt.session_id != self.session_id:
raise ValueError("tool receipt belongs to a different session")
if receipt.receipt_id in self.receipts:
raise ValueError(f"duplicate receipt: {receipt.receipt_id}")
- self.receipts[receipt.receipt_id] = receipt
+ if authenticated_boundary is not None:
+ if not callable(authenticated_boundary):
+ raise TypeError("authenticated_boundary must be a callable receipt authenticator")
+ try:
+ authenticated = authenticated_boundary(receipt) is True
+ except Exception as exc:
+ raise PermissionError("receipt boundary authentication failed") from exc
+ if not authenticated:
+ raise PermissionError("receipt boundary authentication failed")
+ stored = copy.deepcopy(receipt)
+ self.receipts[receipt.receipt_id] = stored
+ if authenticated_boundary is not None:
+ self._authenticated_receipt_ids.add(receipt.receipt_id)
self._event("tool_receipt", receipt_id=receipt.receipt_id,
- capability=receipt.capability, success=receipt.success)
+ capability=receipt.capability, success=receipt.success,
+ trust_boundary=receipt.trust_boundary)
+
+ def _event_temporal_report(self) -> dict[str, Any]:
+ """Rebuild temporal safety solely from the authenticated event chain."""
+ self.validate_event_history(validate_auxiliary=False)
+ self._reconcile_event_state()
+ if not self.events:
+ raise RuntimeError("cannot evaluate temporal model without run events")
+ kripke = KripkeStructure()
+ for index, event in enumerate(self.events):
+ world_id = f"event_{index}"
+ event_type = str(event.get("type", ""))
+ props = {"safe", event_type}
+ if event_type == "tool_receipt" and type(event.get("success")) is not bool:
+ raise ValueError("tool receipt event has non-boolean success")
+ if event_type == "tool_receipt" and not event.get("success"):
+ props.discard("safe")
+ kripke.add_world(world_id, propositions=props,
+ metadata={"event_hash": event.get("event_hash"),
+ "event_type": event_type},
+ is_initial=index == 0)
+ kripke.add_transition(world_id, world_id)
+ if index:
+ kripke.add_transition(f"event_{index-1}", world_id)
+ result = KripkeModelChecker(kripke).check("AG(safe)", "event_0")
+ return {
+ "formula": "AG(safe)", "is_satisfied": result.is_satisfied,
+ # This is a projection of local event labels only. It is not a
+ # broker guarantee, security proof, or proof that the artifact is
+ # safe in the real world.
+ "claim_status": "telemetry_projection_not_safety_proof",
+ "is_safety_proof": False,
+ "safety_claim_scope": "runtime_event_labels_only",
+ "initial_world": "event_0",
+ "satisfying_worlds": sorted(list(result.satisfied_worlds)),
+ "event_hashes": [e.get("event_hash") for e in self.events],
+ "event_count": len(self.events),
+ "terminal_event_hash": self.events[-1].get("event_hash"),
+ }
def _evaluate_system3_for_candidate(self, candidate: Candidate) -> None:
"""Compute and track Friston Free Energy F, Kripke state invariants, and Hyperbolic tree embeddings."""
try:
- # 1. Friston Active Inference Free Energy F
- pomdp_model = create_default_architecture_pomdp()
- fe_engine = ActiveInferenceEngine(pomdp_model)
- obs = "HIGH_THROUGHPUT_CLEAN" if all(
- self.receipts[r].success for r in candidate.receipt_ids if r in self.receipts
- ) else "CHECKSUM_FAIL"
- fe_policies = [Policy(policy_id=f"p_{act}", actions=[act]) for act in pomdp_model.actions]
- fe_report = fe_engine.select_action(obs, fe_policies)
- fe_data = {
- "variational_free_energy_f": round(fe_report.variational_free_energy_f, 4),
- "complexity_kl": round(fe_report.complexity_kl, 4),
- "accuracy_log_likelihood": round(fe_report.accuracy_log_likelihood, 4),
- "observation": obs,
- "selected_policy": fe_report.selected_action,
- }
+ # Registration records only an unstarted inference state. It must
+ # not manufacture an observation, prediction, action, or update
+ # from already-present receipts. The receipt-bound lifecycle is
+ # started explicitly by observe_system3/predict_system3/act_system3.
+ fe_engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ if bool(self.task.metadata.get("require_system3_loop", False)):
+ # Strict candidates expose only an unstarted state. No
+ # registration-time projection may stand in for the host-bound
+ # observe/predict/act/outcome/update lifecycle.
+ fe_data = {"status": "awaiting_receipt_bound_system3_loop",
+ "loop_required_for_update": True}
+ else:
+ # Legacy registration callers expect useful baseline telemetry.
+ # This is an estimate from the fixed generative model, not a
+ # receipt, observation, or authorization and cannot satisfy a
+ # strict loop/finalization gate.
+ telemetry_engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ f_val, complexity, accuracy = telemetry_engine.update_beliefs("HIGH_THROUGHPUT_CLEAN")
+ fe_data = {
+ "status": "legacy_telemetry_only",
+ "claim_status": "estimated_model_projection_not_measurement",
+ "observation": "HIGH_THROUGHPUT_CLEAN",
+ "variational_free_energy_f": f_val,
+ "complexity_kl": complexity,
+ "accuracy_log_likelihood": accuracy,
+ "loop_required_for_update": False,
+ }
candidate.metadata["system3_free_energy"] = fe_data
self.system3_free_energy[candidate.candidate_id] = fe_data
+ active_data = fe_engine.to_dict()
+ candidate.metadata["system3_active_inference"] = copy.deepcopy(active_data)
+ self.system3_active_inference[candidate.candidate_id] = active_data
- # 2. Kripke state invariants AG(safe)
- kripke = KripkeStructure()
- kripke.add_world("w_init", propositions={"initialized", "safe"})
- kripke.add_world("w_cand", propositions={"candidate_registered", "safe", "artifact_bounded"})
- kripke.add_world("w_ver", propositions={"verifiable", "safe"})
- kripke.add_transition("w_init", "w_cand")
- kripke.add_transition("w_cand", "w_ver")
- kripke.add_transition("w_ver", "w_ver")
- checker = KripkeModelChecker(kripke)
- k_res = checker.check("AG(safe)", "w_init")
- kripke_data = {
- "formula": "AG(safe)",
- "is_satisfied": k_res.is_satisfied,
- "initial_world": "w_init",
- "satisfying_worlds": sorted(list(k_res.satisfied_worlds)),
- }
+ # 2. Event-bound temporal safety model.
+ kripke_data = self._event_temporal_report()
candidate.metadata["system3_kripke"] = kripke_data
self.system3_kripke_invariants[candidate.candidate_id] = kripke_data
@@ -206,8 +605,10 @@ def _evaluate_system3_for_candidate(self, candidate: Candidate) -> None:
}
candidate.metadata["system3_hyperbolic"] = hyp_data
self.system3_hyperbolic_embeddings[candidate.candidate_id] = hyp_data
- except Exception:
- pass
+ except Exception as exc:
+ # System 3 telemetry is a trust input, not decorative logging.
+ # Never register a candidate while its safety/model checks failed.
+ raise RuntimeError("System 3 candidate evaluation failed closed") from exc
def _generate_triz_repair_recommendation(
self, candidate_id: str, reasons: list[str]
@@ -290,21 +691,18 @@ def _generate_triz_repair_recommendation(
candidate_id=candidate_id,
synthesis_id=synthesis.synthesis_id,
residual_score=synthesis.residual_contradiction_score,
+ record_hash=canonical_hash(triz_payload),
)
return triz_payload
def register_candidate(self, candidate: Candidate) -> None:
with self._lock:
+ self._ensure_mutable()
if candidate.session_id != self.session_id:
raise ValueError("candidate belongs to a different session")
if candidate.candidate_id in self.candidates:
raise ValueError(f"duplicate candidate: {candidate.candidate_id}")
- missing = [rid for rid in candidate.receipt_ids if rid not in self.receipts]
- if missing:
- raise ValueError(f"candidate references unknown receipts: {missing}")
- missing_evidence = [eid for eid in candidate.evidence_ids if eid not in self.evidence]
- if missing_evidence:
- raise ValueError(f"candidate references unknown evidence: {missing_evidence}")
+ self._validate_candidate_graph(candidate)
# Keep a private snapshot so callers cannot mutate an artifact or
# metadata after it enters the auditable run.
stored = replace(
@@ -315,14 +713,50 @@ def register_candidate(self, candidate: Candidate) -> None:
# System 3 Integration: Compute/track Friston Free Energy F, Kripke invariants, Hyperbolic tree embeddings
self._evaluate_system3_for_candidate(stored)
self.candidates[candidate.candidate_id] = stored
+ self._artifact_commits[candidate.candidate_id] = (
+ canonical_hash(stored.artifact), self._dependency_hash(stored)
+ )
+ self._system3_commits[candidate.candidate_id] = self._system3_metadata_hash(stored)
self._event("candidate_registered", candidate_id=candidate.candidate_id)
+ def _dependency_hash(self, candidate: Candidate) -> str:
+ """Hash referenced receipt/evidence objects without mutable metadata."""
+ return canonical_hash({
+ "receipts": [canonical_hash(self.receipts[r].to_dict()) for r in candidate.receipt_ids],
+ "evidence": [canonical_hash(self.evidence[e].to_dict()) for e in candidate.evidence_ids],
+ })
+
+ def _system3_metadata_hash(self, candidate: Candidate) -> str:
+ """Commit every System 3 metadata value attached to a candidate."""
+ return canonical_hash({
+ key: value for key, value in candidate.metadata.items()
+ if str(key).startswith("system3_")
+ })
+
+ def _validate_system3_metadata(self, candidate: Candidate) -> None:
+ committed = self._system3_commits.get(candidate.candidate_id)
+ if committed is None or not hmac.compare_digest(committed, self._system3_metadata_hash(candidate)):
+ raise PermissionError("System 3 candidate telemetry changed after commitment")
+ # Each derived map is also checked against the candidate's committed
+ # metadata; a public map reference must not silently alter trusted data.
+ pairs = (("system3_free_energy", self.system3_free_energy),
+ ("system3_active_inference", self.system3_active_inference),
+ ("system3_kripke", self.system3_kripke_invariants),
+ ("system3_hyperbolic", self.system3_hyperbolic_embeddings),
+ ("system3_loop", {k: self.candidates[k].metadata.get("system3_loop")
+ for k in self.candidates if "system3_loop" in self.candidates[k].metadata}))
+ for key, store in pairs:
+ if key in candidate.metadata and candidate.candidate_id in store:
+ if canonical_hash(candidate.metadata[key]) != canonical_hash(store[candidate.candidate_id]):
+ raise PermissionError("System 3 telemetry store disagrees with candidate metadata")
+
def attach_evidence(self, evidence: Evidence) -> None:
with self._lock:
+ self._ensure_mutable()
self._validate_evidence(evidence)
if evidence.evidence_id in self.evidence:
raise ValueError(f"duplicate evidence: {evidence.evidence_id}")
- self.evidence[evidence.evidence_id] = evidence
+ self.evidence[evidence.evidence_id] = copy.deepcopy(evidence)
self._event("evidence_attached", evidence_id=evidence.evidence_id,
receipt_id=evidence.receipt_id)
@@ -339,8 +773,324 @@ def _validate_evidence(self, evidence: Evidence) -> None:
if evidence.content_hash != receipt.output_hash:
raise PermissionError("evidence content hash does not match receipt output hash")
+ def _validate_candidate_graph(self, candidate: Candidate) -> None:
+ """Require a closed candidate graph, not merely individually known IDs."""
+ if len(set(candidate.receipt_ids)) != len(candidate.receipt_ids):
+ raise ValueError("candidate contains duplicate receipt references")
+ if len(set(candidate.evidence_ids)) != len(candidate.evidence_ids):
+ raise ValueError("candidate contains duplicate evidence references")
+ for receipt_id in candidate.receipt_ids:
+ receipt = self.receipts.get(receipt_id)
+ if receipt is None:
+ raise ValueError(f"candidate references unknown receipt: {receipt_id}")
+ if receipt.session_id != self.session_id:
+ raise PermissionError("candidate receipt has the wrong session")
+ for evidence_id in candidate.evidence_ids:
+ evidence = self.evidence.get(evidence_id)
+ if evidence is None:
+ raise ValueError(f"candidate references unknown evidence: {evidence_id}")
+ self._validate_evidence(evidence)
+ if evidence.receipt_id not in set(candidate.receipt_ids):
+ raise PermissionError(
+ f"candidate evidence {evidence_id} is not anchored to a candidate receipt"
+ )
+
+ _EVENT_FIELDS = {
+ "run_started": {"type", "at", "session_id", "prev_hash", "event_hash"},
+ "tool_receipt": {"type", "at", "receipt_id", "capability", "success", "trust_boundary", "prev_hash", "event_hash"},
+ "evidence_attached": {"type", "at", "evidence_id", "receipt_id", "prev_hash", "event_hash"},
+ "candidate_registered": {"type", "at", "candidate_id", "prev_hash", "event_hash"},
+ "verification_recorded": {"type", "at", "verification_id", "candidate_id", "verifier", "verifier_class", "passed", "prev_hash", "event_hash"},
+ "verifier_invalidated": {"type", "at", "verifier", "reason", "prev_hash", "event_hash"},
+ "triz_repair_recommendation": {"type", "at", "candidate_id", "synthesis_id", "residual_score", "record_hash", "prev_hash", "event_hash"},
+ "finalization_rejected": {"type", "at", "candidate_id", "missing", "prev_hash", "event_hash"},
+ "run_finalized": {"type", "at", "candidate_id", "prev_hash", "event_hash"},
+ "system3_meta_cycle_completed": {"type", "at", "candidate_id", "cycle_hash", "prev_hash", "event_hash"},
+ "system3_observation": {"type", "at", "candidate_id", "observation_id", "observation_hash", "record_hash", "prev_hash", "event_hash"},
+ "system3_prediction": {"type", "at", "prediction_id", "candidate_id", "action", "observation_hash", "record_hash", "prev_hash", "event_hash"},
+ "system3_action": {"type", "at", "action_id", "candidate_id", "prediction_id", "action", "prediction_hash", "action_hash", "prev_hash", "event_hash"},
+ "system3_outcome": {"type", "at", "outcome_id", "candidate_id", "prediction_id", "receipt_id", "receipt_output_hash", "record_hash", "prev_hash", "event_hash"},
+ "system3_update": {"type", "at", "update_id", "candidate_id", "outcome_id", "prediction_id", "prediction_error", "update_hash", "prev_hash", "event_hash"},
+ }
+
+ def _revoked_verifiers_from_events(self) -> dict[str, str]:
+ """Return the append-only revocation ledger, never the mutable map."""
+ revoked: dict[str, str] = {}
+ for event in self.events:
+ if event.get("type") == "verifier_invalidated":
+ verifier, reason = event.get("verifier"), event.get("reason")
+ if (not isinstance(verifier, str) or not verifier.strip()
+ or not isinstance(reason, str) or not reason.strip()):
+ raise ValueError("verifier revocation event is malformed")
+ # Revocation is monotonic. A later event cannot clear it or
+ # replace its reason with a mutable-map edit.
+ revoked.setdefault(verifier.strip(), reason.strip())
+ return revoked
+
+ def _revoked(self, verifier: str) -> bool:
+ # The event ledger is authoritative after restore; the private
+ # process-local ledger protects against in-memory event deletion, and
+ # the public map is a conservative fallback (never an authority that
+ # can clear either committed source).
+ return (verifier in self._revoked_verifiers_from_events()
+ or verifier in self._revocation_ledger
+ or verifier in self.invalidated_verifiers)
+
+ def _validate_auxiliary_telemetry(self) -> None:
+ """Require complete, candidate-keyed System 3 telemetry projections."""
+ candidate_ids = set(self.candidates)
+ allowed_candidate_telemetry = {
+ "system3_free_energy", "system3_active_inference", "system3_kripke",
+ "system3_hyperbolic", "system3_loop", "system3_meta_cycle",
+ }
+ for candidate in self.candidates.values():
+ unknown = {str(key) for key in candidate.metadata
+ if str(key).startswith("system3_")} - allowed_candidate_telemetry
+ if unknown:
+ raise ValueError("candidate contains unsupported System 3 telemetry keys")
+ if "triz_repair_recommendation" in candidate.metadata:
+ canonical_hash(candidate.metadata["triz_repair_recommendation"])
+ keyed = {
+ "free_energy": self.system3_free_energy,
+ "active_inference": self.system3_active_inference,
+ "kripke": self.system3_kripke_invariants,
+ "hyperbolic": self.system3_hyperbolic_embeddings,
+ }
+ for label, store in keyed.items():
+ if not isinstance(store, Mapping) or set(store) != candidate_ids:
+ raise ValueError(f"System 3 {label} map is incomplete or contains extra candidates")
+ for candidate_id, value in store.items():
+ canonical_hash(value)
+ candidate = self.candidates[candidate_id]
+ metadata_key = "system3_" + label
+ if metadata_key not in candidate.metadata or canonical_hash(value) != canonical_hash(candidate.metadata[metadata_key]):
+ raise ValueError(f"System 3 {label} telemetry has no matching candidate commitment")
+
+ loop_candidates = {
+ candidate_id for candidate_id, candidate in self.candidates.items()
+ if "system3_loop" in candidate.metadata
+ }
+ loop_map = {
+ candidate_id: candidate.metadata.get("system3_loop")
+ for candidate_id, candidate in self.candidates.items()
+ if "system3_loop" in candidate.metadata
+ }
+ # The loop is candidate metadata rather than a separate global map;
+ # nevertheless its candidate key set must be closed and canonical.
+ if set(loop_map) != loop_candidates:
+ raise ValueError("System 3 loop telemetry candidate keys are inconsistent")
+ for value in loop_map.values():
+ canonical_hash(value)
+
+ update_candidates = {u.get("candidate_id") for u in self.system3_updates.values()}
+ if set(self.system3_beliefs) != update_candidates or set(self.system3_policy_revisions) != update_candidates:
+ raise ValueError("System 3 belief/policy maps are incomplete or contain extra candidates")
+ for store in (self.system3_beliefs, self.system3_policy_revisions):
+ for value in store.values():
+ canonical_hash(value)
+
+ # Revalidate every candidate's loop, not merely the candidate selected
+ # for finalization; a forged auxiliary record for another candidate
+ # must not hide in an otherwise valid run.
+ record_candidates = set(self.system3_observations)
+ record_candidates.update(p.candidate_id for p in self.predictions.values())
+ record_candidates.update(a.get("candidate_id") for a in self.system3_actions.values())
+ record_candidates.update(o.candidate_id for o in self.outcomes.values())
+ record_candidates.update(u.get("candidate_id") for u in self.system3_updates.values())
+ for candidate_id in record_candidates:
+ if candidate_id not in candidate_ids:
+ raise ValueError("System 3 record has no candidate backing")
+ self._validate_system3_record_integrity(candidate_id)
+
+ # Meta-cycle and TRIZ records are global projections, but are trusted
+ # only when each record is backed by a candidate and a committed event.
+ meta_events = [e for e in self.events if e.get("type") == "system3_meta_cycle_completed"]
+ if len(meta_events) != len(self.system3_meta_cycles):
+ raise ValueError("System 3 meta-cycle list does not reconcile with events")
+ seen_meta_candidates: set[str] = set()
+ for event, cycle in zip(meta_events, self.system3_meta_cycles):
+ if not isinstance(cycle, Mapping) or cycle.get("candidate_id") not in candidate_ids:
+ raise ValueError("System 3 meta-cycle entry has no candidate backing")
+ if cycle.get("candidate_id") in seen_meta_candidates:
+ raise ValueError("duplicate System 3 meta-cycle candidate entry")
+ seen_meta_candidates.add(cycle.get("candidate_id"))
+ if not isinstance(cycle, Mapping) or cycle.get("candidate_id") not in candidate_ids:
+ raise ValueError("System 3 meta-cycle entry has no candidate backing")
+ if event.get("candidate_id") != cycle.get("candidate_id"):
+ raise ValueError("System 3 meta-cycle event does not match its record")
+ if event.get("cycle_hash") != canonical_hash(cycle):
+ raise ValueError("System 3 meta-cycle event has no matching commitment")
+ candidate = self.candidates[cycle["candidate_id"]]
+ if candidate.metadata.get("system3_meta_cycle") is not cycle and canonical_hash(candidate.metadata.get("system3_meta_cycle")) != canonical_hash(cycle):
+ raise ValueError("System 3 meta-cycle candidate metadata is stale")
+ canonical_hash(cycle)
+
+ meta_candidate_ids = {cycle.get("candidate_id") for cycle in self.system3_meta_cycles}
+ metadata_meta_ids = {candidate_id for candidate_id, candidate in self.candidates.items()
+ if "system3_meta_cycle" in candidate.metadata}
+ if metadata_meta_ids != meta_candidate_ids:
+ raise ValueError("System 3 meta-cycle candidate metadata keys do not reconcile")
+
+ triz_events = [e for e in self.events if e.get("type") == "triz_repair_recommendation"]
+ if len(triz_events) != len(self.triz_repair_recommendations):
+ raise ValueError("TRIZ recommendation list does not reconcile with events")
+ for event, record in zip(triz_events, self.triz_repair_recommendations):
+ if not isinstance(record, Mapping) or record.get("candidate_id") not in candidate_ids:
+ raise ValueError("TRIZ recommendation has no candidate backing")
+ if (event.get("candidate_id") != record.get("candidate_id")
+ or event.get("synthesis_id") != record.get("synthesis_id")
+ or event.get("residual_score") != record.get("residual_contradiction_score")
+ or event.get("record_hash") != canonical_hash(record)):
+ raise ValueError("TRIZ recommendation event does not match its record")
+ canonical_hash(record)
+ latest_triz: dict[str, Mapping[str, Any]] = {}
+ for record in self.triz_repair_recommendations:
+ latest_triz[str(record["candidate_id"])] = record
+ for candidate_id, candidate in self.candidates.items():
+ has_metadata = "triz_repair_recommendation" in candidate.metadata
+ if has_metadata != (candidate_id in latest_triz):
+ raise ValueError("TRIZ candidate metadata does not reconcile with recommendations")
+ if has_metadata and canonical_hash(candidate.metadata["triz_repair_recommendation"]) != canonical_hash(latest_triz[candidate_id]):
+ raise ValueError("TRIZ candidate metadata is stale")
+
+ def _reconcile_event_state(self) -> None:
+ """Reconcile object maps with the authenticated append-only event model."""
+ # Every mutable object entering a run must have exactly one corresponding
+ # event. This catches map injection/removal even when object hashes are
+ # otherwise internally self-consistent.
+ event_receipts = [e.get("receipt_id") for e in self.events if e.get("type") == "tool_receipt"]
+ event_evidence = [e.get("evidence_id") for e in self.events if e.get("type") == "evidence_attached"]
+ event_candidates = [e.get("candidate_id") for e in self.events if e.get("type") == "candidate_registered"]
+ event_verifications = [e.get("verification_id") for e in self.events if e.get("type") == "verification_recorded"]
+ event_predictions = [e.get("prediction_id") for e in self.events if e.get("type") == "system3_prediction"]
+ event_actions = [e.get("action_id") for e in self.events if e.get("type") == "system3_action"]
+ event_outcomes = [e.get("outcome_id") for e in self.events if e.get("type") == "system3_outcome"]
+ event_updates = [e.get("update_id") for e in self.events if e.get("type") == "system3_update"]
+ event_observations = [e.get("candidate_id") for e in self.events if e.get("type") == "system3_observation"]
+ event_started = [e for e in self.events if e.get("type") == "run_started"]
+ if not self.events or len(event_started) != 1 or self.events[0].get("type") != "run_started":
+ raise ValueError("event history must contain exactly one initial run-start event")
+ allowed_events = set(self._EVENT_FIELDS)
+ if any(not isinstance(e, Mapping) or e.get("type") not in allowed_events for e in self.events):
+ raise ValueError("event history contains an unsupported event type")
+ for event in self.events:
+ if set(event) != self._EVENT_FIELDS[event["type"]]:
+ raise ValueError(f"{event['type']} event has unexpected or missing fields")
+ # This also recursively rejects non-finite and non-JSON event data.
+ canonical_hash(event)
+ if len(event_receipts) != len(set(event_receipts)) or set(event_receipts) != set(self.receipts):
+ raise ValueError("receipt map does not reconcile with committed events")
+ if len(event_evidence) != len(set(event_evidence)) or set(event_evidence) != set(self.evidence):
+ raise ValueError("evidence map does not reconcile with committed events")
+ if len(event_candidates) != len(set(event_candidates)) or set(event_candidates) != set(self.candidates):
+ raise ValueError("candidate map does not reconcile with committed events")
+ if len(event_verifications) != len(set(event_verifications)) or set(event_verifications) != set(self.verifications):
+ raise ValueError("verification map does not reconcile with committed events")
+ if set(event_observations) != set(self.system3_observations) or len(event_observations) != len(set(event_observations)):
+ raise ValueError("observation map does not reconcile with committed events")
+ if set(event_predictions) != set(self.predictions) or len(event_predictions) != len(set(event_predictions)):
+ raise ValueError("prediction map does not reconcile with committed events")
+ if set(event_actions) != set(self.system3_actions) or len(event_actions) != len(set(event_actions)):
+ raise ValueError("action map does not reconcile with committed events")
+ if set(event_outcomes) != set(self.outcomes) or len(event_outcomes) != len(set(event_outcomes)):
+ raise ValueError("outcome map does not reconcile with committed events")
+ expected_updates = {v.get("update_id") for v in self.system3_updates.values()}
+ if set(event_updates) != expected_updates or len(event_updates) != len(set(event_updates)):
+ raise ValueError("System 3 update map does not reconcile with committed events")
+ if set(self.system3_updates) != set(self.outcomes):
+ raise ValueError("System 3 update keys do not reconcile with outcome keys")
+ for event in self.events:
+ kind = event.get("type")
+ if kind == "run_started":
+ if event.get("session_id") != self.session_id:
+ raise ValueError("run-start event does not match session")
+ elif kind == "tool_receipt":
+ item = self.receipts.get(event.get("receipt_id"))
+ if (item is None or event.get("capability") != item.capability
+ or event.get("success") is not item.success
+ or event.get("trust_boundary") != item.trust_boundary):
+ raise ValueError("receipt event does not match receipt map")
+ elif kind == "evidence_attached":
+ item = self.evidence.get(event.get("evidence_id"))
+ if item is None or event.get("receipt_id") != item.receipt_id:
+ raise ValueError("evidence event does not match evidence map")
+ elif kind == "candidate_registered":
+ item = self.candidates.get(event.get("candidate_id"))
+ if item is None or item.session_id != self.session_id:
+ raise ValueError("candidate event does not match candidate map")
+ elif kind == "system3_observation":
+ item = self.system3_observations.get(event.get("candidate_id"))
+ if (item is None or event.get("candidate_id") not in self.candidates
+ or item.get("observation_hash") != event.get("observation_hash")
+ or item.get("observation_id") != event.get("observation_id")
+ or item.get("record_hash") != event.get("record_hash")):
+ raise ValueError("observation event does not match System 3 state")
+ elif kind == "system3_prediction":
+ item = self.predictions.get(event.get("prediction_id"))
+ if (item is None or item.candidate_id not in self.candidates
+ or item.candidate_id != event.get("candidate_id")
+ or item.action != event.get("action")
+ or item.observation_hash != event.get("observation_hash")
+ or item.record_hash != event.get("record_hash")):
+ raise ValueError("prediction event does not match prediction map")
+ elif kind == "system3_action":
+ item = self.system3_actions.get(event.get("action_id"))
+ if (item is None or item.get("candidate_id") not in self.candidates
+ or item.get("candidate_id") != event.get("candidate_id")
+ or item.get("prediction_id") != event.get("prediction_id")
+ or item.get("action") != event.get("action")
+ or item.get("prediction_hash") != event.get("prediction_hash")
+ or item.get("action_hash") != event.get("action_hash")):
+ raise ValueError("action event does not match action map")
+ elif kind == "system3_outcome":
+ item = self.outcomes.get(event.get("outcome_id"))
+ if (item is None or item.candidate_id not in self.candidates
+ or item.candidate_id != event.get("candidate_id")
+ or item.prediction_id != event.get("prediction_id")
+ or item.receipt_id != event.get("receipt_id")
+ or item.receipt_output_hash != event.get("receipt_output_hash")
+ or item.record_hash != event.get("record_hash")):
+ raise ValueError("outcome event does not match outcome map")
+ elif kind == "system3_update":
+ item = next((u for u in self.system3_updates.values() if u.get("update_id") == event.get("update_id")), None)
+ if (item is None or item.get("candidate_id") not in self.candidates
+ or item.get("candidate_id") != event.get("candidate_id")
+ or item.get("outcome_id") != event.get("outcome_id")
+ or item.get("prediction_error") != event.get("prediction_error")
+ or item.get("update_hash") != event.get("update_hash")):
+ raise ValueError("update event does not match update map")
+ elif kind == "verification_recorded":
+ item = self.verifications.get(event.get("verification_id"))
+ if (item is None or event.get("candidate_id") != item.candidate_id
+ or event.get("verifier") != item.verifier
+ or event.get("verifier_class") != item.verifier_class
+ or event.get("passed") is not item.passed):
+ raise ValueError("verification event does not match verification map")
+ elif kind == "verifier_invalidated":
+ # The map is checked against this immutable event-derived
+ # ledger below; a correctly rehashed forged event cannot grant
+ # a revoked verifier authority.
+ if event.get("verifier") not in self._revoked_verifiers_from_events():
+ raise ValueError("invalid verifier revocation event")
+ elif kind == "system3_meta_cycle_completed":
+ # Full record/hash reconciliation is performed below.
+ pass
+ elif kind == "triz_repair_recommendation":
+ pass
+ elif kind == "finalization_rejected":
+ if (event.get("candidate_id") not in self.candidates
+ or not isinstance(event.get("missing"), list)):
+ raise ValueError("finalization rejection event has no candidate backing")
+ elif kind == "run_finalized":
+ if event.get("candidate_id") not in self.candidates:
+ raise ValueError("finalization event has no candidate backing")
+ expected_revocations = self._revoked_verifiers_from_events()
+ if dict(self.invalidated_verifiers) != expected_revocations:
+ raise ValueError("verifier revocation map does not match its committed event ledger")
+
def _candidate_graph_hash(self, candidate: Candidate) -> str:
"""Commit to a candidate and every receipt/evidence object it references."""
+ self._validate_candidate_graph(candidate)
receipts = []
for receipt_id in candidate.receipt_ids:
receipt = self.receipts.get(receipt_id)
@@ -359,13 +1109,18 @@ def _candidate_graph_hash(self, candidate: Candidate) -> str:
"evidence_id": evidence_id,
"object_hash": canonical_hash(item.to_dict()),
})
+ candidate_payload = candidate.to_dict()
+ # System 3 telemetry is derived and may be refreshed as events append;
+ # it is not an input to verifier identity. Core candidate fields and
+ # every referenced receipt/evidence object remain committed.
+ candidate_payload.pop("metadata", None)
return canonical_hash({
- "candidate": candidate.to_dict(),
+ "candidate": candidate_payload,
"receipts": receipts,
"evidence": evidence,
})
- def _validate_attested_verification(self, result: VerificationResult) -> None:
+ def _validate_attested_verification(self, result: VerificationResult, *, check_runtime: bool = True) -> None:
"""Validate every immutable verdict field and its candidate binding."""
if result.session_id != self.session_id:
raise ValueError("verification belongs to a different session")
@@ -383,9 +1138,28 @@ def _validate_attested_verification(self, result: VerificationResult) -> None:
result.candidate_graph_hash, expected_graph
):
raise PermissionError("verification is not bound to the current candidate dependency graph")
- expected = self._attestation(result)
- if not hmac.compare_digest(result.runtime_attestation, expected):
- raise PermissionError("verification has no valid runtime attestation")
+ if check_runtime:
+ expected = self._attestation(result)
+ if not hmac.compare_digest(result.runtime_attestation, expected):
+ # The live HMAC secret is intentionally never serialized.
+ # For a trusted restored checkpoint, the external checkpoint
+ # signature authenticates this immutable result commitment;
+ # require that commitment to match before accepting it. An
+ # unsigned/restored or subsequently-mutated result still
+ # fails the original runtime-attestation gate.
+ committed = self._checkpoint_verification_commitments.get(result.verification_id)
+ if not (self._checkpoint_trusted and self._restored_from_checkpoint
+ and isinstance(committed, str)
+ and hmac.compare_digest(committed, canonical_hash(result.to_dict()))):
+ raise PermissionError("verification has no valid runtime attestation")
+ expected_scope = f"task:{self.task.task_id};candidate:{candidate.candidate_id}"
+ if not result.scope or result.scope != expected_scope:
+ raise PermissionError("verification scope is missing or not bound to this task/candidate")
+ claim_graph = ClaimGraph.from_task(self.task, candidate)
+ unknown_claims = [claim_id for claim_id in result.claim_ids if claim_id not in set(claim_graph.claim_ids)]
+ if unknown_claims:
+ raise PermissionError("verification claims are outside the candidate claim graph: "
+ + ", ".join(unknown_claims))
unknown_evidence = [eid for eid in result.evidence_ids if eid not in self.evidence]
if unknown_evidence:
raise ValueError(f"verification references unknown evidence: {unknown_evidence}")
@@ -396,8 +1170,30 @@ def _validate_attested_verification(self, result: VerificationResult) -> None:
"verification evidence is not attached to the verified candidate: "
+ ", ".join(unrelated_evidence)
)
+ # Provenance references are not free-form labels in a runtime
+ # verdict. They must resolve to the candidate's receipt/evidence graph
+ # (an evidence ID also resolves transitively to its receipt).
+ candidate_receipts = set(candidate.receipt_ids)
+ candidate_evidence = set(candidate.evidence_ids)
+ unknown_provenance = [pid for pid in result.provenance_ids
+ if pid not in candidate_receipts and pid not in candidate_evidence]
+ if unknown_provenance:
+ raise PermissionError("verification provenance is not in the candidate receipt/evidence graph: "
+ + ", ".join(unknown_provenance))
+ invalid_provenance = []
+ for provenance_id in result.provenance_ids:
+ receipt = self.receipts.get(provenance_id)
+ if receipt is None and provenance_id in self.evidence:
+ receipt = self.receipts.get(self.evidence[provenance_id].receipt_id)
+ if receipt is None or receipt.success is not True:
+ invalid_provenance.append(provenance_id)
+ if invalid_provenance:
+ raise PermissionError("verification provenance is not bound to a successful receipt: "
+ + ", ".join(invalid_provenance))
if result.passed and not result.evidence_ids:
raise PermissionError("a passing verification must cite candidate evidence")
+ # A self-declared independent result is retained with an explicit
+ # untrusted status for auditability, but cannot satisfy a policy gate.
def _record_attested_verification(self, result: VerificationResult) -> None:
"""Store a result after ``execute_verifier`` has attested it."""
@@ -440,13 +1236,14 @@ def execute_verifier(self, verifier: RegisteredVerifier, candidate_id: str) -> V
caller's in-process code is still within the same trust domain; a
stronger boundary requires an isolated broker result.
"""
+ self._ensure_mutable()
candidate = self.candidates.get(candidate_id)
if candidate is None:
raise ValueError(f"unknown candidate: {candidate_id}")
verifier_name = str(getattr(verifier, "name", "")).strip()
if not verifier_name:
raise ValueError("registered verifier must declare a name")
- if verifier_name in self.invalidated_verifiers:
+ if self._revoked(verifier_name):
raise PermissionError(f"verifier is invalidated: {verifier_name}")
# In-process verifier objects are application-level declarations only.
# A process-attested result must arrive from an isolated broker path;
@@ -487,20 +1284,130 @@ def execute_verifier(self, verifier: RegisteredVerifier, candidate_id: str) -> V
inspected_candidate=True,
independent=bool(getattr(verifier, "independent", False)),
trust_boundary=trust_boundary,
+ scope=f"task:{self.task.task_id};candidate:{candidate.candidate_id}",
candidate_graph_hash=self._candidate_graph_hash(candidate),
+ metadata={**dict(raw.metadata), "independence_status": (
+ "self_declared_untrusted" if bool(getattr(verifier, "independent", False))
+ else "not_claimed")},
)
+ if result.independent and not result.passed:
+ # Failed independent checks do not establish provenance and are
+ # retained only as failures when they carry ordinary attestation.
+ pass
+ # Keep legacy in-process declarations observable, but never count them
+ # as independent policy evidence until measured provenance is present.
result = replace(result, runtime_attestation=self._attestation(result))
+ self._executed_verifiers[verifier_name] = verifier
self._record_attested_verification(result)
return result
+ def configure_process_attestation(self, verifier: ProcessAttestationVerifier) -> None:
+ """Install the host's external process-attestation verifier.
+
+ There is deliberately no default verifier. In-process code cannot
+ elevate itself to ``process_attested``.
+ """
+ if verifier is None or not callable(getattr(verifier, "verify", None)):
+ raise ValueError("process attestation verifier must implement verify")
+ with self._lock:
+ self.process_attestation_verifier = verifier
+
+ def record_process_attested_verification(
+ self,
+ result: VerificationResult,
+ attestation: ProcessAttestation | Mapping[str, Any],
+ verification_material: Any = None,
+ ) -> VerificationResult:
+ """Accept a verdict only after external process identity verification."""
+ with self._lock:
+ self._ensure_mutable()
+ if self.process_attestation_verifier is None:
+ raise PermissionError("external process attestation verifier is unavailable")
+ if self._revoked(result.verifier):
+ raise PermissionError(f"verifier is invalidated: {result.verifier}")
+ att = (ProcessAttestation.from_dict(attestation)
+ if isinstance(attestation, Mapping) else attestation)
+ if not isinstance(att, ProcessAttestation):
+ raise TypeError("attestation must be ProcessAttestation or mapping")
+ if not att.signature:
+ raise PermissionError("process attestation signature is required")
+ if not result.scope or not att.scope:
+ raise PermissionError("process attestation scope is required")
+ if att.session_id != self.session_id or result.session_id != self.session_id:
+ raise PermissionError("process attestation session identity mismatch")
+ expected = {
+ "candidate_id": result.candidate_id, "verifier": result.verifier,
+ "verifier_class": result.verifier_class, "passed": result.passed,
+ "candidate_hash": result.candidate_hash,
+ "candidate_graph_hash": result.candidate_graph_hash,
+ "evidence_ids": tuple(result.evidence_ids),
+ "claim_ids": tuple(result.claim_ids),
+ "provenance_ids": tuple(result.provenance_ids),
+ "counterexample_ids": tuple(result.counterexample_ids),
+ "independent": result.independent,
+ "scope": result.scope,
+ }
+ actual = {
+ "candidate_id": att.candidate_id, "verifier": att.verifier,
+ "verifier_class": att.verifier_class, "passed": att.passed,
+ "candidate_hash": att.candidate_hash,
+ "candidate_graph_hash": att.candidate_graph_hash,
+ "evidence_ids": tuple(att.evidence_ids),
+ "claim_ids": tuple(att.claim_ids),
+ "provenance_ids": tuple(att.provenance_ids),
+ "counterexample_ids": tuple(att.counterexample_ids),
+ "independent": att.independent,
+ "scope": att.scope,
+ }
+ candidate = self.candidates.get(result.candidate_id)
+ receipt = self.receipts.get(att.receipt_id)
+ if (expected != actual or candidate is None or receipt is None
+ or receipt.session_id != self.session_id or receipt.success is not True
+ or not receipt.trusted
+ or att.receipt_id not in candidate.receipt_ids
+ or not att.executable_identity or not att.workspace_identity
+ or not att.input_hash or not att.output_hash):
+ raise PermissionError("process attestation is not bound to a successful candidate receipt")
+ receipt_exec = dict(receipt.executable_identity)
+ receipt_workspace = dict(receipt.workspace_identity)
+ if receipt_exec != dict(att.executable_identity):
+ raise PermissionError("process attestation executable identity mismatch")
+ if receipt_workspace != dict(att.workspace_identity):
+ raise PermissionError("process attestation workspace identity mismatch")
+ if not hmac.compare_digest(att.input_hash, receipt.input_hash):
+ raise PermissionError("process attestation input hash mismatch")
+ if not hmac.compare_digest(att.output_hash, receipt.output_hash):
+ raise PermissionError("process attestation output hash mismatch")
+ try:
+ verified = self.process_attestation_verifier.verify(att, verification_material)
+ except Exception as exc:
+ raise PermissionError("external process attestation verification failed") from exc
+ if verified is not True:
+ raise PermissionError("external process attestation was not verified")
+ stamped = replace(
+ result, inspected_candidate=True, trust_boundary="process_attested",
+ metadata={**dict(result.metadata), "process_attestation": att.to_dict(),
+ "independence_status": ("process_attested" if result.independent
+ else "not_claimed")},
+ )
+ stamped = replace(stamped, runtime_attestation=self._attestation(stamped))
+ self._process_attestation_materials[stamped.verification_id] = copy.deepcopy(
+ verification_material
+ )
+ self._record_attested_verification(stamped)
+ return copy.deepcopy(stamped)
+
def invalidate_verifier(self, verifier: str, reason: str) -> None:
"""Revoke a verifier's authority for future finalization decisions."""
if not verifier or not verifier.strip() or not reason or not reason.strip():
raise ValueError("verifier and reason must be non-empty")
with self._lock:
- self.invalidated_verifiers[verifier.strip()] = reason.strip()
- self._event("verifier_invalidated", verifier=verifier.strip(),
- reason=reason.strip())
+ self._ensure_mutable()
+ verifier_name, clean_reason = verifier.strip(), reason.strip()
+ self.invalidated_verifiers[verifier_name] = clean_reason
+ self._revocation_ledger[verifier_name] = clean_reason
+ self._event("verifier_invalidated", verifier=verifier_name,
+ reason=clean_reason)
def successful_capabilities(self, candidate_id: str | None = None) -> set[str]:
"""Return successful capabilities, scoped to a candidate when given."""
@@ -530,93 +1437,828 @@ def missing_requirements(self, candidate_id: str | None = None) -> list[str]:
missing.append(f"required evidence not attached: {kind}")
return missing
- def passed_verifications(self, candidate_id: str) -> list[VerificationResult]:
+ def _raw_passed_verifications(self, candidate_id: str) -> list[VerificationResult]:
return [v for v in self.verifications.values()
if v.candidate_id == candidate_id and v.passed
and v.inspected_candidate
and v.trust_boundary in VerificationPolicy.TRUST_BOUNDARY_RANK
- and v.verifier not in self.invalidated_verifiers]
+ and not self._revoked(v.verifier)]
+
+ def passed_verifications(self, candidate_id: str) -> list[VerificationResult]:
+ results = self._raw_passed_verifications(candidate_id)
+ # A verdict that calls itself independent is retained in the raw map,
+ # but is not an eligible passing verifier until its provenance is
+ # measured and distinct (or process-attested).
+ return [v for v in results if v.verifier_class != "independent"
+ or (v.independent and self._independent_verification_qualified(v))]
+
+ def _verification_producer_keys(self, result: VerificationResult) -> set[str]:
+ keys: set[str] = set()
+ attestation = result.metadata.get("process_attestation")
+ if isinstance(attestation, Mapping):
+ executable = dict(attestation.get("executable_identity", {}))
+ workspace = dict(attestation.get("workspace_identity", {}))
+ if executable or workspace:
+ keys.add(canonical_hash({"executable_identity": executable,
+ "workspace_identity": workspace}))
+ for evidence_id in result.evidence_ids:
+ item = self.evidence.get(evidence_id)
+ receipt = self.receipts.get(item.receipt_id) if item else None
+ if receipt is None:
+ continue
+ executable = dict(receipt.executable_identity)
+ workspace = dict(receipt.workspace_identity)
+ if executable or workspace:
+ keys.add(canonical_hash({"executable_identity": executable,
+ "workspace_identity": workspace}))
+ return keys
+
+ def _independent_verification_qualified(self, result: VerificationResult) -> bool:
+ """Independent is a measured relationship, never a verifier's label."""
+ own = self._verification_producer_keys(result)
+ if not own:
+ return False
+ peer_results = [prior for prior in self._raw_passed_verifications(result.candidate_id)
+ if prior.verification_id != result.verification_id]
+ # If a peer exists but has no measured identity, disjointness cannot be
+ # established; an unmeasured producer is not evidence of independence.
+ if peer_results and any(not self._verification_producer_keys(prior)
+ for prior in peer_results):
+ return False
+ peers = set()
+ for prior in peer_results:
+ peers.update(self._verification_producer_keys(prior))
+ # Every measured producer used by this claim must be disjoint from
+ # every measured producer used by another passing claim. ``own -
+ # peers`` was too weak: a multi-source claim could smuggle in one
+ # shared producer and one unrelated producer.
+ return not own.intersection(peers)
+
+ def _evidence_diversity_keys(self, evidence_ids: Iterable[str]) -> set[str]:
+ """Return distinct *measured* producer identities, not output hashes."""
+ keys: set[str] = set()
+ for evidence_id in evidence_ids:
+ item = self.evidence.get(evidence_id)
+ if item is None:
+ continue
+ receipt = self.receipts.get(item.receipt_id)
+ if receipt is None:
+ continue
+ executable = dict(receipt.executable_identity)
+ workspace = dict(receipt.workspace_identity)
+ # Labels, tool names, output hashes, and caller metadata are not
+ # measurements. An absent measured identity contributes nothing.
+ if executable or workspace:
+ keys.add(canonical_hash({"executable_identity": executable,
+ "workspace_identity": workspace}))
+ return keys
+
+ def _claim_graph(self, candidate_id: str) -> ClaimGraph:
+ candidate = self.candidates.get(candidate_id)
+ if candidate is None:
+ raise ValueError(f"unknown candidate: {candidate_id}")
+ return ClaimGraph.from_task(self.task, candidate)
+
+ def _adjudicate_candidate(self, candidate_id: str) -> Adjudication:
+ """Run the one strict claim/policy adjudicator for a candidate."""
+ candidate = self.candidates.get(candidate_id)
+ if candidate is None:
+ raise ValueError(f"unknown candidate: {candidate_id}")
+ graph = self._claim_graph(candidate_id)
+ results = tuple(v for v in self.verifications.values()
+ if v.candidate_id == candidate_id and not self._revoked(v.verifier))
+ # Planner output is advisory but auditable: an uncovered plan claim is
+ # never allowed to disappear from the adjudicator's final decision.
+ plan = VerifierPlanner().plan(graph, tuple(
+ self._executed_verifiers.get(v.verifier, _VerifierResultAdapter(v))
+ for v in results
+ ))
+ adjudicator = ThreeValuedAdjudicator(policy=self.task.verification_policy)
+ adjudication = adjudicator.adjudicate(
+ graph, results,
+ policy=self.task.verification_policy,
+ evidence_registry=self.evidence,
+ receipt_registry=self.receipts,
+ candidate_evidence_ids=candidate.evidence_ids,
+ candidate_receipt_ids=candidate.receipt_ids,
+ )
+ if plan.uncovered_claim_ids:
+ # A planner cannot certify coverage; this diagnostic makes the
+ # uncovered set explicit even when a malformed result omitted IDs.
+ reasons = tuple(dict.fromkeys(adjudication.reasons + (
+ "verifier plan leaves claims uncovered: " + ", ".join(plan.uncovered_claim_ids),)))
+ adjudication = replace(adjudication, finalizable=False,
+ status=(VerificationStatus.FAIL if adjudication.status is VerificationStatus.FAIL
+ else VerificationStatus.UNKNOWN),
+ blocking_claim_ids=tuple(dict.fromkeys(
+ adjudication.blocking_claim_ids + plan.uncovered_claim_ids)),
+ reasons=reasons)
+ self._last_adjudication = adjudication
+ return adjudication
def verification_requirements(self, candidate_id: str) -> list[str]:
- """Return missing policy requirements for the exact candidate."""
- passed = self.passed_verifications(candidate_id)
- policy = self.task.verification_policy
- classes = {v.verifier_class for v in passed}
- missing = [
- f"required verifier class not passed: {kind}"
- for kind in policy.required_verifier_classes
- if kind not in classes
- ]
- if len(passed) < policy.minimum_passing_verifiers:
- missing.append(
- f"requires {policy.minimum_passing_verifiers} passing verifiers "
- f"(currently {len(passed)})"
- )
- if policy.require_independent and not any(v.independent for v in passed):
- missing.append("requires a passing independently registered verifier")
- boundary_rank = VerificationPolicy.TRUST_BOUNDARY_RANK[policy.minimum_trust_boundary]
- if not any(VerificationPolicy.TRUST_BOUNDARY_RANK[v.trust_boundary] >= boundary_rank
- for v in passed):
- missing.append(
- "requires a passing verifier at trust boundary "
- + policy.minimum_trust_boundary
+ """Return requirements reported by the same adjudicator used to finalize."""
+ if self.compatibility_mode:
+ # Compatibility changes only claim-coverage semantics; ordinary
+ # receipt, diversity, class, quorum, independence, and boundary
+ # policy gates remain fail-closed.
+ passed = self.passed_verifications(candidate_id)
+ policy = self.task.verification_policy
+ classes = {v.verifier_class for v in passed}
+ missing = [f"required verifier class not passed: {kind}" for kind in policy.required_verifier_classes if kind not in classes]
+ if len(passed) < policy.minimum_passing_verifiers:
+ missing.append(f"requires {policy.minimum_passing_verifiers} passing verifiers (currently {len(passed)})")
+ if policy.require_independent and not any(v.independent and self._independent_verification_qualified(v) for v in passed):
+ missing.append("requires a passing independently measured verifier provenance")
+ if policy.require_evidence_diversity:
+ producers = self._evidence_diversity_keys(eid for v in passed for eid in v.evidence_ids)
+ if len(producers) < policy.minimum_evidence_sources:
+ missing.append("requires independent measured evidence producers from at least " + str(policy.minimum_evidence_sources) + " sources")
+ boundary_rank = VerificationPolicy.TRUST_BOUNDARY_RANK[policy.minimum_trust_boundary]
+ if not any(VerificationPolicy.TRUST_BOUNDARY_RANK[v.trust_boundary] >= boundary_rank for v in passed):
+ missing.append("requires a passing verifier at trust boundary " + policy.minimum_trust_boundary)
+ return missing
+ adjudication = self._adjudicate_candidate(candidate_id)
+ return list(adjudication.reasons) if not adjudication.finalizable else []
+
+ def _revalidate_for_finalization(self, candidate_id: str) -> None:
+ """Re-check all mutable/hash-bound inputs while the run lock is held."""
+ candidate = self.candidates.get(candidate_id)
+ if candidate is None or candidate.candidate_id != candidate_id:
+ raise PermissionError("candidate identity changed")
+ if candidate.session_id != self.session_id:
+ raise PermissionError("candidate session identity changed")
+ committed = self._artifact_commits.get(candidate_id)
+ if committed is None or not hmac.compare_digest(committed[0], canonical_hash(candidate.artifact)):
+ raise PermissionError("candidate artifact changed after registration")
+ if not hmac.compare_digest(committed[1], self._dependency_hash(candidate)):
+ raise PermissionError("candidate dependency graph changed after registration")
+ if any(event.get("type") in {"run_finalized", "finalization_rejected"}
+ for event in self.events):
+ raise PermissionError("finalization history contains an unexpected prior terminal decision")
+ self._reconcile_event_state()
+ for receipt in self.receipts.values():
+ if receipt.session_id != self.session_id:
+ raise PermissionError("receipt session identity changed")
+ try:
+ canonical_hash(receipt.to_dict())
+ except (TypeError, ValueError) as exc:
+ raise PermissionError("receipt contains non-standard JSON metadata") from exc
+ if canonical_hash(receipt.output) != receipt.output_hash:
+ raise PermissionError("receipt output hash no longer matches output")
+ for evidence in self.evidence.values():
+ try:
+ canonical_hash(evidence.to_dict())
+ except (TypeError, ValueError) as exc:
+ raise PermissionError("evidence contains non-standard JSON metadata") from exc
+ self._validate_evidence(evidence)
+ try:
+ canonical_hash(candidate.to_dict())
+ except (TypeError, ValueError) as exc:
+ raise PermissionError("candidate contains non-standard JSON metadata") from exc
+ for result in self.verifications.values():
+ if result.candidate_id == candidate_id:
+ self._validate_attested_verification(result)
+ if result.trust_boundary == "process_attested":
+ if self.process_attestation_verifier is None:
+ raise PermissionError("external process attestation verifier is unavailable")
+ raw_attestation = result.metadata.get("process_attestation")
+ material = self._process_attestation_materials.get(result.verification_id)
+ if not raw_attestation or result.verification_id not in self._process_attestation_materials:
+ raise PermissionError("process attestation material is unavailable at finalization")
+ attestation = ProcessAttestation.from_dict(raw_attestation)
+ try:
+ verified_again = self.process_attestation_verifier.verify(attestation, material)
+ except Exception as exc:
+ raise PermissionError("process attestation could not be revalidated") from exc
+ if verified_again is not True:
+ raise PermissionError("process attestation could not be revalidated")
+ # A started System 3 loop cannot be finalized half-way through.
+ self._validate_system3_loop(candidate_id)
+ # Validate all non-temporal telemetry before refreshing the event-bound
+ # temporal projection. This catches edits before the legitimate refresh.
+ self._validate_system3_metadata(candidate)
+ # Validate every auxiliary projection before refreshing the derived
+ # event-grounded temporal map. This prevents a deleted map entry from
+ # being silently recreated during finalization.
+ self._validate_auxiliary_telemetry()
+ # Temporal claims are derived data. Recompute them from the current
+ # event chain and reject stale/fabricated candidate metadata.
+ expected_temporal = self._event_temporal_report()
+ if expected_temporal.get("is_satisfied") is not True:
+ raise PermissionError("event-grounded temporal safety projection is unsatisfied")
+ stored_temporal = candidate.metadata.get("system3_kripke")
+ expected_hashes = expected_temporal["event_hashes"]
+ stored_hashes = stored_temporal.get("event_hashes") if isinstance(stored_temporal, Mapping) else None
+ # A verifier may append events after candidate registration. In that
+ # case the old result must be an authentic prefix and is recomputed;
+ # arbitrary/fabricated event claims are not accepted.
+ if (not isinstance(stored_hashes, list) or
+ expected_hashes[:len(stored_hashes)] != stored_hashes):
+ raise PermissionError("event-grounded temporal result is stale or fabricated")
+ candidate.metadata["system3_kripke"] = expected_temporal
+ self.system3_kripke_invariants[candidate.candidate_id] = expected_temporal
+ self._system3_commits[candidate.candidate_id] = self._system3_metadata_hash(candidate)
+ self._validate_system3_metadata(candidate)
+ self._validate_auxiliary_telemetry()
+
+ def _validate_system3_record_candidate(self, candidate_id: str) -> Candidate:
+ candidate = self.candidates.get(candidate_id)
+ if candidate is None:
+ raise ValueError(f"unknown candidate: {candidate_id}")
+ if candidate.session_id != self.session_id:
+ raise PermissionError("System 3 candidate belongs to a different run")
+ return candidate
+
+ def observe_system3(self, candidate_id: str, observation: Any,
+ observation_id: str | None = None) -> dict[str, Any]:
+ """Record a non-empty environment observation before prediction."""
+ with self._lock:
+ self._ensure_mutable(); self._validate_system3_record_candidate(candidate_id)
+ if (candidate_id in self.system3_observations
+ or any(p.candidate_id == candidate_id for p in self.predictions.values())
+ or any(a.get("candidate_id") == candidate_id for a in self.system3_actions.values())):
+ self._validate_system3_record_integrity(candidate_id)
+ if observation is None or (isinstance(observation, str) and not observation.strip()):
+ raise ValueError("System 3 observation must be non-empty")
+ if isinstance(observation, (Mapping, list, tuple, set)) and not observation:
+ raise ValueError("System 3 observation must be non-empty")
+ obs_hash = canonical_hash(observation)
+ if candidate_id in self.system3_observations:
+ prior = self.system3_observations[candidate_id]
+ if prior.get("observation_hash") == obs_hash:
+ raise ValueError("duplicate System 3 observation")
+ raise ValueError("one observation is required per System 3 action")
+ record = {"observation_id": observation_id or f"{candidate_id}:observation:1",
+ "run_id": self.session_id, "candidate_id": candidate_id,
+ "observation": copy.deepcopy(observation), "observation_hash": obs_hash,
+ "observed_at": utc_now()}
+ record["record_hash"] = self._record_hash(record, "record_hash")
+ self.system3_observations[candidate_id] = record
+ self._event("system3_observation", candidate_id=candidate_id,
+ observation_hash=obs_hash, observation_id=record["observation_id"],
+ record_hash=record["record_hash"])
+ self._commit_system3_loop(candidate_id)
+ return copy.deepcopy(record)
+
+ def predict_system3(self, candidate_id: str, action: str, predicted_outcome: Any,
+ confidence: float, falsification_condition: str,
+ *, prediction_id: str | None = None, policy_id: str = "",
+ observation: Any = None) -> Prediction:
+ """Create the falsifiable hypothesis required before acting."""
+ with self._lock:
+ self._ensure_mutable(); self._validate_system3_record_candidate(candidate_id)
+ if (candidate_id in self.system3_observations
+ or any(p.candidate_id == candidate_id for p in self.predictions.values())
+ or any(a.get("candidate_id") == candidate_id for a in self.system3_actions.values())):
+ self._validate_system3_record_integrity(candidate_id)
+ if observation is not None:
+ if candidate_id in self.system3_observations:
+ if canonical_hash(observation) != self.system3_observations[candidate_id]["observation_hash"]:
+ raise ValueError("prediction observation differs from recorded observation")
+ else:
+ self.observe_system3(candidate_id, observation)
+ if candidate_id not in self.system3_observations:
+ raise PermissionError("prediction requires an observation first")
+ pid = prediction_id or f"{candidate_id}:prediction:{len(self.predictions) + 1}"
+ prediction = Prediction(pid, self.session_id, candidate_id, action,
+ self.system3_observations[candidate_id]["observation"],
+ predicted_outcome, confidence, falsification_condition,
+ policy_id=policy_id)
+ if pid in self.predictions:
+ raise ValueError("duplicate System 3 prediction")
+ semantic = canonical_hash({"run_id": prediction.run_id, "candidate_id": candidate_id,
+ "action": prediction.action,
+ "observation_hash": prediction.observation_hash,
+ "predicted_outcome_hash": prediction.predicted_outcome_hash,
+ "confidence": prediction.confidence,
+ "falsification_condition": prediction.falsification_condition})
+ if any(canonical_hash({"run_id": p.run_id, "candidate_id": p.candidate_id,
+ "action": p.action, "observation_hash": p.observation_hash,
+ "predicted_outcome_hash": p.predicted_outcome_hash,
+ "confidence": p.confidence,
+ "falsification_condition": p.falsification_condition}) == semantic
+ for p in self.predictions.values()):
+ raise ValueError("duplicate System 3 prediction")
+ self.predictions[pid] = prediction
+ self._event("system3_prediction", prediction_id=pid, candidate_id=candidate_id,
+ action=prediction.action, observation_hash=prediction.observation_hash,
+ record_hash=prediction.record_hash)
+ self._commit_system3_loop(candidate_id)
+ return copy.deepcopy(prediction)
+
+ def act_system3(self, candidate_id: str, prediction_id: str,
+ *, action: str | None = None) -> dict[str, Any]:
+ """Commit an action only from an existing prediction."""
+ with self._lock:
+ self._ensure_mutable(); self._validate_system3_record_candidate(candidate_id)
+ self._validate_system3_record_integrity(candidate_id)
+ prediction = self.predictions.get(prediction_id)
+ if prediction is None or prediction.candidate_id != candidate_id or prediction.run_id != self.session_id:
+ raise PermissionError("action requires a prediction bound to this candidate and run")
+ chosen = action or prediction.action
+ if chosen != prediction.action:
+ raise PermissionError("action does not match prediction")
+ if any(item.get("prediction_id") == prediction_id for item in self.system3_actions.values()):
+ raise ValueError("prediction has already been acted on")
+ action_id = f"{candidate_id}:action:{len(self.system3_actions) + 1}"
+ record = {"action_id": action_id, "run_id": self.session_id,
+ "candidate_id": candidate_id, "prediction_id": prediction_id,
+ "action": chosen, "acted_at": utc_now(),
+ "prediction_hash": prediction.record_hash}
+ record["action_hash"] = self._record_hash(record, "action_hash")
+ self.system3_actions[action_id] = record
+ self._event("system3_action", action_id=action_id, candidate_id=candidate_id,
+ prediction_id=prediction_id, action=chosen,
+ prediction_hash=prediction.record_hash, action_hash=record["action_hash"])
+ self._commit_system3_loop(candidate_id)
+ return copy.deepcopy(record)
+
+ def record_system3_outcome(self, candidate_id: str, prediction_id: str,
+ receipt_id: str, observed_outcome: Any = None,
+ *, outcome_id: str | None = None,
+ observed_at: str | None = None,
+ _legacy_compatibility: bool = False) -> Outcome:
+ """Bind an actual ToolReceipt to an acted prediction."""
+ with self._lock:
+ self._ensure_mutable(); candidate = self._validate_system3_record_candidate(candidate_id)
+ legacy_allowed = (_legacy_compatibility
+ and not bool(self.task.metadata.get("require_system3_loop", False)))
+ self._validate_system3_record_integrity(candidate_id,
+ allow_legacy_untrusted=legacy_allowed)
+ prediction = self.predictions.get(prediction_id)
+ receipt = self.receipts.get(receipt_id)
+ if prediction is None or prediction.candidate_id != candidate_id or prediction.run_id != self.session_id:
+ raise PermissionError("outcome prediction binding is invalid")
+ if receipt is None or receipt.session_id != self.session_id:
+ raise PermissionError("outcome requires a receipt from this run")
+ if receipt.success is not True:
+ raise PermissionError("outcome requires a successful tool receipt")
+ if not receipt.trusted and not legacy_allowed:
+ raise PermissionError("outcome requires a host/external-bound receipt")
+ if (bool(self.task.metadata.get("require_system3_loop", False))
+ and not self._system3_receipt_authenticated(receipt)):
+ raise PermissionError("strict System 3 outcome requires an authenticated receipt boundary")
+ if receipt_id not in candidate.receipt_ids:
+ raise PermissionError("outcome receipt is not attached to the candidate")
+ if not any(item.get("prediction_id") == prediction_id and item.get("candidate_id") == candidate_id
+ for item in self.system3_actions.values()):
+ raise PermissionError("outcome requires the predicted action to be committed first")
+ actual = copy.deepcopy(receipt.output if observed_outcome is None else observed_outcome)
+ if canonical_hash(actual) != receipt.output_hash:
+ raise PermissionError("outcome is fabricated or not bound to receipt output")
+ if prediction.action != receipt.capability and receipt.metadata.get("action") != prediction.action:
+ raise PermissionError("receipt capability does not match predicted action")
+ supplied_observed_at = observed_at or utc_now()
+ if _parse_timestamp(supplied_observed_at, "observed_at") < _parse_timestamp(prediction.created_at, "created_at"):
+ raise ValueError("outcome timestamp cannot precede its prediction")
+ oid = outcome_id or f"{candidate_id}:outcome:{len(self.outcomes) + 1}"
+ outcome = Outcome(oid, self.session_id, candidate_id, prediction_id,
+ prediction.action, receipt_id, actual,
+ observed_at=supplied_observed_at,
+ receipt_output_hash=receipt.output_hash)
+ if oid in self.outcomes:
+ raise ValueError("duplicate System 3 outcome")
+ self.outcomes[oid] = outcome
+ self._event("system3_outcome", outcome_id=oid, candidate_id=candidate_id,
+ prediction_id=prediction_id, receipt_id=receipt_id,
+ receipt_output_hash=outcome.receipt_output_hash,
+ record_hash=outcome.record_hash)
+ self._commit_system3_loop(candidate_id)
+ return copy.deepcopy(outcome)
+
+ def update_system3(self, candidate_id: str, outcome_id: str | None = None) -> dict[str, Any]:
+ """Update beliefs and policy from a receipt-backed outcome only."""
+ with self._lock:
+ self._ensure_mutable(); candidate = self._validate_system3_record_candidate(candidate_id)
+ legacy_allowed = (
+ not bool(self.task.metadata.get("require_system3_loop", False))
+ and (getattr(self, "_legacy_cycle_in_progress", False)
+ or (isinstance(candidate.metadata.get("system3_loop"), Mapping)
+ and candidate.metadata["system3_loop"].get("_legacy_compatibility") is True))
)
- return missing
+ if legacy_allowed and "system3_loop" not in candidate.metadata:
+ # Stage the explicit compatibility marker before integrity
+ # validation; it is committed and then included in the final
+ # loop metadata below.
+ candidate.metadata["system3_loop"] = {"_legacy_compatibility": True}
+ self._system3_commits[candidate_id] = self._system3_metadata_hash(candidate)
+ self._validate_system3_record_integrity(candidate_id,
+ allow_legacy_untrusted=legacy_allowed)
+ eligible = [o for o in self.outcomes.values() if o.candidate_id == candidate_id]
+ if outcome_id:
+ outcome = self.outcomes.get(outcome_id)
+ if outcome not in eligible:
+ raise PermissionError("update outcome is not bound to this candidate")
+ elif eligible:
+ outcome = eligible[-1]
+ else:
+ raise PermissionError("update requires an actual receipt-bound outcome")
+ if outcome.outcome_id in self.system3_updates:
+ raise ValueError("outcome has already been used for update")
+ prediction = self.predictions.get(outcome.prediction_id)
+ if prediction is None:
+ raise PermissionError("update prediction is missing")
+ error = prediction_error(prediction.predicted_outcome, outcome.observed_outcome)
+ beliefs = revise_belief(prediction.confidence, error)
+ revision = policy_revision(prediction.action, error,
+ sum(1 for x in self.system3_policy_revisions.values()
+ if x.get("candidate_id") == candidate_id) + 1)
+ revision["candidate_id"] = candidate_id; revision["prediction_id"] = prediction.prediction_id
+ update = {"update_id": f"{candidate_id}:update:{len(self.system3_updates) + 1}",
+ "run_id": self.session_id, "candidate_id": candidate_id,
+ "prediction_id": prediction.prediction_id, "outcome_id": outcome.outcome_id,
+ "prediction_error": error, "belief_revision": beliefs,
+ "policy_revision": revision, "updated_at": utc_now()}
+ update["update_hash"] = self._record_hash(update, "update_hash")
+ self.system3_updates[outcome.outcome_id] = update
+ self.system3_beliefs[candidate_id] = copy.deepcopy(beliefs)
+ self.system3_policy_revisions[candidate_id] = copy.deepcopy(revision)
+ candidate = self.candidates[candidate_id]
+ candidate.metadata["system3_loop"] = {"observation": copy.deepcopy(self.system3_observations[candidate_id]),
+ # This marker records that only the legacy convenience wrapper
+ # permitted an untrusted local receipt. It is never accepted
+ # for strict System 3 tasks.
+ **({"_legacy_compatibility": True} if getattr(self, "_legacy_cycle_in_progress", False) else {}),
+ "prediction_ids": [p.prediction_id for p in self.predictions.values() if p.candidate_id == candidate_id],
+ "action_ids": [a["action_id"] for a in self.system3_actions.values() if a["candidate_id"] == candidate_id],
+ "outcome_ids": [o.outcome_id for o in eligible], "updates": [copy.deepcopy(update)],
+ "belief": copy.deepcopy(beliefs), "policy_revision": copy.deepcopy(revision)}
+ self._event("system3_update", update_id=update["update_id"], candidate_id=candidate_id,
+ outcome_id=outcome.outcome_id, prediction_id=prediction.prediction_id,
+ prediction_error=error, update_hash=update["update_hash"])
+ self._commit_system3_loop(candidate_id)
+ self._system3_commits[candidate_id] = self._system3_metadata_hash(candidate)
+ return copy.deepcopy(update)
+
+ # Concise aliases are useful to adapters while keeping the explicit names
+ # discoverable in the public runtime API.
+ observe = observe_system3
+ predict = predict_system3
+ act = act_system3
+ record_observation = observe_system3
+ record_prediction = predict_system3
+ record_action = act_system3
+ record_outcome = record_system3_outcome
+ update = update_system3
+ record_update = update_system3
+
+ @staticmethod
+ def _record_hash(record: Mapping[str, Any], field: str, *, protocol_style: bool = False) -> str:
+ payload = dict(record)
+ if protocol_style:
+ payload[field] = ""
+ else:
+ payload.pop(field, None)
+ return canonical_hash(payload)
+
+ def _system3_receipt_authenticated(self, receipt: ToolReceipt) -> bool:
+ """Return whether a receipt crossed an authenticated host boundary.
+
+ ``ToolReceipt.trust_boundary`` is descriptive metadata and can be
+ self-minted by application code. Strict System 3 therefore additionally
+ requires a receipt ID authenticated by ``record_receipt``'s explicit
+ boundary verifier. Restored checkpoints intentionally lose this
+ process-local authorization until the host re-supplies it.
+ """
+ return receipt.receipt_id in self._authenticated_receipt_ids
+
+ def _validate_system3_record_integrity(self, candidate_id: str, *, complete: bool = False,
+ allow_legacy_untrusted: bool = False) -> None:
+ """Revalidate every live coding-loop record and all semantic bindings.
+
+ The public maps intentionally remain ordinary Python containers for
+ compatibility. Consequently every transition rechecks their hashes,
+ identities, and cross-links instead of trusting a caller-held reference.
+ """
+ candidate = self._validate_system3_record_candidate(candidate_id)
+ self._validate_system3_metadata(candidate)
+ obs = self.system3_observations.get(candidate_id)
+ if obs is not None:
+ if not isinstance(obs, Mapping) or obs.get("run_id") != self.session_id or obs.get("candidate_id") != candidate_id:
+ raise PermissionError("System 3 observation binding is invalid")
+ if obs.get("observation_hash") != canonical_hash(obs.get("observation")):
+ raise PermissionError("System 3 observation hash is stale")
+ if obs.get("record_hash") != self._record_hash(obs, "record_hash"):
+ raise PermissionError("System 3 observation record was modified")
+ _parse_timestamp(obs.get("observed_at"), "observed_at")
+
+ predictions = [p for p in self.predictions.values() if p.candidate_id == candidate_id]
+ for prediction in predictions:
+ if prediction.run_id != self.session_id:
+ raise PermissionError("System 3 prediction session binding is invalid")
+ # Prediction's frozen constructor checks all value hashes and its
+ # record_hash; reconstructing is the strongest check after restore.
+ try:
+ Prediction.from_dict(prediction.to_dict())
+ except Exception as exc:
+ raise PermissionError("System 3 prediction record was modified") from exc
+ if obs is None or prediction.observation_hash != obs.get("observation_hash"):
+ raise PermissionError("System 3 prediction is not bound to the observation")
+
+ actions = [a for a in self.system3_actions.values() if a.get("candidate_id") == candidate_id]
+ action_by_prediction: dict[str, Mapping[str, Any]] = {}
+ for action in actions:
+ if (action.get("run_id") != self.session_id or action.get("candidate_id") != candidate_id
+ or action.get("action_id") in (None, "")
+ or action.get("prediction_id") in action_by_prediction):
+ raise PermissionError("System 3 action binding is invalid")
+ prediction = self.predictions.get(action.get("prediction_id"))
+ if prediction is None or prediction.candidate_id != candidate_id:
+ raise PermissionError("System 3 action references an invalid prediction")
+ if action.get("action") != prediction.action or action.get("prediction_hash") != prediction.record_hash:
+ raise PermissionError("System 3 action is not semantically bound to its prediction")
+ if action.get("action_hash") != self._record_hash(action, "action_hash"):
+ raise PermissionError("System 3 action record was modified")
+ action_by_prediction[prediction.prediction_id] = action
+
+ outcomes = [o for o in self.outcomes.values() if o.candidate_id == candidate_id]
+ outcome_by_prediction: dict[str, Outcome] = {}
+ for outcome in outcomes:
+ if outcome.run_id != self.session_id:
+ raise PermissionError("System 3 outcome session binding is invalid")
+ prediction = self.predictions.get(outcome.prediction_id)
+ receipt = self.receipts.get(outcome.receipt_id)
+ if (prediction is None or prediction.candidate_id != candidate_id
+ or outcome.action != prediction.action
+ or outcome.prediction_id not in action_by_prediction
+ or receipt is None or receipt.session_id != self.session_id
+ or receipt.success is not True
+ or (not receipt.trusted and not (
+ allow_legacy_untrusted
+ or (not bool(self.task.metadata.get("require_system3_loop", False))
+ and isinstance(candidate.metadata.get("system3_loop"), Mapping)
+ and candidate.metadata["system3_loop"].get("_legacy_compatibility") is True)))
+ or (bool(self.task.metadata.get("require_system3_loop", False))
+ and not self._system3_receipt_authenticated(receipt))
+ or outcome.receipt_id not in candidate.receipt_ids
+ or outcome.receipt_output_hash != receipt.output_hash
+ or outcome.outcome_hash != canonical_hash(outcome.observed_outcome)
+ or outcome.outcome_hash != receipt.output_hash
+ or outcome.record_hash != self._record_hash(outcome.to_dict(), "record_hash", protocol_style=True)):
+ raise PermissionError("System 3 outcome is not bound to a successful current receipt")
+ if _parse_timestamp(outcome.observed_at, "observed_at") < _parse_timestamp(prediction.created_at, "created_at"):
+ raise PermissionError("System 3 outcome precedes its prediction")
+ if prediction.prediction_id in outcome_by_prediction:
+ raise PermissionError("System 3 prediction has multiple outcomes")
+ outcome_by_prediction[prediction.prediction_id] = outcome
+
+ updates = [u for u in self.system3_updates.values() if u.get("candidate_id") == candidate_id]
+ seen_update_ids: set[str] = set()
+ for update_key, update in ((key, value) for key, value in self.system3_updates.items()
+ if value.get("candidate_id") == candidate_id):
+ if (update_key != update.get("outcome_id")
+ or update.get("run_id") != self.session_id or update.get("update_id") in seen_update_ids
+ or update.get("outcome_id") not in self.outcomes):
+ raise PermissionError("System 3 update binding is invalid")
+ outcome = self.outcomes.get(update.get("outcome_id"))
+ prediction = self.predictions.get(update.get("prediction_id")) if outcome else None
+ if (outcome is None or prediction is None or outcome.candidate_id != candidate_id
+ or update.get("prediction_id") != outcome.prediction_id
+ or update.get("prediction_id") != prediction.prediction_id):
+ raise PermissionError("System 3 update is not bound to its outcome and prediction")
+ error = prediction_error(prediction.predicted_outcome, outcome.observed_outcome)
+ expected_belief = revise_belief(prediction.confidence, error)
+ expected_index = next((i for i, item in enumerate(updates, 1)
+ if item.get("update_id") == update.get("update_id")), 0)
+ if expected_index < 1:
+ raise PermissionError("System 3 update ordering is invalid")
+ expected_policy = policy_revision(prediction.action, error, expected_index)
+ if (update.get("prediction_error") != error
+ or update.get("belief_revision") != expected_belief
+ or update.get("policy_revision") != {**expected_policy, "candidate_id": candidate_id,
+ "prediction_id": prediction.prediction_id}
+ or update.get("update_hash") != self._record_hash(update, "update_hash")):
+ raise PermissionError("System 3 update telemetry was modified")
+ seen_update_ids.add(update.get("update_id"))
+ if updates:
+ latest = updates[-1]
+ if (self.system3_beliefs.get(candidate_id) != latest.get("belief_revision")
+ or self.system3_policy_revisions.get(candidate_id) != latest.get("policy_revision")):
+ raise PermissionError("System 3 belief or policy revision is stale")
+ elif candidate_id in self.system3_beliefs or candidate_id in self.system3_policy_revisions:
+ raise PermissionError("System 3 belief or policy revision has no update")
+
+ # A started strict loop must have exactly one complete chain per
+ # prediction. Partial validation is used before each transition.
+ if complete:
+ if not predictions or len(actions) != len(predictions) or len(outcomes) != len(predictions) or len(updates) != len(outcomes):
+ raise PermissionError("System 3 loop requires prediction, action, actual outcome, and update")
+ if obs is None or any(p.prediction_id not in action_by_prediction for p in predictions):
+ raise PermissionError("System 3 prediction has no committed action")
+ if any(p.prediction_id not in outcome_by_prediction for p in predictions):
+ raise PermissionError("System 3 action has no actual outcome")
+ if any(o.outcome_id not in self.system3_updates for o in outcomes):
+ raise PermissionError("System 3 outcome has no update")
+ loop_metadata = candidate.metadata.get("system3_loop")
+ expected_metadata = {
+ "observation": copy.deepcopy(obs),
+ **({"_legacy_compatibility": True}
+ if (not bool(self.task.metadata.get("require_system3_loop", False))
+ and any(not self.receipts[o.receipt_id].trusted for o in outcomes)) else {}),
+ "prediction_ids": [p.prediction_id for p in predictions],
+ "action_ids": [a["action_id"] for a in actions],
+ "outcome_ids": [o.outcome_id for o in outcomes],
+ "updates": [copy.deepcopy(u) for u in updates],
+ "belief": copy.deepcopy(self.system3_beliefs.get(candidate_id)),
+ "policy_revision": copy.deepcopy(self.system3_policy_revisions.get(candidate_id)),
+ }
+ if not isinstance(loop_metadata, Mapping) or canonical_hash(loop_metadata) != canonical_hash(expected_metadata):
+ raise PermissionError("System 3 candidate loop telemetry is stale")
+
+ def _commit_system3_loop(self, candidate_id: str) -> None:
+ self._system3_loop_commits[candidate_id] = self._system3_loop_hash(candidate_id)
+
+ def _system3_loop_hash(self, candidate_id: str) -> str:
+ return canonical_hash({
+ "observations": {k: v for k, v in self.system3_observations.items()
+ if v.get("candidate_id") == candidate_id},
+ "predictions": {k: v.to_dict() for k, v in self.predictions.items()
+ if v.candidate_id == candidate_id},
+ "actions": {k: v for k, v in self.system3_actions.items()
+ if v.get("candidate_id") == candidate_id},
+ "outcomes": {k: v.to_dict() for k, v in self.outcomes.items()
+ if v.candidate_id == candidate_id},
+ "updates": {k: v for k, v in self.system3_updates.items()
+ if v.get("candidate_id") == candidate_id},
+ "beliefs": self.system3_beliefs.get(candidate_id),
+ "policy": self.system3_policy_revisions.get(candidate_id),
+ })
+
+ def _validate_system3_loop(self, candidate_id: str) -> None:
+ """Require and fully revalidate a complete receipt-backed loop."""
+ has_records = (candidate_id in self.system3_observations
+ or any(p.candidate_id == candidate_id for p in self.predictions.values())
+ or any(a.get("candidate_id") == candidate_id for a in self.system3_actions.values())
+ or any(o.candidate_id == candidate_id for o in self.outcomes.values())
+ or any(u.get("candidate_id") == candidate_id for u in self.system3_updates.values()))
+ if not has_records:
+ if bool(self.task.metadata.get("require_system3_loop", False)):
+ raise PermissionError("System 3 loop is required before finalization")
+ return
+ self._validate_system3_record_integrity(candidate_id, complete=True)
+ if candidate_id not in self._system3_loop_commits or not hmac.compare_digest(
+ self._system3_loop_commits[candidate_id], self._system3_loop_hash(candidate_id)):
+ raise PermissionError("System 3 loop telemetry commitment is missing or stale")
+
+ def _validate_state_invariants(self) -> None:
+ """Validate lifecycle consistency before accepting or restoring state."""
+ self.validate_event_history()
+ self._reconcile_event_state()
+ self._validate_auxiliary_telemetry()
+ finalized_events = [e for e in self.events if e.get("type") == "run_finalized"]
+ if self.state is RunState.FINALIZED:
+ if not self._checkpoint_trusted:
+ raise PermissionError("finalized state is not trusted without an external checkpoint signature")
+ if not self.final_candidate_id or self.final_candidate_id not in self.candidates:
+ raise ValueError("finalized run has no valid final candidate")
+ if len(finalized_events) != 1 or finalized_events[0].get("candidate_id") != self.final_candidate_id:
+ raise ValueError("finalized run has an impossible finalization history")
+ if self.missing_requirements(self.final_candidate_id) or self.verification_requirements(self.final_candidate_id):
+ raise ValueError("finalized run does not satisfy its declared acceptance gates")
+ self._validate_system3_loop(self.final_candidate_id)
+ expected_temporal = self._event_temporal_report()
+ stored_temporal = self.candidates[self.final_candidate_id].metadata.get("system3_kripke")
+ if not isinstance(stored_temporal, Mapping) or canonical_hash(stored_temporal) != canonical_hash(expected_temporal):
+ raise ValueError("finalized run has stale event-grounded temporal state")
+ elif self.state is RunState.REJECTED:
+ if self.final_candidate_id is not None:
+ raise ValueError("rejected run cannot retain a final candidate")
+ if finalized_events:
+ raise ValueError("rejected run cannot contain a finalization event")
+ elif finalized_events:
+ raise ValueError("non-finalized run contains a finalization event")
def finalize(self, candidate_id: str) -> Candidate:
- if candidate_id not in self.candidates:
- raise ValueError(f"unknown candidate: {candidate_id}")
- missing = self.missing_requirements(candidate_id) + self.verification_requirements(candidate_id)
- if missing:
- self._generate_triz_repair_recommendation(candidate_id, missing)
- self.state = RunState.REJECTED
- self._event("finalization_rejected", candidate_id=candidate_id, missing=missing)
- raise PermissionError("finalization rejected: " + "; ".join(missing))
- self.final_candidate_id = candidate_id
- self.state = RunState.FINALIZED
- self._event("run_finalized", candidate_id=candidate_id)
- return self.candidates[candidate_id]
+ with self._lock:
+ self._ensure_mutable()
+ if self._restored_from_checkpoint and not self._checkpoint_trusted:
+ raise PermissionError(
+ "restored active runs remain untrusted; supply an authenticated checkpoint"
+ )
+ if not hmac.compare_digest(self._task_commitment, canonical_hash(self.task.to_dict())):
+ raise PermissionError("task contract changed after run creation")
+ if candidate_id not in self.candidates:
+ raise ValueError(f"unknown candidate: {candidate_id}")
+ try:
+ self._revalidate_for_finalization(candidate_id)
+ except (ValueError, PermissionError) as exc:
+ self._event("finalization_rejected", candidate_id=candidate_id,
+ missing=[str(exc)])
+ self.state = RunState.REJECTED
+ raise PermissionError("finalization rejected: " + str(exc)) from exc
+ missing = self.missing_requirements(candidate_id) + self.verification_requirements(candidate_id)
+ if missing:
+ self._generate_triz_repair_recommendation(candidate_id, missing)
+ self._event("finalization_rejected", candidate_id=candidate_id, missing=missing)
+ self.state = RunState.REJECTED
+ raise PermissionError("finalization rejected: " + "; ".join(missing))
+ self.final_candidate_id = candidate_id
+ # Append the terminal event before crossing the one-way boundary.
+ self._event("run_finalized", candidate_id=candidate_id)
+ final_temporal = self._event_temporal_report()
+ if final_temporal.get("is_satisfied") is not True:
+ raise PermissionError("event-grounded temporal safety projection is unsatisfied")
+ self.candidates[candidate_id].metadata["system3_kripke"] = final_temporal
+ self.system3_kripke_invariants[candidate_id] = final_temporal
+ self._system3_commits[candidate_id] = self._system3_metadata_hash(self.candidates[candidate_id])
+ self.state = RunState.FINALIZED
+ self._checkpoint_trusted = True
+ self._validate_state_invariants()
+ return copy.deepcopy(object.__getattribute__(self, "candidates")[candidate_id])
+
+ def reattest_restored_checkpoint(
+ self, authenticator: CheckpointAuthenticator, signature: str
+ ) -> None:
+ """Explicitly authenticate an untrusted restored active run.
+
+ The authenticator must cover the current complete checkpoint payload,
+ including task/requirements, receipts, graph, and event commitments.
+ This does not implement cross-session adaptation or transfer trust.
+ """
+ with self._lock:
+ if not self._restored_from_checkpoint:
+ raise ValueError("only restored checkpoints require re-attestation")
+ if not callable(getattr(authenticator, "verify", None)):
+ raise TypeError("checkpoint authenticator must implement verify")
+ payload = self.to_dict()
+ if authenticator.verify(payload, signature) is not True:
+ raise PermissionError("restored checkpoint re-attestation failed")
+ # The external signature now authenticates the current result
+ # commitments as well. Use them as the safe replacement for the
+ # process-local HMAC secret; receipt authorizations remain empty
+ # unless they were present in the authenticated checkpoint itself.
+ self._checkpoint_verification_commitments = {
+ str(key): str(value)
+ for key, value in payload.get("verification_commitments", {}).items()
+ }
+ self._checkpoint_trusted = True
def run_system3_meta_cycle(self, candidate_id: str) -> dict[str, Any]:
"""Execute a full System 3 meta-cognitive reflection cycle for a candidate."""
with self._lock:
+ self._ensure_mutable()
candidate = self.candidates.get(candidate_id)
if candidate is None:
raise ValueError(f"unknown candidate: {candidate_id}")
- # 1. Active Inference Free Energy evaluation
- pomdp_model = create_default_architecture_pomdp()
- fe_engine = ActiveInferenceEngine(pomdp_model)
- observation = "HIGH_THROUGHPUT_CLEAN" if all(
- self.receipts[r].success for r in candidate.receipt_ids if r in self.receipts
- ) else "CHECKSUM_FAIL"
- fe_policies = [Policy(policy_id=f"p_{act}", actions=[act]) for act in pomdp_model.actions]
- fe_eval = fe_engine.select_action(observation, fe_policies)
+ # Establish the auditable coding loop from a real candidate receipt.
+ # Explicit callers can use the five public primitives themselves;
+ # this convenience path never invents an outcome or success flag.
+ if not any(p.candidate_id == candidate_id for p in self.predictions.values()):
+ if not candidate.receipt_ids:
+ raise PermissionError("System 3 coding cycle requires an actual tool receipt")
+ receipt = self.receipts[candidate.receipt_ids[0]]
+ if bool(self.task.metadata.get("require_system3_loop", False)) and (
+ receipt.success is not True or not receipt.trusted
+ or not self._system3_receipt_authenticated(receipt)):
+ raise PermissionError(
+ "strict System 3 convenience cycle requires an authenticated host receipt"
+ )
+ self.observe_system3(candidate_id, {
+ "candidate_artifact_hash": canonical_hash(candidate.artifact),
+ "receipt_id": receipt.receipt_id,
+ })
+ prediction = self.predict_system3(
+ candidate_id, receipt.capability,
+ {"expected_receipt_success": True}, 0.5,
+ "falsified when the bound tool receipt is unsuccessful",
+ policy_id=f"system3:{candidate_id}",
+ )
+ self.act_system3(candidate_id, prediction.prediction_id)
+ # Explicitly scoped compatibility switch: only this legacy
+ # convenience wrapper may use a self-minted diagnostic receipt.
+ # The public outcome primitive and all strict tasks remain
+ # receipt-bound and authenticated.
+ self._legacy_cycle_in_progress = not bool(self.task.metadata.get("require_system3_loop", False))
+ try:
+ outcome = self.record_system3_outcome(
+ candidate_id, prediction.prediction_id, receipt.receipt_id,
+ _legacy_compatibility=self._legacy_cycle_in_progress,
+ )
+ self.update_system3(candidate_id, outcome.outcome_id)
+ finally:
+ self._legacy_cycle_in_progress = False
+
+ # 1. Active Inference telemetry is grounded in the completed
+ # receipt-bound update above. Do not run a second synthetic
+ # observe/predict/act/update cycle over a guessed observation.
+ loop_update = next(u for u in reversed(list(self.system3_updates.values()))
+ if u.get("candidate_id") == candidate_id)
+ loop_prediction = self.predictions[loop_update["prediction_id"]]
+ observation = self.system3_observations[candidate_id]["observation"]
fe_report = {
- "variational_free_energy_f": round(fe_eval.variational_free_energy_f, 4),
- "complexity_kl": round(fe_eval.complexity_kl, 4),
- "accuracy_log_likelihood": round(fe_eval.accuracy_log_likelihood, 4),
- "observation": observation,
- "selected_policy": fe_eval.selected_action,
- "evaluated_policies_count": len(fe_eval.evaluated_policies),
+ "prediction_error": loop_update["prediction_error"],
+ "belief_revision": copy.deepcopy(loop_update["belief_revision"]),
+ "policy_revision": copy.deepcopy(loop_update["policy_revision"]),
+ "observation_hash": self.system3_observations[candidate_id]["observation_hash"],
+ "selected_policy": loop_prediction.action,
+ "evaluated_policies_count": len([p for p in self.predictions.values() if p.candidate_id == candidate_id]),
}
- # 2. Kripke Modal Safety Invariant Model Checking
- kripke = KripkeStructure()
- kripke.add_world("w0", propositions={"init", "safe"})
- kripke.add_world("w1", propositions={"executing", "safe"})
- kripke.add_world("w2", propositions={"verified", "safe"})
- kripke.add_transition("w0", "w1")
- kripke.add_transition("w1", "w2")
- kripke.add_transition("w2", "w2")
- checker = KripkeModelChecker(kripke)
- formula_res = checker.check("AG(safe)", "w0")
- kripke_report = {
- "formula": "AG(safe)",
- "satisfied": formula_res.is_satisfied,
- "initial_world": "w0",
- "satisfying_worlds": sorted(list(formula_res.satisfied_worlds)),
- }
+ # 2. Kripke model is a projection of actual append-only events.
+ kripke_report = self._event_temporal_report()
+ kripke_report["satisfied"] = kripke_report["is_satisfied"]
# 3. Hyperbolic Tree Embedding
tree = {candidate_id: list(candidate.receipt_ids) + list(candidate.evidence_ids)}
@@ -642,7 +2284,10 @@ def run_system3_meta_cycle(self, candidate_id: str) -> dict[str, Any]:
bias_detector = CognitiveBiasDetector()
bias_findings = bias_detector.audit_session(
session_data={
- "epistemic_ledger": [{"tag": "PROVEN", "claim": f"Candidate {candidate_id} registered"}],
+ # Registration is an observed event, not proof of safety or
+ # correctness; never feed a synthetic PROVEN claim to bias
+ # analysis.
+ "epistemic_ledger": [{"tag": "OBSERVED", "claim": f"Candidate {candidate_id} registered"}],
"refinement_cycles": [{"refinement_type": "architectural", "focus_area": "system3"}],
"phase_history": [{"phase": self.state.value}],
}
@@ -655,7 +2300,7 @@ def run_system3_meta_cycle(self, candidate_id: str) -> dict[str, Any]:
task_complexity=0.7,
contradiction_density=0.3,
failure_count=len(self.invalidated_verifiers),
- epistemic_uncertainty=round(fe_eval.complexity_kl, 3),
+ epistemic_uncertainty=round(loop_update["prediction_error"], 3),
)
# 6. Dialectical Synthesis
@@ -685,40 +2330,119 @@ def run_system3_meta_cycle(self, candidate_id: str) -> dict[str, Any]:
"dialectical_synthesis": syn.to_dict(),
}
- self.system3_meta_cycles.append(cycle_record)
- candidate.metadata["system3_meta_cycle"] = cycle_record
- self._event("system3_meta_cycle_completed", candidate_id=candidate_id, f_val=fe_eval.variational_free_energy_f)
- return cycle_record
+ committed_cycle = copy.deepcopy(cycle_record)
+ self.system3_meta_cycles.append(committed_cycle)
+ candidate.metadata["system3_meta_cycle"] = copy.deepcopy(committed_cycle)
+ self._event("system3_meta_cycle_completed", candidate_id=candidate_id,
+ cycle_hash=canonical_hash(committed_cycle))
+ self._system3_commits[candidate_id] = self._system3_metadata_hash(candidate)
+ self._validate_system3_metadata(candidate)
+ return copy.deepcopy(cycle_record)
- def to_dict(self) -> dict[str, Any]:
- """Serialize a run for round-trip checkpointing."""
- return {
- "version": "2.0",
- "session_id": self.session_id,
+ def to_dict(self, authenticator: CheckpointAuthenticator | None = None) -> dict[str, Any]:
+ """Serialize a checkpoint without secrets.
+
+ A signature is optional for compatibility with active, local
+ checkpoints. Finalized/trusted checkpoints must be signed by an
+ authenticator held outside the serialized payload.
+ """
+ system3_payload = {
+ "free_energy": self.system3_free_energy,
+ "active_inference": self.system3_active_inference,
+ "kripke": self.system3_kripke_invariants,
+ "hyperbolic": self.system3_hyperbolic_embeddings,
+ "meta_cycles": self.system3_meta_cycles,
+ "observations": self.system3_observations, "predictions": {k: v.to_dict() for k, v in self.predictions.items()},
+ "actions": self.system3_actions, "outcomes": {k: v.to_dict() for k, v in self.outcomes.items()},
+ "updates": self.system3_updates, "beliefs": self.system3_beliefs,
+ "policy_revisions": self.system3_policy_revisions,
+ "triz": self.triz_repair_recommendations,
+ }
+ events = copy.deepcopy(self.events)
+ terminal_hash = events[-1].get("event_hash", "0" * 64) if events else "0" * 64
+ # Authentication performed by record_receipt is process-local, so a
+ # checkpoint must carry a signed, receipt-bound projection of it. The
+ # IDs alone are useful for compatibility; commitments bind each ID to
+ # the exact serialized receipt restored below.
+ authenticated_receipt_ids = sorted(self._authenticated_receipt_ids)
+ authenticated_receipt_commitments = {
+ receipt_id: canonical_hash(self.receipts[receipt_id].to_dict())
+ for receipt_id in authenticated_receipt_ids
+ if receipt_id in self.receipts
+ }
+ payload = {
+ "version": "2.1", "session_id": self.session_id,
+ "compatibility_mode": self.compatibility_mode,
+ "cross_session_adaptation_implemented": CROSS_SESSION_ADAPTATION_IMPLEMENTED,
"task": self.task.to_dict(),
+ "task_commitment": self._task_commitment,
+ "compatibility_commitment": self._compatibility_commitment,
"state": self.state.value,
"started_at": self.started_at,
"receipts": [receipt.to_dict() for receipt in self.receipts.values()],
+ "authenticated_receipt_ids": authenticated_receipt_ids,
+ "authenticated_receipt_commitments": authenticated_receipt_commitments,
"candidates": [candidate.to_dict() for candidate in self.candidates.values()],
"evidence": [item.to_dict() for item in self.evidence.values()],
"verifications": [item.to_dict() for item in self.verifications.values()],
- "events": copy.deepcopy(self.events),
+ # Public integrity commitments detect accidental/tampering edits;
+ # they do not replace an external checkpoint signature.
+ "verification_commitments": {
+ item.verification_id: canonical_hash(item.to_dict())
+ for item in self.verifications.values()
+ },
+ "events": events, "event_count": len(events),
+ "event_terminal_commitment": canonical_hash({"count": len(events), "terminal_hash": terminal_hash}),
"final_candidate_id": self.final_candidate_id,
"invalidated_verifiers": dict(self.invalidated_verifiers),
+ "revocation_commitments": dict(self._revocation_ledger),
"system3_free_energy": copy.deepcopy(self.system3_free_energy),
+ "system3_active_inference": copy.deepcopy(self.system3_active_inference),
"system3_kripke_invariants": copy.deepcopy(self.system3_kripke_invariants),
"system3_hyperbolic_embeddings": copy.deepcopy(self.system3_hyperbolic_embeddings),
"system3_meta_cycles": copy.deepcopy(self.system3_meta_cycles),
+ "system3_observations": copy.deepcopy(self.system3_observations),
+ "predictions": [v.to_dict() for v in self.predictions.values()],
+ "system3_actions": copy.deepcopy(self.system3_actions),
+ "outcomes": [v.to_dict() for v in self.outcomes.values()],
+ "system3_updates": copy.deepcopy(self.system3_updates),
+ "system3_beliefs": copy.deepcopy(self.system3_beliefs),
+ "system3_policy_revisions": copy.deepcopy(self.system3_policy_revisions),
+ "system3_loop_commits": copy.deepcopy(self._system3_loop_commits),
"triz_repair_recommendations": copy.deepcopy(self.triz_repair_recommendations),
- # Production deployments should protect this with an external key
- # store; it is included here so an in-memory checkpoint can be
- # faithfully restored without silently trusting new signatures.
- "attestation_secret": self._attestation_secret.hex(),
+ "artifact_commits": copy.deepcopy(self._artifact_commits),
+ "system3_commits": copy.deepcopy(self._system3_commits),
+ "system3_state_hash": canonical_hash(system3_payload),
}
+ if authenticator is not None:
+ if not callable(getattr(authenticator, "sign", None)):
+ raise TypeError("checkpoint authenticator must implement sign")
+ payload["checkpoint_signature"] = authenticator.sign(payload)
+ return payload
@classmethod
- def from_dict(cls, data: dict[str, Any]) -> "FableRun":
- """Restore a run and reject tampered event history or payload hashes."""
+ def from_dict(cls, data: dict[str, Any], authenticator: CheckpointAuthenticator | None = None) -> "FableRun":
+ """Restore a run; trusted terminal state requires external authenticity."""
+ if not isinstance(data, Mapping):
+ raise TypeError("checkpoint must be a mapping")
+ checkpoint_signature = data.get("checkpoint_signature")
+ if data.get("cross_session_adaptation_implemented", False) is not False:
+ raise ValueError("cross-session adaptation is not implemented")
+ if str(data.get("version", "2.0")) == "2.1" and ("event_count" not in data or "event_terminal_commitment" not in data):
+ raise ValueError("version 2.1 checkpoint lacks event terminal commitment")
+ signed_payload = {k: copy.deepcopy(v) for k, v in data.items() if k != "checkpoint_signature"}
+ checkpoint_trusted = False
+ if authenticator is not None:
+ if not callable(getattr(authenticator, "verify", None)):
+ raise TypeError("checkpoint authenticator must implement verify")
+ if not checkpoint_signature or authenticator.verify(signed_payload, checkpoint_signature) is not True:
+ raise PermissionError("checkpoint authenticity verification failed")
+ checkpoint_trusted = True
+ elif data.get("state") in (RunState.FINALIZED.value, RunState.REJECTED.value) or checkpoint_signature:
+ if data.get("state") in (RunState.FINALIZED.value, RunState.REJECTED.value):
+ raise PermissionError("restored terminal state requires external checkpoint authenticity")
+ if checkpoint_signature:
+ raise PermissionError("checkpoint signature requires its external authenticator")
task_data = dict(data["task"])
policy_data = dict(task_data.pop("verification_policy", {}))
task_data["constraints"] = tuple(task_data.get("constraints", ()))
@@ -726,15 +2450,28 @@ def from_dict(cls, data: dict[str, Any]) -> "FableRun":
task_data["required_capabilities"] = tuple(task_data.get("required_capabilities", ()))
task_data["required_evidence"] = tuple(task_data.get("required_evidence", ()))
task_data["verification_policy"] = VerificationPolicy(**policy_data)
+ compatibility_mode = data.get("compatibility_mode", False)
+ if type(compatibility_mode) is not bool:
+ raise TypeError("compatibility_mode must be a boolean")
run = cls(
session_id=data["session_id"],
task=TaskSpec(**task_data),
state=RunState(data.get("state", RunState.CREATED.value)),
+ compatibility_mode=compatibility_mode,
started_at=data.get("started_at", utc_now()),
)
- secret = data.get("attestation_secret")
- if secret:
- run._attestation_secret = bytes.fromhex(secret)
+ run._restoring = True
+ supplied_task_commitment = data.get("task_commitment")
+ if supplied_task_commitment is not None and not hmac.compare_digest(
+ str(supplied_task_commitment), run._task_commitment):
+ raise PermissionError("restored task contract commitment does not match payload")
+ supplied_mode_commitment = data.get("compatibility_commitment")
+ if supplied_mode_commitment is not None and not hmac.compare_digest(
+ str(supplied_mode_commitment), run._compatibility_commitment):
+ raise PermissionError("restored compatibility mode commitment does not match payload")
+ # Attestation keys are process-local and intentionally never restored.
+ run._checkpoint_trusted = checkpoint_trusted
+ run._restored_from_checkpoint = True
run.receipts = {}
for item in data.get("receipts", []):
receipt = ToolReceipt(**item)
@@ -743,6 +2480,35 @@ def from_dict(cls, data: dict[str, Any]) -> "FableRun":
if receipt.receipt_id in run.receipts:
raise ValueError("duplicate restored tool receipt")
run.receipts[receipt.receipt_id] = receipt
+ # ``_authenticated_receipt_ids`` is intentionally not restored from
+ # an unsigned checkpoint: the field is only meaningful when the whole
+ # checkpoint was authenticated by the external checkpoint authority.
+ # For an authenticated checkpoint, bind every restored authorization
+ # to the exact receipt bytes and reject aliases, unknown IDs, and
+ # self-minted receipts. This prevents a model-supplied flag or a
+ # replayed receipt from manufacturing strict System 3 trust.
+ run._authenticated_receipt_ids = set()
+ if checkpoint_trusted:
+ raw_ids = data.get("authenticated_receipt_ids", ())
+ if not isinstance(raw_ids, (list, tuple)) or any(not isinstance(item, str) for item in raw_ids):
+ raise PermissionError("authenticated receipt authorization state is malformed")
+ if len(set(raw_ids)) != len(raw_ids):
+ raise PermissionError("authenticated receipt authorization state contains duplicates")
+ raw_commitments = data.get("authenticated_receipt_commitments", {})
+ if (not isinstance(raw_commitments, Mapping)
+ or any(not isinstance(key, str) for key in raw_commitments)):
+ raise PermissionError("authenticated receipt commitments are malformed")
+ commitments = dict(raw_commitments)
+ if set(commitments) != set(raw_ids):
+ raise PermissionError("authenticated receipt commitments are incomplete")
+ for receipt_id in raw_ids:
+ receipt = run.receipts.get(receipt_id)
+ commitment = commitments.get(receipt_id)
+ if (receipt is None or not receipt.trusted
+ or not isinstance(commitment, str)
+ or not hmac.compare_digest(commitment, canonical_hash(receipt.to_dict()))):
+ raise PermissionError("authenticated receipt authorization is not bound to its receipt")
+ run._authenticated_receipt_ids = set(raw_ids)
run.candidates = {}
for item in data.get("candidates", []):
candidate = Candidate(
@@ -763,9 +2529,13 @@ def from_dict(cls, data: dict[str, Any]) -> "FableRun":
raise ValueError("duplicate restored evidence")
run.evidence[evidence.evidence_id] = evidence
for candidate in run.candidates.values():
- if any(evidence_id not in run.evidence for evidence_id in candidate.evidence_ids):
- raise ValueError("candidate references an unknown restored evidence item")
+ run._validate_candidate_graph(candidate)
+ for candidate in run.candidates.values():
+ run._artifact_commits[candidate.candidate_id] = (
+ canonical_hash(candidate.artifact), run._dependency_hash(candidate)
+ )
run.verifications = {}
+ supplied_verification_commits = {str(k): str(v) for k, v in dict(data.get("verification_commitments", {})).items()}
seen_verifier_candidates: set[tuple[str, str]] = set()
for item in data.get("verifications", []):
result = VerificationResult(
@@ -773,20 +2543,99 @@ def from_dict(cls, data: dict[str, Any]) -> "FableRun":
"reasons": tuple(item.get("reasons", ())),
"evidence_ids": tuple(item.get("evidence_ids", ()))})
pair = (result.verifier, result.candidate_id)
+ expected_commit = supplied_verification_commits.get(result.verification_id)
+ if expected_commit is not None and not hmac.compare_digest(expected_commit, canonical_hash(result.to_dict())):
+ raise PermissionError("restored verification commitment does not match payload")
if pair in seen_verifier_candidates:
raise ValueError("duplicate restored verifier verdict")
- run._validate_attested_verification(result)
+ run._validate_attested_verification(result, check_runtime=False)
run.verifications[result.verification_id] = result
seen_verifier_candidates.add(pair)
+ if supplied_verification_commits and set(supplied_verification_commits) != set(run.verifications):
+ raise PermissionError("restored verification commitments are incomplete")
+ # Retain the already checked public commitments as the replacement for
+ # the process-local runtime HMAC when (and only when) the enclosing
+ # checkpoint was externally authenticated.
+ if checkpoint_trusted:
+ run._checkpoint_verification_commitments = dict(supplied_verification_commits)
run.events = copy.deepcopy(data.get("events", []))
run.final_candidate_id = data.get("final_candidate_id")
run.invalidated_verifiers = dict(data.get("invalidated_verifiers", {}))
+ # Reconstruct the monotonic private ledger from committed events; a
+ # checkpoint field cannot introduce a revocation that has no event.
+ run._revocation_ledger = run._revoked_verifiers_from_events()
+ supplied_revocations = data.get("revocation_commitments")
+ if supplied_revocations is not None and dict(supplied_revocations) != run._revocation_ledger:
+ raise PermissionError("restored verifier revocation commitment does not match events")
run.system3_free_energy = copy.deepcopy(data.get("system3_free_energy", {}))
+ run.system3_active_inference = copy.deepcopy(data.get("system3_active_inference", {}))
run.system3_kripke_invariants = copy.deepcopy(data.get("system3_kripke_invariants", {}))
run.system3_hyperbolic_embeddings = copy.deepcopy(data.get("system3_hyperbolic_embeddings", {}))
run.system3_meta_cycles = copy.deepcopy(data.get("system3_meta_cycles", []))
+ run.system3_observations = copy.deepcopy(data.get("system3_observations", {}))
+ run.predictions = {}
+ for item in data.get("predictions", []):
+ prediction = Prediction.from_dict(item)
+ if prediction.run_id != run.session_id or prediction.prediction_id in run.predictions:
+ raise ValueError("invalid restored System 3 prediction")
+ run.predictions[prediction.prediction_id] = prediction
+ run.system3_actions = copy.deepcopy(data.get("system3_actions", {}))
+ run.outcomes = {}
+ for item in data.get("outcomes", []):
+ outcome = Outcome.from_dict(item)
+ if outcome.run_id != run.session_id or outcome.outcome_id in run.outcomes:
+ raise ValueError("invalid restored System 3 outcome")
+ run.outcomes[outcome.outcome_id] = outcome
+ run.system3_updates = copy.deepcopy(data.get("system3_updates", {}))
+ run.system3_beliefs = copy.deepcopy(data.get("system3_beliefs", {}))
+ run.system3_policy_revisions = copy.deepcopy(data.get("system3_policy_revisions", {}))
+ run._system3_loop_commits = {str(k): str(v) for k, v in dict(data.get("system3_loop_commits", {})).items()}
run.triz_repair_recommendations = copy.deepcopy(data.get("triz_repair_recommendations", []))
- run.validate_event_history()
+ # Restore active-inference telemetry through its semantic revalidator;
+ # hashes alone would allow a self-consistent but numerically fabricated
+ # prediction history.
+ for inference_state in run.system3_active_inference.values():
+ ActiveInferenceEngine.from_dict(inference_state)
+ supplied_system3_hash = data.get("system3_state_hash")
+ if supplied_system3_hash:
+ actual_system3_hash = canonical_hash({
+ "free_energy": run.system3_free_energy,
+ "active_inference": run.system3_active_inference,
+ "kripke": run.system3_kripke_invariants,
+ "hyperbolic": run.system3_hyperbolic_embeddings,
+ "meta_cycles": run.system3_meta_cycles,
+ "observations": run.system3_observations, "predictions": {k: v.to_dict() for k, v in run.predictions.items()},
+ "actions": run.system3_actions, "outcomes": {k: v.to_dict() for k, v in run.outcomes.items()},
+ "updates": run.system3_updates, "beliefs": run.system3_beliefs,
+ "policy_revisions": run.system3_policy_revisions,
+ "triz": run.triz_repair_recommendations,
+ })
+ if not hmac.compare_digest(str(supplied_system3_hash), actual_system3_hash):
+ raise PermissionError("restored System 3 state hash mismatch")
+ supplied_commits = data.get("artifact_commits")
+ if supplied_commits is not None:
+ normalized = {str(k): tuple(v) for k, v in dict(supplied_commits).items()}
+ if normalized != run._artifact_commits:
+ raise PermissionError("restored artifact commitment does not match payload")
+ supplied_system3_commits = data.get("system3_commits")
+ run._system3_commits = ({str(k): str(v) for k, v in dict(supplied_system3_commits).items()}
+ if supplied_system3_commits is not None else {
+ c.candidate_id: run._system3_metadata_hash(c)
+ for c in run.candidates.values()
+ })
+ if set(run._system3_commits) != set(run.candidates):
+ raise PermissionError("restored System 3 telemetry commitments are incomplete")
+ for candidate in run.candidates.values():
+ run._validate_system3_metadata(candidate)
+ if candidate.candidate_id in run._system3_loop_commits:
+ run._validate_system3_loop(candidate.candidate_id)
+ run.validate_event_history(
+ expected_count=data.get("event_count") if "event_count" in data else None,
+ expected_terminal_commitment=data.get("event_terminal_commitment")
+ if "event_terminal_commitment" in data else None,
+ )
+ run._validate_state_invariants()
+ run._restoring = False
return run
def status(self) -> dict[str, Any]:
@@ -794,6 +2643,7 @@ def status(self) -> dict[str, Any]:
"session_id": self.session_id,
"task_id": self.task.task_id,
"state": self.state.value,
+ "compatibility_mode": self.compatibility_mode,
"receipts": len(self.receipts),
"candidates": len(self.candidates),
"evidence": len(self.evidence),
@@ -806,17 +2656,31 @@ def status(self) -> dict[str, Any]:
),
"verification_policy": self.task.verification_policy.to_dict(),
"final_candidate_id": self.final_candidate_id,
+ "cross_session_adaptation": {
+ "implemented": False,
+ "status": "not_implemented; no authenticated persisted self-model is transferred",
+ },
"system3_state": {
"free_energy_tracked": len(self.system3_free_energy),
"kripke_invariants_tracked": len(self.system3_kripke_invariants),
"hyperbolic_embeddings_tracked": len(self.system3_hyperbolic_embeddings),
"meta_cycles_count": len(self.system3_meta_cycles),
"triz_repairs_count": len(self.triz_repair_recommendations),
+ "predictions": len(self.predictions), "outcomes": len(self.outcomes),
+ "updates": len(self.system3_updates),
},
}
-def new_run(session_id: str, task: TaskSpec) -> FableRun:
- run = FableRun(session_id=session_id, task=task)
+def new_run(session_id: str, task: TaskSpec, *, compatibility_mode: bool = False) -> FableRun:
+ """Create an active run; strict claim adjudication is the default.
+
+ ``compatibility_mode`` is an explicit escape hatch for legacy callers
+ that only provide tuple/boolean verifier results and have no claim graph.
+ It must never be inferred from missing fields in a purported strict run.
+ """
+ if type(compatibility_mode) is not bool:
+ raise TypeError("compatibility_mode must be a boolean")
+ run = FableRun(session_id=session_id, task=task, compatibility_mode=compatibility_mode)
run.start()
return run
diff --git a/fable_v2/system3/__init__.py b/fable_v2/system3/__init__.py
index 671b44d..4ca13f3 100644
--- a/fable_v2/system3/__init__.py
+++ b/fable_v2/system3/__init__.py
@@ -44,7 +44,7 @@
GenerativeModel,
Policy,
PolicyEvaluation,
- create_default_architecture_pomdp,
+ create_default_architecture_pomdp, prediction_error, revise_belief, policy_revision,
)
from .hyperbolic import (
HyperbolicGeometryError,
@@ -167,6 +167,7 @@
"Policy",
"PolicyEvaluation",
"create_default_architecture_pomdp",
+ "prediction_error", "revise_belief", "policy_revision",
# Oracle
"Abort",
"And",
diff --git a/fable_v2/system3/dialectical.py b/fable_v2/system3/dialectical.py
index 3bd19c5..150c2fa 100644
--- a/fable_v2/system3/dialectical.py
+++ b/fable_v2/system3/dialectical.py
@@ -11,10 +11,55 @@
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
+from collections.abc import Mapping
import copy
import hashlib
+import hmac
import json
import math
+from ..protocol import Evidence, ToolReceipt, canonical_hash
+
+
+def _finite(value: Any, field_name: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
+ raise ValueError(f"{field_name} must be finite")
+ return float(value)
+
+
+@dataclass(frozen=True)
+class DialecticalMeasurement:
+ """Receipt-bound measured round score; unlike a bare float it is attestable."""
+ round_index: int
+ score: float
+ score_hash: str
+ receipt_id: str
+ evidence_id: str
+ provenance: Dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self) -> None:
+ if type(self.round_index) is not int or self.round_index < 0:
+ raise ValueError("measurement round_index must be a non-negative integer")
+ score = _finite(self.score, "measurement score")
+ if not 0 <= score <= 1:
+ raise ValueError("measurement score must be between 0 and 1")
+ if not isinstance(self.score_hash, str) or not self.score_hash:
+ raise ValueError("measurement score_hash is required")
+ if not hmac.compare_digest(self.score_hash, canonical_hash(score)):
+ raise ValueError("measurement score_hash does not match score")
+ for name in ("receipt_id", "evidence_id"):
+ if not isinstance(getattr(self, name), str) or not getattr(self, name).strip():
+ raise ValueError(f"measurement {name} is required")
+ object.__setattr__(self, "provenance", copy.deepcopy(dict(self.provenance)))
+ canonical_hash(self.provenance)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return copy.deepcopy(asdict(self))
+
+
+# Descriptive aliases for hosts that used either spelling while this API was
+# experimental.
+MeasuredRoundScore = DialecticalMeasurement
+MeasurementRecord = DialecticalMeasurement
@dataclass
@@ -28,11 +73,18 @@ class Contradiction:
domain: str = "software_architecture"
metadata: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ _finite(self.severity, "contradiction severity")
+ if not 0 <= self.severity <= 1:
+ raise ValueError("contradiction severity must be between 0 and 1")
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.metadata)
+
def to_dict(self) -> Dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "Contradiction":
+ def from_dict(cls, data: Dict[str, Any]) -> "Contradiction":
return cls(**data)
@@ -48,11 +100,23 @@ class ThesisCandidate:
metrics: Dict[str, float] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ if not isinstance(self.metrics, dict):
+ raise TypeError("thesis metrics must be a mapping")
+ for key, value in self.metrics.items():
+ _finite(value, f"thesis metric {key}")
+ self.core_assumptions = list(self.core_assumptions)
+ self.strengths = list(self.strengths)
+ self.weaknesses = list(self.weaknesses)
+ self.metrics = dict(self.metrics)
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.metadata)
+
def to_dict(self) -> Dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "ThesisCandidate":
+ def from_dict(cls, data: Dict[str, Any]) -> "ThesisCandidate":
return cls(**data)
@@ -68,8 +132,18 @@ class AntithesisCritique:
severity_score: float = 0.5 # [0.0, 1.0]
metadata: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ _finite(self.severity_score, "critique severity_score")
+ if not 0 <= self.severity_score <= 1:
+ raise ValueError("critique severity_score must be between 0 and 1")
+ self.contradictions = list(self.contradictions)
+ self.failure_modes = list(self.failure_modes)
+ self.adversarial_scenarios = list(self.adversarial_scenarios)
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.metadata)
+
def to_dict(self) -> Dict[str, Any]:
- return {
+ return copy.deepcopy({
"critique_id": self.critique_id,
"thesis_id": self.thesis_id,
"title": self.title,
@@ -78,10 +152,10 @@ def to_dict(self) -> Dict[str, Any]:
"adversarial_scenarios": self.adversarial_scenarios,
"severity_score": self.severity_score,
"metadata": self.metadata,
- }
+ })
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "AntithesisCritique":
+ def from_dict(cls, data: Dict[str, Any]) -> "AntithesisCritique":
data_copy = dict(data)
if "contradictions" in data_copy:
data_copy["contradictions"] = [
@@ -112,14 +186,19 @@ class TRIZResolutionRecommendation:
expected_pareto_gain: str
confidence: float = 0.85
+ def __post_init__(self) -> None:
+ _finite(self.confidence, "recommendation confidence")
+ if not 0 <= self.confidence <= 1:
+ raise ValueError("recommendation confidence must be between 0 and 1")
+
def to_dict(self) -> Dict[str, Any]:
- return {
+ return copy.deepcopy({
"principle": self.principle.to_dict(),
"contradiction": self.contradiction.to_dict(),
"resolution_strategy": self.resolution_strategy,
"expected_pareto_gain": self.expected_pareto_gain,
"confidence": self.confidence,
- }
+ })
@dataclass
@@ -139,8 +218,22 @@ class EmergentSynthesis:
convergence_achieved: bool
metadata: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ _finite(self.initial_contradiction_score, "initial contradiction score")
+ _finite(self.residual_contradiction_score, "residual contradiction score")
+ if not 0 <= self.initial_contradiction_score <= 1 or not 0 <= self.residual_contradiction_score <= 1:
+ raise ValueError("synthesis scores must be between 0 and 1")
+ if type(self.debate_rounds_executed) is not int or self.debate_rounds_executed < 0:
+ raise ValueError("debate_rounds_executed must be a non-negative integer")
+ if type(self.convergence_achieved) is not bool:
+ raise TypeError("convergence_achieved must be a boolean")
+ self.resolved_contradictions = list(self.resolved_contradictions)
+ self.transcended_principles = list(self.transcended_principles)
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.metadata)
+
def to_dict(self) -> Dict[str, Any]:
- return {
+ return copy.deepcopy({
"synthesis_id": self.synthesis_id,
"thesis_id": self.thesis_id,
"critique_id": self.critique_id,
@@ -154,10 +247,10 @@ def to_dict(self) -> Dict[str, Any]:
"pareto_improvement_claim": self.pareto_improvement_claim,
"convergence_achieved": self.convergence_achieved,
"metadata": self.metadata,
- }
+ })
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "EmergentSynthesis":
+ def from_dict(cls, data: Dict[str, Any]) -> "EmergentSynthesis":
data_copy = dict(data)
if "resolved_contradictions" in data_copy:
data_copy["resolved_contradictions"] = [
@@ -455,8 +548,9 @@ def resolve_contradiction(self, contradiction: Contradiction) -> List[TRIZResolu
f"via {analog_example.lower()}."
)
gain = (
- f"Transcend trade-off: Preserves {contradiction.improving_parameter} gains while "
- f"eliminating {contradiction.worsening_parameter} regression."
+ "UNMEASURED: expected Pareto effect only; no baseline or execution "
+ f"measurement supplied for {contradiction.improving_parameter}/"
+ f"{contradiction.worsening_parameter}."
)
rec = TRIZResolutionRecommendation(
@@ -487,6 +581,10 @@ def synthesize(
critique: AntithesisCritique,
max_debate_rounds: int = 4,
target_residual_threshold: float = 0.15,
+ measured_round_scores: Optional[List[float]] = None,
+ measured_pareto_metrics: Optional[Dict[str, float]] = None,
+ measured_receipts: Optional[List[ToolReceipt]] = None,
+ measured_evidence: Optional[List[Evidence]] = None,
) -> EmergentSynthesis:
"""
Execute bounded dialectical debate rounds to synthesize a higher-order paradigm.
@@ -495,6 +593,92 @@ def synthesize(
- Zero unverified compromises (transcendence over weak averaging)
- Traceable derivation of emergent principles.
"""
+ # Normalize typed score records without ever trusting caller-supplied
+ # numeric claims. Bare scores are accepted only when the evidence
+ # output explicitly contains the same score series.
+ measurement_records: List[DialecticalMeasurement] = []
+ bare_scores: List[float] = []
+ if measured_round_scores:
+ for index, raw_score in enumerate(measured_round_scores):
+ if isinstance(raw_score, DialecticalMeasurement):
+ measurement_records.append(raw_score)
+ elif isinstance(raw_score, Mapping):
+ measurement_records.append(DialecticalMeasurement(**dict(raw_score)))
+ else:
+ score = _finite(raw_score, "measured round score")
+ if not 0 <= score <= 1:
+ raise ValueError("measured round scores must be in [0, 1]")
+ bare_scores.append(score)
+ if measurement_records and bare_scores:
+ raise ValueError("measured scores must use either typed records or numeric scores")
+ measured = measured_round_scores is not None and bool(measured_round_scores)
+ pareto_supplied = measured_pareto_metrics is not None
+ if measured or pareto_supplied:
+ # A numeric series is not a measurement by itself. Require actual
+ # successful receipts and hash-bound evidence for measured claims.
+ if not measured_receipts or not measured_evidence:
+ raise ValueError("measured convergence requires receipt and evidence provenance")
+ receipts = {r.receipt_id: r for r in measured_receipts
+ if isinstance(r, ToolReceipt) and r.success}
+ if len(receipts) != len(measured_receipts):
+ raise ValueError("measured convergence receipts must be successful and unique")
+ evidence_ids = [item.evidence_id for item in measured_evidence
+ if isinstance(item, Evidence)]
+ if len(evidence_ids) != len(set(evidence_ids)):
+ raise ValueError("measured convergence evidence must be unique")
+ for item in measured_evidence:
+ if not isinstance(item, Evidence) or item.receipt_id not in receipts:
+ raise ValueError("measured evidence has unresolved receipt provenance")
+ receipt = receipts[item.receipt_id]
+ if (item.session_id != receipt.session_id or
+ item.content_hash != receipt.output_hash or
+ item.source_output_hash != receipt.output_hash or
+ canonical_hash(item.content) != item.content_hash):
+ raise ValueError("measured evidence is not hash-bound to its receipt")
+ if measured_round_scores is not None:
+ if not measured_round_scores:
+ raise ValueError("measured round scores must be finite numbers in [0, 1]")
+ if measurement_records:
+ rounds = [record.round_index for record in measurement_records]
+ if len(set(rounds)) != len(rounds) or rounds != sorted(rounds):
+ raise ValueError("typed measurements must have unique ordered rounds")
+ allowed = set(receipts)
+ evidence_by_id = {item.evidence_id: item for item in (measured_evidence or [])}
+ if any(record.receipt_id not in allowed or record.evidence_id not in evidence_by_id
+ for record in measurement_records):
+ raise ValueError("typed measurement provenance is not supplied")
+ for record in measurement_records:
+ item = evidence_by_id[record.evidence_id]
+ if item.receipt_id != record.receipt_id:
+ raise ValueError("typed measurement receipt/evidence mismatch")
+ # The typed record commits to the score; its receipt/evidence
+ # links and score hash are the provenance boundary. Hosts may
+ # keep the numeric score in a non-JSON measurement artifact.
+ else:
+ def score_series(value: Any) -> List[float]:
+ found: List[float] = []
+ if isinstance(value, Mapping):
+ for child in value.values():
+ found.extend(score_series(child))
+ elif isinstance(value, (list, tuple)):
+ if value and all(isinstance(x, (int, float)) and not isinstance(x, bool)
+ and math.isfinite(float(x)) for x in value):
+ found.extend(float(x) for x in value)
+ else:
+ for child in value:
+ found.extend(score_series(child))
+ return found
+ expected = [float(x) for x in bare_scores]
+ observed = []
+ for item in (measured_evidence or []):
+ observed.extend(score_series(item.content))
+ if not any(len(series) >= len(expected) and series[:len(expected)] == expected
+ for series in (observed,)):
+ raise ValueError("measured scores do not match receipt/evidence output")
+ if measured_pareto_metrics is not None and any(
+ not isinstance(v, (int, float)) or isinstance(v, bool) or
+ not math.isfinite(float(v)) for v in measured_pareto_metrics.values()):
+ raise ValueError("Pareto measurements must be finite numbers")
contradictions = critique.contradictions
if not contradictions:
# Construct a default contradiction from critique title / failure modes
@@ -543,17 +727,44 @@ def synthesize(
synthesis_id = f"syn_{thesis.thesis_id}_{critique.critique_id}_{int(initial_score*100)}"
title = f"Emergent Synthesis: {thesis.title} Transcended"
+ # A synthesis is a proposal, not a measured experiment. Never turn
+ # deterministic score decay in the planner into a claim about the
+ # world. Only externally supplied measurements can establish either
+ # convergence or Pareto dominance.
+ measured = measured_round_scores is not None and bool(measured_round_scores)
+ normalized_scores = ([record.score for record in measurement_records]
+ if measurement_records else bare_scores)
+ monotonic_measurement = bool(measured) and all(
+ float(b) <= float(a) for a, b in zip(normalized_scores, normalized_scores[1:])
+ )
+ if measured and monotonic_measurement:
+ measured_residual = float(normalized_scores[-1])
+ current_score = measured_residual
+ convergence = measured_residual <= target_residual_threshold
+ else:
+ # The bounded planner is allowed to report estimated convergence
+ # for a substantive, explicitly supplied contradiction. This is
+ # a planning result only: ``measured`` remains false and the
+ # Pareto claim below remains UNMEASURED. Keep the auto-generated
+ # fallback contradiction conservative so an empty critique cannot
+ # manufacture a convergence proof (important for strict callers).
+ convergence = bool(critique.contradictions) and current_score <= target_residual_threshold
+ pareto_measured = bool(measured_pareto_metrics) and all(
+ isinstance(v, (int, float)) and math.isfinite(float(v))
+ for v in (measured_pareto_metrics or {}).values()
+ )
+ pareto_claim = (
+ "MEASURED: Pareto metrics supplied externally; dominance requires an explicit baseline."
+ if pareto_measured else
+ "UNMEASURED: no Pareto baseline/metrics supplied; this is a proposal, not a Pareto claim."
+ )
+ score_label = "measured" if measured_round_scores else "estimated planner"
narrative = (
f"Synthesized architecture transcending '{thesis.title}' and red-team critique '{critique.title}'.\n"
- f"Resolutions applied across {round_num} debate rounds:\n"
+ f"Resolutions applied across {round_num} debate rounds ({score_label} only):\n"
+ "\n".join([f"- {part}" for part in synthesis_narrative_parts])
- + f"\nResult: Initial contradiction score {initial_score:.2f} converged to residual {current_score:.2f}."
- )
-
- pareto_claim = (
- f"Achieves strictly non-dominated 10D Pareto frontier improvement. "
- f"Eliminates {len(resolved_contradictions)} primary architectural contradictions "
- f"using {len(transcended_principles)} TRIZ transcendence operators."
+ + f"\nResult: Initial contradiction score {initial_score:.2f}; "
+ f"{score_label} residual {current_score:.2f}."
)
return EmergentSynthesis(
@@ -568,8 +779,15 @@ def synthesize(
initial_contradiction_score=initial_score,
residual_contradiction_score=current_score,
pareto_improvement_claim=pareto_claim,
- convergence_achieved=current_score <= target_residual_threshold,
+ convergence_achieved=convergence,
metadata={
+ "measured": measured,
+ "measured_round_scores": ([record.to_dict() for record in measurement_records]
+ if measurement_records else list(normalized_scores)),
+ "measured_receipt_ids": [r.receipt_id for r in (measured_receipts or [])],
+ "measured_evidence_ids": [e.evidence_id for e in (measured_evidence or [])],
+ "measurement_provenance_resolved": bool(measured_receipts and measured_evidence),
+ "pareto_measured": pareto_measured,
"thesis_title": thesis.title,
"critique_title": critique.title,
"reduction_ratio": round((initial_score - current_score) / max(initial_score, 1e-6), 4),
diff --git a/fable_v2/system3/free_energy.py b/fable_v2/system3/free_energy.py
index 832d065..24ca6a4 100644
--- a/fable_v2/system3/free_energy.py
+++ b/fable_v2/system3/free_energy.py
@@ -15,13 +15,25 @@
import json
import math
+from ..protocol import canonical_hash
+
EPS = 1e-12
+def _finite(value: Any, field_name: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
+ raise ValueError(f"{field_name} must be finite")
+ return float(value)
+
+
def _normalize(dist: Sequence[float]) -> List[float]:
- """Normalize a vector to a valid probability distribution."""
- total = sum(dist)
+ """Normalize a finite, bounded probability vector."""
+ if len(dist) > 128 or any(isinstance(x, bool) or not isinstance(x, (int, float)) or not math.isfinite(float(x)) or x < 0 for x in dist):
+ raise ValueError("probability vector contains invalid or unbounded values")
+ total = sum(float(x) for x in dist)
+ if not math.isfinite(total):
+ raise ValueError("probability vector sum must be finite")
if total < EPS:
# Uniform fallback
n = len(dist)
@@ -33,8 +45,13 @@ def _softmax(values: Sequence[float], temperature: float = 1.0) -> List[float]:
"""Numerically stable softmax."""
if not values:
return []
- temp = max(1e-6, temperature)
- scaled = [v / temp for v in values]
+ if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(float(v))
+ for v in values):
+ raise ValueError("softmax values must be finite")
+ if not isinstance(temperature, (int, float)) or isinstance(temperature, bool) or not math.isfinite(float(temperature)):
+ raise ValueError("softmax temperature must be finite")
+ temp = max(1e-6, float(temperature))
+ scaled = [float(v) / temp for v in values]
max_v = max(scaled)
exps = [math.exp(v - max_v) for v in scaled]
sum_exps = sum(exps)
@@ -71,6 +88,21 @@ class Policy:
description: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ if not isinstance(self.policy_id, str) or not self.policy_id.strip():
+ raise ValueError("policy_id must be non-empty")
+ if (not isinstance(self.actions, list) or len(self.actions) > 128
+ or any(not isinstance(a, str) or not a.strip() for a in self.actions)):
+ raise ValueError("policy actions are invalid or unbounded")
+ if not self.actions:
+ raise ValueError("policy must contain at least one action")
+ self.actions = list(self.actions)
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.metadata)
+
+ def commitment(self) -> str:
+ return canonical_hash(self.to_dict())
+
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@@ -93,11 +125,25 @@ class PolicyEvaluation:
is_optimal: bool = False
metadata: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ for name in ("expected_free_energy_g", "risk_pragmatic_divergence",
+ "ambiguity_expected_entropy", "epistemic_information_gain",
+ "pragmatic_goal_utility", "probability"):
+ _finite(getattr(self, name), name)
+ if not 0 <= self.probability <= 1:
+ raise ValueError("policy probability must be between 0 and 1")
+ if (not isinstance(self.actions, list) or not self.actions
+ or any(not isinstance(a, str) or not a.strip() for a in self.actions)):
+ raise ValueError("policy evaluation actions are invalid")
+ self.actions = list(self.actions)
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.metadata)
+
def to_dict(self) -> Dict[str, Any]:
- return asdict(self)
+ return copy.deepcopy(asdict(self))
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "PolicyEvaluation":
+ def from_dict(cls, data: Dict[str, Any]) -> "PolicyEvaluation":
return cls(**data)
@@ -122,6 +168,12 @@ class GenerativeModel:
d_prior: List[float] # Length: len(states)
def __post_init__(self):
+ if not self.states or not self.observations or not self.actions:
+ raise ValueError("generative model requires non-empty states, observations, and actions")
+ if any(not isinstance(x, str) or not x.strip() for x in (*self.states, *self.observations, *self.actions)):
+ raise ValueError("generative model labels must be non-empty strings")
+ if len(self.states) > 128 or len(self.observations) > 128 or len(self.actions) > 128:
+ raise ValueError("generative model exceeds bounds")
num_s = len(self.states)
num_o = len(self.observations)
if len(self.d_prior) != num_s:
@@ -130,6 +182,19 @@ def __post_init__(self):
raise ValueError(f"C preferences length {len(self.c_preferences)} != number of observations {num_o}")
if len(self.a_matrix) != num_o or any(len(row) != num_s for row in self.a_matrix):
raise ValueError(f"A matrix must have shape ({num_o}, {num_s})")
+ for action, matrix in self.b_matrices.items():
+ if action not in self.actions or not isinstance(matrix, list) or len(matrix) != num_s:
+ raise ValueError("B transition matrix has invalid action or shape")
+ if any(not isinstance(row, list) or len(row) != num_s for row in matrix):
+ raise ValueError("B transition matrix has invalid shape")
+ if any(not isinstance(value, (int, float)) or isinstance(value, bool)
+ or not math.isfinite(float(value)) or float(value) < 0
+ for row in matrix for value in row):
+ raise ValueError("B transition matrix contains invalid values")
+ if any(sum(float(matrix[i][j]) for i in range(num_s)) < EPS for j in range(num_s)):
+ raise ValueError("B transition matrix contains an empty transition column")
+ if set(self.b_matrices) != set(self.actions):
+ raise ValueError("B transition matrices must cover every action")
# Normalize columns of A
norm_a = [[0.0] * num_s for _ in range(num_o)]
for s_idx in range(num_s):
@@ -164,8 +229,19 @@ class FreeEnergyReport:
selected_action: str
telemetry: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ if type(self.step) is not int or self.step < 0:
+ raise ValueError("free-energy step must be a non-negative integer")
+ for name in ("variational_free_energy_f", "complexity_kl",
+ "accuracy_log_likelihood", "surprisal_bound"):
+ _finite(getattr(self, name), name)
+ self.belief_state = {str(k): _finite(v, f"belief {k}") for k, v in self.belief_state.items()}
+ self.evaluated_policies = list(self.evaluated_policies)
+ self.telemetry = copy.deepcopy(dict(self.telemetry))
+ canonical_hash(self.telemetry)
+
def to_dict(self) -> Dict[str, Any]:
- return {
+ return copy.deepcopy({
"step": self.step,
"current_observation": self.current_observation,
"belief_state": self.belief_state,
@@ -177,10 +253,10 @@ def to_dict(self) -> Dict[str, Any]:
"selected_policy": self.selected_policy.to_dict(),
"selected_action": self.selected_action,
"telemetry": self.telemetry,
- }
+ })
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "FreeEnergyReport":
+ def from_dict(cls, data: Dict[str, Any]) -> "FreeEnergyReport":
policies = [PolicyEvaluation.from_dict(p) for p in data.get("evaluated_policies", [])]
sel_pol = PolicyEvaluation.from_dict(data["selected_policy"])
return cls(
@@ -210,11 +286,191 @@ def __init__(
generative_model: GenerativeModel,
policy_precision_gamma: float = 16.0,
):
- self.model = generative_model
- self.gamma = policy_precision_gamma
+ if not isinstance(generative_model, GenerativeModel):
+ raise TypeError("generative_model must be GenerativeModel")
+ self.model = copy.deepcopy(generative_model)
+ if not isinstance(policy_precision_gamma, (int, float)) or not math.isfinite(float(policy_precision_gamma)) or policy_precision_gamma < 0 or policy_precision_gamma > 1_000_000:
+ raise ValueError("policy precision is invalid or out of bounds")
+ self.gamma = float(policy_precision_gamma)
self.current_beliefs: List[float] = list(self.model.d_prior)
+ self._belief_commitment: str = canonical_hash(self.current_beliefs)
self.step_count: int = 0
self.history: List[Dict[str, Any]] = []
+ self.last_observation: Optional[str] = None
+ self._observation_commitment: Optional[str] = None
+ self.last_prediction: List[Dict[str, Any]] = []
+ self.last_action: Optional[str] = None
+ self.last_update: Optional[Dict[str, float]] = None
+ self.state_trusted: bool = True
+ self._model_commitment: str = canonical_hash(self.model.to_dict())
+ self._prediction_commitment: Optional[str] = None
+ self._prediction_observation_commitment: Optional[str] = None
+ self._policy_commitments: Dict[str, str] = {}
+ self._policy_refs: Dict[str, Policy] = {}
+ self._last_policies: List[Policy] = []
+ self._action_commitment: Optional[str] = None
+
+ def _ensure_trusted(self) -> None:
+ beliefs_ok = (isinstance(self.current_beliefs, list)
+ and len(self.current_beliefs) == len(self.model.states)
+ and all(isinstance(x, (int, float)) and not isinstance(x, bool)
+ and math.isfinite(float(x)) and float(x) >= 0
+ for x in self.current_beliefs))
+ if (not self.state_trusted
+ or canonical_hash(self.model.to_dict()) != self._model_commitment
+ or not beliefs_ok
+ or canonical_hash(self.current_beliefs) != self._belief_commitment):
+ self.state_trusted = False
+ raise ValueError("active-inference state is untrusted")
+
+ def _validate_evaluations(self, evaluations: Any, *, require_bound: bool = True) -> List[PolicyEvaluation]:
+ if not isinstance(evaluations, list) or not evaluations or len(evaluations) > 10_000:
+ raise ValueError("predictions must be a non-empty bounded list")
+ normalized: List[PolicyEvaluation] = []
+ seen: Set[str] = set()
+ for item in evaluations:
+ if not isinstance(item, PolicyEvaluation):
+ raise TypeError("predictions must contain PolicyEvaluation records")
+ if item.policy_id in seen or not item.actions or any(a not in self.model.actions for a in item.actions):
+ raise ValueError("prediction contains a duplicate or invalid action")
+ # The object returned by predict is caller-mutable. Compare it to
+ # the private snapshot before allowing it to drive action/update.
+ for value_name in ("expected_free_energy_g", "risk_pragmatic_divergence",
+ "ambiguity_expected_entropy", "epistemic_information_gain",
+ "pragmatic_goal_utility", "probability"):
+ _finite(getattr(item, value_name), value_name)
+ declared_policy_commitment = item.metadata.get("policy_commitment")
+ if declared_policy_commitment is not None and (
+ self._policy_commitments.get(item.policy_id) != declared_policy_commitment
+ or (item.policy_id in self._policy_refs
+ and self._policy_refs[item.policy_id].commitment() != declared_policy_commitment)):
+ raise PermissionError("policy record is not bound to this prediction")
+ seen.add(item.policy_id)
+ normalized.append(item)
+ if require_bound:
+ if (self._prediction_commitment is None
+ or self._prediction_observation_commitment != self._observation_commitment):
+ raise PermissionError("predictions are not bound to the current observation")
+ if canonical_hash([x.to_dict() for x in normalized]) != self._prediction_commitment:
+ raise PermissionError("predictions were modified or are not bound to this engine")
+ return normalized
+
+ def observe(self, observation: str) -> str:
+ """Record an observation from the environment (no self-reported state)."""
+ self._ensure_trusted()
+ if observation not in self.model.observations:
+ raise ValueError(f"Unknown observation '{observation}'. Available: {self.model.observations}")
+ self.last_observation = observation
+ self._observation_commitment = canonical_hash(observation)
+ return observation
+
+ def predict(self, candidate_policies: Optional[List[Policy]] = None) -> List[PolicyEvaluation]:
+ """Predict policy outcomes from the current posterior without acting."""
+ self._ensure_trusted()
+ policies = candidate_policies or [
+ Policy(policy_id=f"policy_{act}", actions=[act], label=f"Execute {act}")
+ for act in self.model.actions
+ ]
+ if not isinstance(policies, list) or len(policies) > 10_000:
+ raise ValueError("too many candidate policies")
+ seen_policy_ids: Set[str] = set()
+ for policy in policies:
+ if not isinstance(policy, Policy):
+ raise TypeError("candidate policies must be Policy records")
+ if policy.policy_id in seen_policy_ids or any(a not in self.model.actions for a in policy.actions):
+ raise ValueError("candidate policy contains duplicate or invalid actions")
+ # Capture the complete policy before evaluation; a caller must not
+ # mutate actions or metadata between predict and act.
+ self._policy_commitments[policy.policy_id] = policy.commitment()
+ self._policy_refs[policy.policy_id] = policy
+ seen_policy_ids.add(policy.policy_id)
+ self._last_policies = list(policies)
+ evaluations = [self.evaluate_policy(policy) for policy in policies]
+ for evaluation, policy in zip(evaluations, policies):
+ evaluation.metadata["policy_commitment"] = self._policy_commitments[policy.policy_id]
+ self.last_prediction = [copy.deepcopy(evaluation.to_dict()) for evaluation in evaluations]
+ self._prediction_commitment = canonical_hash(self.last_prediction)
+ self._prediction_observation_commitment = self._observation_commitment
+ return evaluations
+
+ def act(self, predictions: Optional[List[PolicyEvaluation]] = None) -> str:
+ """Select an action only from an untampered, engine-bound prediction set."""
+ self._ensure_trusted()
+ if predictions is None:
+ if self._prediction_commitment is not None and self.last_prediction:
+ evaluations = [PolicyEvaluation.from_dict(copy.deepcopy(item))
+ for item in self.last_prediction]
+ else:
+ evaluations = self.predict()
+ else:
+ evaluations = predictions
+ self._validate_evaluations(evaluations, require_bound=True)
+ selected = min(evaluations, key=lambda item: item.expected_free_energy_g)
+ if not selected.actions or selected.actions[0] not in self.model.actions:
+ raise ValueError("selected prediction contains an invalid action")
+ self.last_action = selected.actions[0]
+ self._action_commitment = canonical_hash({
+ "action": self.last_action,
+ "predictions": self._prediction_commitment,
+ })
+ return self.last_action
+
+ def update(self, observation: Optional[str] = None) -> Dict[str, float]:
+ """Update beliefs from the bound observation and prediction set only."""
+ self._ensure_trusted()
+ if self.last_observation is None:
+ raise PermissionError("update requires a prior observed environment value")
+ observed = self.last_observation if observation is None else observation
+ if (observed != self.last_observation or observed not in self.model.observations
+ or self._observation_commitment != canonical_hash(self.last_observation)):
+ raise PermissionError("update observation is not bound to the prior observation")
+ if self._prediction_commitment is None or self.last_action is None:
+ raise PermissionError("update requires bound predictions and a selected action")
+ self._validate_evaluations(
+ [PolicyEvaluation.from_dict(item) for item in self.last_prediction],
+ require_bound=True,
+ )
+ if self._action_commitment != canonical_hash({
+ "action": self.last_action, "predictions": self._prediction_commitment}):
+ raise PermissionError("selected action is not bound to the current predictions")
+ f_val, complexity, accuracy = self.update_beliefs(observed)
+ for name, value in (("variational_free_energy_f", f_val),
+ ("complexity_kl", complexity),
+ ("accuracy_log_likelihood", accuracy)):
+ _finite(value, name)
+ self.last_update = {
+ "variational_free_energy_f": f_val,
+ "complexity_kl": complexity,
+ "accuracy_log_likelihood": accuracy,
+ }
+ return dict(self.last_update)
+
+ def observe_predict_act_update(
+ self, observation: str, candidate_policies: Optional[List[Policy]] = None
+ ) -> Dict[str, Any]:
+ """Run the complete observe -> predict -> act -> update lifecycle."""
+ self.observe(observation)
+ predictions = self.predict(candidate_policies)
+ action = self.act(predictions)
+ update = self.update(observation)
+ self.step_count += 1
+ record = {
+ "step": self.step_count, "observation": observation,
+ "policies": [copy.deepcopy(policy.to_dict()) for policy in self._last_policies],
+ "predictions": [item.to_dict() for item in predictions],
+ "action": action, "update": update,
+ }
+ # ``candidate_policies`` is None when defaults were generated; retain
+ # the actual policy records so a restored cycle can revalidate mutable
+ # labels, descriptions, and metadata as well as its actions.
+ if not record["policies"]:
+ record["policies"] = [
+ {"policy_id": item.policy_id, "actions": list(item.actions),
+ "label": "", "description": "", "metadata": {}}
+ for item in predictions
+ ]
+ self.history.append(copy.deepcopy(record))
+ return record
def update_beliefs(self, observation: str) -> Tuple[float, float, float]:
"""
@@ -222,6 +478,7 @@ def update_beliefs(self, observation: str) -> Tuple[float, float, float]:
ln q*(s) = ln p(s) + ln p(o | s) - ln Z
Returns (Free_Energy_F, Complexity_KL, Accuracy_Log_Likelihood).
"""
+ self._ensure_trusted()
if observation not in self.model.observations:
raise ValueError(f"Unknown observation '{observation}'. Available: {self.model.observations}")
@@ -237,6 +494,7 @@ def update_beliefs(self, observation: str) -> Tuple[float, float, float]:
# Posterior beliefs via softmax
self.current_beliefs = _softmax(log_joint)
+ self._belief_commitment = canonical_hash(self.current_beliefs)
# Calculate Variational Free Energy F = Complexity - Accuracy
# Complexity = D_KL(q(s) || p(s))
@@ -257,6 +515,7 @@ def evaluate_policy(self, policy: Policy) -> PolicyEvaluation:
Evaluate Expected Free Energy G(pi) for candidate policy pi:
G(pi) = Risk (Pragmatic Divergence) + Ambiguity (Expected Uncertainty)
"""
+ self._ensure_trusted()
num_s = len(self.model.states)
num_o = len(self.model.observations)
@@ -336,9 +595,16 @@ def select_action(
3. Compute policy posterior distribution P(pi) via softmax(-gamma * G).
4. Select optimal policy and action (Action).
"""
+ self._ensure_trusted()
+ if not isinstance(candidate_policies, list):
+ raise TypeError("candidate_policies must be a list")
self.step_count += 1
# 1. Perception
+ if observation not in self.model.observations:
+ raise ValueError(f"Unknown observation '{observation}'. Available: {self.model.observations}")
+ self.last_observation = observation
+ self._observation_commitment = canonical_hash(observation)
f_val, comp_kl, acc_ll = self.update_beliefs(observation)
if not candidate_policies:
@@ -348,8 +614,24 @@ def select_action(
for act in self.model.actions
]
+ if not candidate_policies or any(not isinstance(p, Policy) or not p.actions
+ or any(a not in self.model.actions for a in p.actions)
+ for p in candidate_policies):
+ raise ValueError("candidate policies contain invalid actions")
+ if len({p.policy_id for p in candidate_policies}) != len(candidate_policies):
+ raise ValueError("candidate policies contain duplicate identifiers")
+ self._last_policies = list(candidate_policies)
+ for policy in candidate_policies:
+ self._policy_commitments[policy.policy_id] = policy.commitment()
+ self._policy_refs[policy.policy_id] = policy
+
# 2. Policy Planning
evaluations = [self.evaluate_policy(p) for p in candidate_policies]
+ for evaluation, policy in zip(evaluations, candidate_policies):
+ evaluation.metadata["policy_commitment"] = self._policy_commitments[policy.policy_id]
+ self.last_prediction = [copy.deepcopy(evaluation.to_dict()) for evaluation in evaluations]
+ self._prediction_commitment = canonical_hash(self.last_prediction)
+ self._prediction_observation_commitment = self._observation_commitment
# 3. Policy Posterior P(pi) = softmax(-gamma * G)
neg_g_values = [-self.gamma * e.expected_free_energy_g for e in evaluations]
@@ -361,6 +643,9 @@ def select_action(
best_eval = min(evaluations, key=lambda e: e.expected_free_energy_g)
best_eval.is_optimal = True
selected_action = best_eval.actions[0] if best_eval.actions else self.model.actions[0]
+ self.last_action = selected_action
+ self._action_commitment = canonical_hash({
+ "action": selected_action, "predictions": self._prediction_commitment})
belief_dict = {
self.model.states[i]: self.current_beliefs[i]
@@ -390,21 +675,164 @@ def select_action(
return report
def to_dict(self) -> Dict[str, Any]:
+ self._ensure_trusted()
return {
"model": self.model.to_dict(),
"gamma": self.gamma,
"current_beliefs": self.current_beliefs,
"step_count": self.step_count,
- "history": self.history,
+ "history": copy.deepcopy(self.history),
+ "last_observation": self.last_observation,
+ "last_prediction": copy.deepcopy(self.last_prediction),
+ "last_action": self.last_action,
+ "last_update": copy.deepcopy(self.last_update),
+ "state_trusted": bool(self.state_trusted),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ActiveInferenceEngine":
- model = GenerativeModel.from_dict(data["model"])
+ """Restore only a structurally and semantically consistent history."""
+ if not isinstance(data, dict) or not isinstance(data.get("model"), dict):
+ raise ValueError("restored inference state requires a model mapping")
+ model = GenerativeModel.from_dict(copy.deepcopy(data["model"]))
engine = cls(generative_model=model, policy_precision_gamma=data.get("gamma", 16.0))
- engine.current_beliefs = list(data.get("current_beliefs", model.d_prior))
- engine.step_count = int(data.get("step_count", 0))
- engine.history = list(data.get("history", []))
+ if data.get("state_trusted", True) is not True:
+ engine.state_trusted = False
+ raise ValueError("restored active-inference state is explicitly untrusted")
+ beliefs = data.get("current_beliefs", model.d_prior)
+ if (not isinstance(beliefs, list) or len(beliefs) != len(model.states)
+ or any(not isinstance(x, (int, float)) or isinstance(x, bool)
+ or not math.isfinite(float(x)) or float(x) < 0 for x in beliefs)):
+ raise ValueError("restored belief state is invalid")
+ total = sum(float(x) for x in beliefs)
+ if total < EPS or abs(total - 1.0) > 1e-6:
+ raise ValueError("restored belief state is not normalized")
+ engine.current_beliefs = [float(x) for x in beliefs]
+
+ step_count = data.get("step_count", 0)
+ if type(step_count) is not int or step_count < 0 or step_count > 1_000_000:
+ raise ValueError("restored inference step count is invalid")
+ history = data.get("history", [])
+ if not isinstance(history, list) or len(history) > 100_000 or len(history) != step_count:
+ raise ValueError("restored inference history/step count mismatch")
+ valid_actions = set(model.actions)
+ valid_observations = set(model.observations)
+ for index, record in enumerate(history, 1):
+ if not isinstance(record, dict) or record.get("step") != index:
+ raise ValueError("restored inference history has invalid step ordering")
+ observation = record.get("observation")
+ action = record.get("action")
+ if observation not in valid_observations or action not in valid_actions:
+ raise ValueError("restored inference history has invalid observation/action")
+ policies_data = record.get("policies")
+ if policies_data is not None:
+ if (not isinstance(policies_data, list) or not policies_data
+ or len(policies_data) > 10_000):
+ raise ValueError("restored inference history has invalid policy records")
+ policies = [Policy.from_dict(copy.deepcopy(item)) for item in policies_data]
+ if len({p.policy_id for p in policies}) != len(policies):
+ raise ValueError("restored policy list contains duplicates")
+ if any(any(a not in valid_actions for a in p.actions) for p in policies):
+ raise ValueError("restored policy contains an invalid action")
+ predictions = record.get("predictions")
+ if not isinstance(predictions, list) or not predictions:
+ raise ValueError("restored inference history lacks policy predictions")
+ policy_ids = set()
+ for prediction in predictions:
+ if not isinstance(prediction, dict) or not isinstance(prediction.get("policy_id"), str):
+ raise ValueError("restored policy prediction is malformed")
+ if prediction["policy_id"] in policy_ids:
+ raise ValueError("restored policy list contains duplicates")
+ policy_ids.add(prediction["policy_id"])
+ actions = prediction.get("actions")
+ if not isinstance(actions, list) or not actions or any(a not in valid_actions for a in actions):
+ raise ValueError("restored policy contains an invalid action")
+ for key in ("expected_free_energy_g", "risk_pragmatic_divergence",
+ "ambiguity_expected_entropy", "epistemic_information_gain",
+ "pragmatic_goal_utility", "probability"):
+ value = prediction.get(key)
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)):
+ raise ValueError("restored policy contains a non-finite metric")
+ update = record.get("update")
+ if not isinstance(update, dict) or any(
+ not isinstance(update.get(key), (int, float)) or isinstance(update.get(key), bool)
+ or not math.isfinite(float(update.get(key)))
+ for key in ("variational_free_energy_f", "complexity_kl", "accuracy_log_likelihood")):
+ raise ValueError("restored inference update is malformed")
+ engine.step_count = step_count
+ engine.history = copy.deepcopy(history)
+ last_observation = data.get("last_observation")
+ if last_observation is not None and last_observation not in valid_observations:
+ raise ValueError("restored last observation is invalid")
+ last_action = data.get("last_action")
+ if last_action is not None and last_action not in valid_actions:
+ raise ValueError("restored last action is invalid")
+ last_prediction = data.get("last_prediction", [])
+ if not isinstance(last_prediction, list):
+ raise ValueError("restored last prediction must be a list")
+ # Validate the same prediction schema by using a synthetic history row.
+ if last_prediction:
+ policy_ids = set()
+ for prediction in last_prediction:
+ if (not isinstance(prediction, dict) or not isinstance(prediction.get("policy_id"), str)
+ or prediction["policy_id"] in policy_ids
+ or not isinstance(prediction.get("actions"), list)
+ or not prediction["actions"]
+ or any(a not in valid_actions for a in prediction["actions"])):
+ raise ValueError("restored last policy list is invalid")
+ policy_ids.add(prediction["policy_id"])
+ for key in ("expected_free_energy_g", "risk_pragmatic_divergence",
+ "ambiguity_expected_entropy", "epistemic_information_gain",
+ "pragmatic_goal_utility", "probability"):
+ value = prediction.get(key)
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)):
+ raise ValueError("restored last policy metric is invalid")
+ last_update = data.get("last_update")
+ if last_update is not None and (not isinstance(last_update, dict) or any(
+ not isinstance(last_update.get(key), (int, float)) or isinstance(last_update.get(key), bool)
+ or not math.isfinite(float(last_update.get(key)))
+ for key in ("variational_free_energy_f", "complexity_kl", "accuracy_log_likelihood"))):
+ raise ValueError("restored last update is invalid")
+ if step_count == 0 and any(x is not None for x in (last_observation, last_action, last_update)):
+ raise ValueError("empty restored history cannot have last-step state")
+ engine.last_observation = last_observation
+ engine._observation_commitment = (canonical_hash(last_observation)
+ if last_observation is not None else None)
+ engine.last_prediction = copy.deepcopy(last_prediction)
+ engine.last_action = last_action
+ engine.last_update = copy.deepcopy(last_update)
+
+ # Recompute every recorded cycle from the model, rather than trusting
+ # persisted predictions, actions, metrics, or beliefs. A structurally
+ # valid but numerically edited checkpoint therefore cannot become a
+ # trusted inference state.
+ recomputed = cls(generative_model=copy.deepcopy(model), policy_precision_gamma=engine.gamma)
+ for record in history:
+ if record.get("policies") is not None:
+ policies = [Policy.from_dict(copy.deepcopy(p)) for p in record["policies"]]
+ else:
+ policies = [Policy(policy_id=p["policy_id"], actions=list(p["actions"]))
+ for p in record["predictions"]]
+ expected = recomputed.observe_predict_act_update(record["observation"], policies)
+ if canonical_hash(expected) != canonical_hash(record):
+ raise ValueError("restored inference metrics do not match recomputed predictions")
+ if engine.current_beliefs != recomputed.current_beliefs:
+ raise ValueError("restored belief state does not match recomputed history")
+ if last_observation != recomputed.last_observation:
+ raise ValueError("restored last observation does not match recomputed state")
+ if step_count and (not last_prediction
+ or canonical_hash(last_prediction) != canonical_hash(recomputed.last_prediction)):
+ raise ValueError("restored last prediction does not match recomputed state")
+ if last_action != recomputed.last_action or last_update != recomputed.last_update:
+ raise ValueError("restored last action/update does not match recomputed state")
+ engine._belief_commitment = canonical_hash(engine.current_beliefs)
+ engine._prediction_commitment = recomputed._prediction_commitment
+ engine._prediction_observation_commitment = recomputed._prediction_observation_commitment
+ engine._policy_commitments = dict(recomputed._policy_commitments)
+ engine._policy_refs = dict(recomputed._policy_refs)
+ engine._last_policies = list(recomputed._last_policies)
+ engine._action_commitment = recomputed._action_commitment
+ engine.state_trusted = True
return engine
@@ -477,3 +905,32 @@ def create_default_architecture_pomdp() -> GenerativeModel:
c_preferences=c_pref,
d_prior=d_prior,
)
+
+# Auditable System 3 coding-loop helpers. Kept in this shipped module so
+# package manifests cannot silently omit the anti-laziness primitives.
+def prediction_error(predicted: Any, observed: Any) -> float:
+ """Compute bounded error from values, never from a caller success claim."""
+ if isinstance(predicted, bool) or isinstance(observed, bool):
+ return 0.0 if predicted == observed else 1.0
+ if isinstance(predicted, (int, float)) and isinstance(observed, (int, float)):
+ p, o = _finite(predicted, "predicted outcome"), _finite(observed, "observed outcome")
+ return min(1.0, abs(p - o) / max(1.0, abs(p), abs(o)))
+ return 0.0 if canonical_hash(predicted) == canonical_hash(observed) else 1.0
+
+
+def revise_belief(confidence: float, error: float) -> Dict[str, float]:
+ prior, err = _finite(confidence, "confidence"), _finite(error, "prediction_error")
+ if not 0.0 <= prior <= 1.0 or not 0.0 <= err <= 1.0:
+ raise ValueError("belief inputs must be in [0, 1]")
+ return {"prior_confidence": prior, "posterior_confidence": max(0.0, min(1.0, prior * (1.0 - err))),
+ "prediction_error": err}
+
+
+def policy_revision(action: str, error: float, revision_index: int) -> Dict[str, Any]:
+ if not isinstance(action, str) or not action.strip():
+ raise ValueError("action must be non-empty")
+ err = _finite(error, "prediction_error")
+ if not 0.0 <= err <= 1.0 or type(revision_index) is not int or revision_index < 1:
+ raise ValueError("invalid policy revision inputs")
+ payload = {"action": action.strip(), "prediction_error": err, "revision_index": revision_index}
+ return {**payload, "revision_id": canonical_hash(payload)}
diff --git a/fable_v2/system3/hyperbolic.py b/fable_v2/system3/hyperbolic.py
index e86d335..e0cce6b 100644
--- a/fable_v2/system3/hyperbolic.py
+++ b/fable_v2/system3/hyperbolic.py
@@ -57,8 +57,10 @@ class HyperbolicPoint:
curvature: float = 1.0
def __post_init__(self):
- if self.curvature <= 0.0:
- raise ValueError(f"Hyperbolic curvature must be positive (c > 0), got {self.curvature}")
+ if not math.isfinite(float(self.curvature)) or self.curvature <= 0.0:
+ raise ValueError(f"Hyperbolic curvature must be positive and finite (c > 0), got {self.curvature}")
+ if not self.coords or len(self.coords) > 128 or any(not math.isfinite(float(x)) for x in self.coords):
+ raise ValueError("hyperbolic coordinates must be finite and bounded")
n_sq = _norm_sq(self.coords)
max_sq = 1.0 / self.curvature
if n_sq >= max_sq:
@@ -100,9 +102,9 @@ class PoincareBall:
"""
def __init__(self, dimension: int = 2, curvature: float = 1.0):
- if dimension < 1:
- raise ValueError(f"Dimension must be >= 1, got {dimension}")
- if curvature <= 0.0:
+ if not isinstance(dimension, int) or dimension < 1 or dimension > 128:
+ raise ValueError(f"Dimension must be a bounded integer >= 1, got {dimension}")
+ if not math.isfinite(float(curvature)) or curvature <= 0.0:
raise ValueError(f"Curvature c must be positive, got {curvature}")
self.dimension = dimension
self.curvature = curvature
@@ -483,12 +485,51 @@ def _parse_nested(node_dict: Dict[str, Any]):
else:
adj[k_str] = []
- all_targets = {target for targets in adj.values() for target in targets}
- roots = [k for k in adj.keys() if k not in all_targets]
+ # Adjacency-list conventions commonly omit leaf keys. A target
+ # appearing in an edge is therefore an implied leaf, not an
+ # undefined reference. Explicit metadata remains strict below:
+ # labels for IDs that do not occur in the resulting hierarchy are
+ # rejected rather than silently ignored.
+ for target in {target for targets in adj.values() for target in targets}:
+ adj.setdefault(target, [])
+ roots = [k for k in adj.keys() if k not in {target for targets in adj.values() for target in targets}]
if roots:
detected_root = roots[0]
- return adj, labels, detected_root
+ unknown_labels = set(labels) - set(adj)
+ if unknown_labels:
+ raise HyperbolicGeometryError("node labels reference undefined nodes")
+
+ # A hierarchy is a rooted DAG/tree, never a cyclic graph. Validate
+ # before the recursive embedding walk (which must not be allowed to
+ # recurse forever on hostile input).
+ if len(adj) > 100_000:
+ raise ValueError("hyperbolic hierarchy is too large")
+ roots = [node for node in adj if node not in {c for children in adj.values() for c in children}]
+ root = str(root_id) if root_id is not None else (detected_root or (roots[0] if roots else None))
+ if root is None or root not in adj:
+ raise HyperbolicGeometryError("hierarchy has no valid root")
+ state: Dict[str, int] = {}
+ def visit(node: str) -> None:
+ if state.get(node) == 1:
+ raise HyperbolicGeometryError("cyclic hyperbolic hierarchy input")
+ if state.get(node) == 2:
+ return
+ state[node] = 1
+ children = adj.get(node, [])
+ if len(children) != len(set(children)):
+ raise HyperbolicGeometryError("duplicate edge in hyperbolic hierarchy")
+ for child in children:
+ if child == node:
+ raise HyperbolicGeometryError("self-cycle in hyperbolic hierarchy")
+ if child not in adj:
+ raise HyperbolicGeometryError("hierarchy references an undefined node")
+ visit(child)
+ state[node] = 2
+ visit(root)
+ if len(state) != len(adj):
+ raise HyperbolicGeometryError("hyperbolic hierarchy contains disconnected nodes")
+ return adj, labels, root
def _compute_distortion(
self,
diff --git a/fable_v2/system3/induction.py b/fable_v2/system3/induction.py
index 7273909..48728eb 100644
--- a/fable_v2/system3/induction.py
+++ b/fable_v2/system3/induction.py
@@ -12,9 +12,12 @@
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
import copy
import hashlib
+import hmac
import json
+import math
import re
import time
+from collections.abc import Mapping
from ..protocol import Evidence, ToolReceipt, canonical_hash, utc_now
@@ -37,8 +40,26 @@ class AxiomProvenance:
falsification_attempts: int = 0
created_at: str = field(default_factory=utc_now)
source_verifier: str = "MetaProofInducer"
+ # Canonical identities of distinct evidence producers, resolved by the
+ # inducer rather than asserted by a model.
+ independent_sources: List[str] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
+ def __post_init__(self) -> None:
+ if not isinstance(self.provenance_id, str) or not self.provenance_id.strip():
+ raise ValueError("provenance_id must be non-empty")
+ if (not isinstance(self.receipt_ids, list) or not isinstance(self.evidence_ids, list)
+ or any(not isinstance(item, str) or not item.strip()
+ for item in (*self.receipt_ids, *self.evidence_ids))):
+ raise TypeError("provenance IDs must be non-empty strings")
+ if len(set(self.receipt_ids)) != len(self.receipt_ids) or len(set(self.evidence_ids)) != len(self.evidence_ids):
+ raise ValueError("provenance IDs must be distinct; duplicate empirical inputs are not independent")
+ if (type(self.empirical_samples) is not int or type(self.falsification_attempts) is not int
+ or self.empirical_samples < 0 or self.falsification_attempts < 0):
+ raise ValueError("provenance counts must be non-negative integers")
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.metadata)
+
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@@ -62,58 +83,101 @@ class NeuroSymbolicAxiom:
counter_examples: List[str] = field(default_factory=list)
proof_sketch: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
+ # Axiom objects remain mutable for source compatibility, but every trusted
+ # operation verifies this seal. It is deliberately not serialized.
+ _sealed_hash: str = field(default="", init=False, repr=False, compare=False)
+ _verified_token: bool = field(default=False, init=False, repr=False, compare=False)
+
+ def __post_init__(self) -> None:
+ allowed_domains = {"architecture", "concurrency", "security", "integrity", "performance", "causal", "temporal"}
+ if not isinstance(self.domain, str) or self.domain not in allowed_domains:
+ raise ValueError("axiom domain is not a recognized typed invariant domain")
+ if not isinstance(self.status, AxiomStatus):
+ self.status = AxiomStatus(self.status)
+ if (not isinstance(self.confidence, (int, float)) or isinstance(self.confidence, bool)
+ or not math.isfinite(float(self.confidence)) or not 0 <= self.confidence <= 1):
+ raise ValueError("axiom confidence must be a finite number between 0 and 1")
+ # PROVEN is a derived lifecycle state. It can only be reached by
+ # MetaProofInducer.verify_axiom_empirically after resolving concrete
+ # receipts/evidence; serialized or caller-constructed PROVEN objects
+ # are never accepted as proof.
+ if self.status == AxiomStatus.PROVEN:
+ raise ValueError("PROVEN axioms must be produced by validated empirical verification")
+ self.boundary_conditions = copy.deepcopy(dict(self.boundary_conditions))
+ self.counter_examples = list(self.counter_examples)
+ self.metadata = copy.deepcopy(dict(self.metadata))
+ canonical_hash(self.boundary_conditions)
+ canonical_hash(self.metadata)
+ self._seal()
+
+ def _seal(self) -> None:
+ object.__setattr__(self, "_sealed_hash", canonical_hash(self.to_dict()))
+ object.__setattr__(self, "_verified_token", self.status is AxiomStatus.PROVEN)
+
+ def _validate_seal(self) -> None:
+ """Reject post-induction edits before evaluation or publication."""
+ try:
+ current = canonical_hash(self.to_dict())
+ except Exception as exc:
+ raise PermissionError("axiom status or provenance is malformed") from exc
+ if self.status is AxiomStatus.PROVEN and not self._verified_token:
+ raise PermissionError("PROVEN status is not an authenticated inducer result")
+ if not self._sealed_hash or not hmac.compare_digest(current, self._sealed_hash):
+ raise PermissionError("axiom or proof provenance was mutated after sealing")
def evaluate(self, state: Dict[str, Any]) -> bool:
+ """Evaluate a typed semantic predicate without vacuous defaults.
+
+ Every supported predicate declares and validates its input fields. An
+ unknown expression is rejected rather than silently returning True.
"""
- Evaluate if a specific execution state satisfies this axiom.
- Falls back to symbolic keyword evaluation if no compiled predicate.
- """
- # Pre-programmed symbolic evaluators based on axiom patterns
+ self._validate_seal()
+ if not isinstance(state, Mapping):
+ raise TypeError("predicate state must be a mapping")
sym = self.symbolic_expression.lower()
- # 1. Token Ratio Bound: ratio <= 0.003
- if "ratio" in sym and "<=" in sym and "0.003" in sym:
- val = float(state.get("token_ratio", state.get("ratio", 0.0)))
- chars = int(state.get("raw_chars", state.get("length", 10000)))
- if chars >= 10000:
- return val <= 0.003001
- return True
-
- # 2. Hard Lock Precondition: remaining > 0 => can_execute == False
+ def finite_number(name: str) -> float:
+ value = state.get(name)
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)):
+ raise ValueError(f"predicate requires finite numeric field: {name}")
+ return float(value)
+
+ def required(name: str) -> Any:
+ if name not in state:
+ raise ValueError(f"predicate requires field: {name}")
+ return state[name]
+
+ if "tokenratio" in sym or ("ratio" in sym and "0.003" in sym):
+ chars = finite_number("raw_chars")
+ ratio = finite_number("token_ratio")
+ if chars < 0 or ratio < 0:
+ raise ValueError("token ratio fields must be non-negative")
+ return chars < 10000 or ratio <= 0.003
if "authority_remaining" in sym or "pacing_remaining" in sym:
- rem = float(state.get("authority_remaining_seconds", state.get("remaining_seconds", 0.0)))
- can_exec = bool(state.get("can_execute_code", False))
- if rem > 0.0:
- return not can_exec
- return True
-
- # 3. Content Integrity: SHA256(content) == content_hash
+ remaining = finite_number("authority_remaining_seconds")
+ can_execute = required("can_execute_code")
+ if type(can_execute) is not bool:
+ raise TypeError("can_execute_code must be boolean")
+ return remaining <= 0.0 or not can_execute
if "sha256" in sym or "content_hash" in sym:
- content = state.get("content")
- c_hash = state.get("content_hash")
- if content is not None and c_hash:
- computed = hashlib.sha256(
- content.encode("utf-8") if isinstance(content, str) else bytes(content)
- ).hexdigest()
- return computed == c_hash
- return True
-
- # 4. Monotonic Non-Increase: R_{k+1} <= R_k
+ content = required("content")
+ claimed = required("content_hash")
+ if not isinstance(claimed, str) or not claimed:
+ raise ValueError("content_hash must be non-empty")
+ return canonical_hash(content) == claimed
if "r_{k+1}" in sym or "residual" in sym:
- prev_res = float(state.get("prev_residual", 1.0))
- curr_res = float(state.get("curr_residual", 0.5))
- return curr_res <= prev_res + 1e-6
-
- # 5. DAG Acyclicity: Cycles == 0
+ previous = finite_number("prev_residual")
+ current = finite_number("curr_residual")
+ return previous >= 0 and current >= 0 and current <= previous + 1e-6
if "dag" in sym or "acyclic" in sym:
- cycles = int(state.get("cycle_count", 0))
+ cycles = required("cycle_count")
+ if type(cycles) is not int or isinstance(cycles, bool) or cycles < 0:
+ raise TypeError("cycle_count must be a non-negative integer")
return cycles == 0
-
- # Default fallback
- return True
+ raise ValueError("axiom has no registered typed semantic predicate")
def to_dict(self) -> Dict[str, Any]:
- return {
+ return copy.deepcopy({
"axiom_id": self.axiom_id,
"name": self.name,
"symbolic_expression": self.symbolic_expression,
@@ -126,10 +190,10 @@ def to_dict(self) -> Dict[str, Any]:
"counter_examples": self.counter_examples,
"proof_sketch": self.proof_sketch,
"metadata": self.metadata,
- }
+ })
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "NeuroSymbolicAxiom":
+ def from_dict(cls, data: Dict[str, Any]) -> "NeuroSymbolicAxiom":
data_copy = dict(data)
if "status" in data_copy and isinstance(data_copy["status"], str):
data_copy["status"] = AxiomStatus(data_copy["status"])
@@ -147,6 +211,8 @@ class MetaProofInducer:
def __init__(self):
self.induced_axioms: Dict[str, NeuroSymbolicAxiom] = {}
+ self._receipts: Dict[str, ToolReceipt] = {}
+ self._evidence: Dict[str, Evidence] = {}
def induce_axioms_from_session(
self,
@@ -164,20 +230,58 @@ def induce_axioms_from_session(
- Subagent contract completeness
"""
axioms: List[NeuroSymbolicAxiom] = []
- receipt_ids = [r.receipt_id for r in receipts]
- evidence_ids = [e.evidence_id for e in evidence]
+ if len(receipts) > 10000 or len(evidence) > 10000:
+ raise ValueError("induction input exceeds bounds")
+ # Resolve provenance against concrete session objects. Never accept an
+ # ID merely because it was mentioned by telemetry.
+ self._receipts = {}
+ self._evidence = {}
+ for receipt in receipts:
+ if not isinstance(receipt, ToolReceipt) or receipt.receipt_id in self._receipts:
+ raise ValueError("invalid or duplicate induction receipt")
+ if not receipt.success:
+ continue
+ self._receipts[receipt.receipt_id] = receipt
+ for item in evidence:
+ if not isinstance(item, Evidence) or item.evidence_id in self._evidence:
+ raise ValueError("invalid or duplicate induction evidence")
+ receipt = self._receipts.get(item.receipt_id)
+ if (receipt is None or item.session_id != receipt.session_id
+ or item.content_hash != receipt.output_hash
+ or item.source_output_hash != receipt.output_hash):
+ raise ValueError("induction evidence has unresolved receipt provenance")
+ self._evidence[item.evidence_id] = item
+ receipt_ids = list(self._receipts)
+ evidence_ids = list(self._evidence)
+ # Older callers passed a phase snapshot as a request for baseline
+ # architecture axioms. Preserve that discovery convenience, while
+ # keeping the resulting axioms INDUCED (never proven) until concrete
+ # receipt/evidence provenance is supplied.
+ legacy_baseline = (not receipts and not evidence and isinstance(session_telemetry, Mapping)
+ and isinstance(session_telemetry.get("active_phase"), str))
+ def producer_key(receipt: ToolReceipt) -> str:
+ return canonical_hash({
+ "executable_identity": dict(receipt.executable_identity),
+ "workspace_identity": dict(receipt.workspace_identity),
+ })
+ def provenance(prefix: str, rids: List[str], eids: List[str], samples: int) -> AxiomProvenance:
+ sources = sorted({producer_key(self._receipts[self._evidence[e].receipt_id])
+ for e in eids if e in self._evidence})
+ return AxiomProvenance(provenance_id=f"prov_{prefix}_{len(axioms)+1}",
+ receipt_ids=rids, evidence_ids=eids, empirical_samples=samples,
+ falsification_attempts=3, independent_sources=sources)
# 1. Induce Content-Addressed Storage Integrity Axiom
- cas_receipts = [r for r in receipts if "cas" in r.capability.lower() or "compress" in r.tool_name.lower()]
- if (cas_receipts or any("cas" in e.source.lower() for e in evidence)
- or session_telemetry
- or (domain_hints and any(d.lower() in ("integrity", "architecture", "storage") for d in domain_hints))):
+ cas_receipts = [r for r in self._receipts.values() if "cas" in r.capability.lower() or "compress" in r.tool_name.lower()]
+ cas_evidence = [e for e in self._evidence.values() if "cas" in e.source.lower() or "storage" in e.kind.lower()]
+ if cas_receipts or cas_evidence or legacy_baseline:
prov = AxiomProvenance(
provenance_id=f"prov_cas_{len(axioms)+1}",
- receipt_ids=receipt_ids[:5],
- evidence_ids=evidence_ids[:5],
- empirical_samples=max(1, len(cas_receipts) + len(evidence)),
+ receipt_ids=list(dict.fromkeys([r.receipt_id for r in cas_receipts[:5]] + [e.receipt_id for e in cas_evidence[:5]])),
+ evidence_ids=[e.evidence_id for e in cas_evidence[:5]],
+ empirical_samples=len(cas_receipts) + len(cas_evidence),
falsification_attempts=5,
+ independent_sources=sorted({producer_key(self._receipts[e.receipt_id]) for e in cas_evidence}),
)
ax_cas = NeuroSymbolicAxiom(
axiom_id="AXIOM-CAS-001",
@@ -185,7 +289,7 @@ def induce_axioms_from_session(
symbolic_expression="forall data in Payloads: get_text(put(data)) == data and SHA256(data) == cas_uri.hash",
natural_language="Content-Addressed Storage guarantees 100% bit-exact lossless recovery and tamper-proof content hashing.",
domain="integrity",
- status=AxiomStatus.PROVEN,
+ status=AxiomStatus.INDUCED,
confidence=1.0,
provenance=prov,
boundary_conditions={"min_length": 0, "max_length": 100_000_000, "encoding": "UTF-8"},
@@ -199,12 +303,13 @@ def induce_axioms_from_session(
# 2. Induce Token Compaction Boundedness Invariant
comp_receipts = [r for r in receipts if "compress" in r.tool_name.lower() or "slice" in r.tool_name.lower()]
- if comp_receipts or session_telemetry:
+ comp_evidence = [e for e in self._evidence.values() if "compress" in e.source.lower() or "token" in e.kind.lower()]
+ if comp_receipts or comp_evidence or legacy_baseline:
prov = AxiomProvenance(
provenance_id=f"prov_tok_{len(axioms)+1}",
- receipt_ids=receipt_ids[:5],
- evidence_ids=evidence_ids[:5],
- empirical_samples=max(1, len(comp_receipts)),
+ receipt_ids=list(dict.fromkeys([r.receipt_id for r in comp_receipts[:5]] + [e.receipt_id for e in comp_evidence[:5]])),
+ evidence_ids=[e.evidence_id for e in comp_evidence[:5]],
+ empirical_samples=max(1, len(comp_receipts) + len(comp_evidence)),
falsification_attempts=10,
)
ax_tok = NeuroSymbolicAxiom(
@@ -213,7 +318,7 @@ def induce_axioms_from_session(
symbolic_expression="forall payload in Texts (|payload| >= 10000): TokenRatio(compress(payload)) <= 0.003 tokens/char",
natural_language="Large payloads stored as Content-Addressed pointers achieve <= 0.003 effective tokens per raw character.",
domain="performance",
- status=AxiomStatus.PROVEN,
+ status=AxiomStatus.INDUCED,
confidence=0.99,
provenance=prov,
boundary_conditions={"payload_min_chars": 10000, "target_ratio": 0.003},
@@ -225,12 +330,14 @@ def induce_axioms_from_session(
axioms.append(ax_tok)
# 3. Induce Hard Authority Time-Lock Invariant
- if session_telemetry:
+ lock_receipts = [r for r in self._receipts.values() if "lock" in r.capability.lower() or "authority" in r.capability.lower()]
+ lock_evidence = [e for e in self._evidence.values() if "lock" in e.kind.lower() or "authority" in e.kind.lower()]
+ if lock_receipts or lock_evidence or legacy_baseline:
prov = AxiomProvenance(
provenance_id=f"prov_lock_{len(axioms)+1}",
- receipt_ids=receipt_ids[:2],
- evidence_ids=evidence_ids[:2],
- empirical_samples=len(session_telemetry.get("phase_history", [])) + 1,
+ receipt_ids=list(dict.fromkeys([r.receipt_id for r in lock_receipts[:2]] + [e.receipt_id for e in lock_evidence[:2]])),
+ evidence_ids=[e.evidence_id for e in lock_evidence[:2]],
+ empirical_samples=len(lock_receipts) + len(lock_evidence),
falsification_attempts=3,
)
ax_lock = NeuroSymbolicAxiom(
@@ -239,7 +346,7 @@ def induce_axioms_from_session(
symbolic_expression="forall t < AuthorityDeadline: can_execute_code(t) == False (unless out-of-band override)",
natural_language="Code execution in codebase is strictly locked until the immutable outer authority time budget elapses.",
domain="security",
- status=AxiomStatus.PROVEN,
+ status=AxiomStatus.INDUCED,
confidence=1.0,
provenance=prov,
boundary_conditions={"min_time_budget_minutes": 0.1, "requires_proven_count": 2, "requires_invariant_count": 1},
@@ -250,36 +357,98 @@ def induce_axioms_from_session(
)
axioms.append(ax_lock)
- # 4. Induce Dialectical Monotonic Contradiction Convergence Invariant
- prov = AxiomProvenance(
- provenance_id=f"prov_conv_{len(axioms)+1}",
- receipt_ids=receipt_ids[:2],
- evidence_ids=evidence_ids[:2],
- empirical_samples=4,
- falsification_attempts=8,
- )
- ax_conv = NeuroSymbolicAxiom(
- axiom_id="AXIOM-DIALECTIC-004",
- name="Monotonic Dialectical Contradiction Convergence",
- symbolic_expression="forall k in DebateRounds: ResidualContradiction(k+1) <= ResidualContradiction(k) * 0.55",
- natural_language="Each dialectical synthesis round strictly decreases residual architectural contradiction severity.",
- domain="architecture",
- status=AxiomStatus.PROVEN,
- confidence=0.95,
- provenance=prov,
- boundary_conditions={"max_rounds": 4, "convergence_threshold": 0.15},
- proof_sketch=(
- "Proof by Induction: Base case R_0 <= 1.0. For inductive step, R_{k+1} = R_k * (0.55 - 0.05*k) <= 0.55 * R_k. "
- "Hence lim_{k->inf} R_k = 0 monotonically."
- ),
- )
- axioms.append(ax_conv)
-
+ # 4. Induce dialectical convergence only when actual architecture
+ # evidence exists; telemetry alone is not provenance.
+ convergence_evidence = [e for e in self._evidence.values()
+ if "architect" in e.kind.lower() or "architect" in e.source.lower()]
+ convergence_receipts = [r for r in self._receipts.values()
+ if "debate" in r.capability.lower() or "synth" in r.capability.lower()]
+ if convergence_evidence or convergence_receipts:
+ prov = AxiomProvenance(
+ provenance_id=f"prov_conv_{len(axioms)+1}",
+ receipt_ids=list(dict.fromkeys([r.receipt_id for r in convergence_receipts[:2]] + [e.receipt_id for e in convergence_evidence[:2]])),
+ evidence_ids=[e.evidence_id for e in convergence_evidence[:2]],
+ empirical_samples=len(convergence_receipts) + len(convergence_evidence),
+ falsification_attempts=8,
+ )
+ ax_conv = NeuroSymbolicAxiom(
+ axiom_id="AXIOM-DIALECTIC-004",
+ name="Monotonic Dialectical Contradiction Convergence",
+ symbolic_expression="forall k in DebateRounds: ResidualContradiction(k+1) <= ResidualContradiction(k) * 0.55",
+ natural_language="Each dialectical synthesis round strictly decreases residual architectural contradiction severity.",
+ domain="architecture", status=AxiomStatus.INDUCED, confidence=0.95,
+ provenance=prov,
+ boundary_conditions={"max_rounds": 4, "convergence_threshold": 0.15},
+ proof_sketch=(
+ "Proof by Induction: Base case R_0 <= 1.0. For inductive step, R_{k+1} = R_k * (0.55 - 0.05*k) <= 0.55 * R_k. "
+ "Hence lim_{k->inf} R_k = 0 monotonically."
+ ),
+ )
+ axioms.append(ax_conv)
+
+ if not axioms:
+ # Compatibility sentinel: this is explicitly a hypothesis about
+ # missing evidence, not an induced domain invariant.
+ axioms.append(NeuroSymbolicAxiom(
+ axiom_id="AXIOM-INSUFFICIENT-EVIDENCE", name="Insufficient Evidence",
+ symbolic_expression="insufficient_evidence",
+ natural_language="No invariant may be induced until concrete provenance is supplied.",
+ domain="architecture", status=AxiomStatus.HYPOTHESIZED, confidence=0.0,
+ provenance=AxiomProvenance("prov_none"), metadata={"non_actionable": True}))
for ax in axioms:
self.induced_axioms[ax.axiom_id] = ax
return axioms
+ def _validate_empirical_inputs(
+ self, axiom: NeuroSymbolicAxiom, test_cases: List[Dict[str, Any]]
+ ) -> tuple[bool, list[dict[str, Any]]]:
+ """Validate that every test is a distinct, receipt-backed observation.
+
+ A predicate state is not evidence merely because it is a Python dict.
+ The explicit receipt/evidence links and boundary labels prevent replaying
+ one observation under several labels or proving only the easy interior.
+ """
+ if not isinstance(test_cases, list) or len(test_cases) < 3:
+ return False, [{"error": "at least three empirical cases are required"}]
+ fingerprints: set[str] = set()
+ covered: set[str] = set()
+ for case in test_cases:
+ if not isinstance(case, Mapping):
+ return False, [{"error": "empirical cases must be mappings"}]
+ rid, eid = case.get("receipt_id"), case.get("evidence_id")
+ if not isinstance(rid, str) or not isinstance(eid, str):
+ return False, [{"error": "each empirical case requires receipt_id and evidence_id"}]
+ receipt = self._receipts.get(rid)
+ evidence = self._evidence.get(eid)
+ if receipt is None or evidence is None or evidence.receipt_id != rid:
+ return False, [{"error": "empirical case provenance is unresolved"}]
+ if (not receipt.success or evidence.content_hash != receipt.output_hash
+ or evidence.source_output_hash != receipt.output_hash
+ or canonical_hash(evidence.content) != receipt.output_hash):
+ return False, [{"error": "empirical case is not tied to receipt/evidence output"}]
+ labels = case.get("boundary_coverage", case.get("boundary", ()))
+ if isinstance(labels, str):
+ labels = (labels,)
+ if not isinstance(labels, (list, tuple, set)) or not labels:
+ return False, [{"error": "each empirical case must declare boundary_coverage"}]
+ covered.update(str(label) for label in labels)
+ state = dict(case.get("state", case)) if isinstance(case.get("state", case), Mapping) else None
+ if state is None:
+ return False, [{"error": "empirical case state must be a mapping"}]
+ for key in ("receipt_id", "evidence_id", "boundary_coverage", "boundary"):
+ state.pop(key, None)
+ # IDs and labels are provenance, not semantic input. Duplicate
+ # semantic observations remain duplicates even with new receipts.
+ fingerprint = canonical_hash(state)
+ if fingerprint in fingerprints:
+ return False, [{"error": "duplicate empirical input"}]
+ fingerprints.add(fingerprint)
+ required = {str(key) for key in axiom.boundary_conditions}
+ if required and not required.issubset(covered):
+ return False, [{"error": "empirical cases do not cover all boundary conditions"}]
+ return True, []
+
def verify_axiom_empirically(
self,
axiom: NeuroSymbolicAxiom,
@@ -290,15 +459,73 @@ def verify_axiom_empirically(
Evaluates the axiom predicate across all test cases.
Updates status to PROVEN if 100% pass with >= 3 samples, or FALSIFIED if counter-example found.
"""
+ try:
+ axiom._validate_seal()
+ except (ValueError, PermissionError) as exc:
+ return False, axiom.confidence, [{"error": str(exc)}]
+ # A legacy, local predicate smoke-test may evaluate a newly constructed
+ # axiom without an attached session registry. It is deliberately
+ # limited to an explicitly sampled provenance count and never applies
+ # to measured receipt/evidence paths.
+ legacy_local_evaluation = (
+ not self._receipts and not self._evidence
+ and axiom.provenance.empirical_samples >= 3
+ and axiom.provenance.falsification_attempts >= 1
+ and (axiom.metadata.get("verified_by_inducer") is not True
+ or axiom.metadata.get("legacy_local_evaluation") is True)
+ )
+ if axiom.status == AxiomStatus.PROVEN:
+ if axiom.metadata.get("verified_by_inducer") is not True:
+ return False, axiom.confidence, [{"error": "PROVEN status is not an authenticated inducer result"}]
+ if not axiom.metadata.get("legacy_local_evaluation"):
+ return False, axiom.confidence, [{"error": "PROVEN axioms are immutable; create a new induction result"}]
+ # Induction is a hypothesis-generating operation, never proof. A
+ # PROVEN status requires an auditable provenance chain and a meaningful
+ # sample size; model/session metadata alone is not evidence.
if not test_cases:
return False, axiom.confidence, []
+ if not legacy_local_evaluation and self.induced_axioms.get(axiom.axiom_id) is not axiom:
+ return False, axiom.confidence, [{"error": "axiom provenance is not resolved in this inducer"}]
+ # The following provenance checks are mandatory for real measured
+ # induction. A legacy local predicate smoke-test has no external
+ # objects to resolve, so it skips only this provenance lookup; it does
+ # not alter strict measured verification behavior.
+ resolved_receipts = [self._receipts.get(rid) for rid in axiom.provenance.receipt_ids]
+ resolved_evidence = [self._evidence.get(eid) for eid in axiom.provenance.evidence_ids]
+ if not legacy_local_evaluation:
+ if (not resolved_receipts or any(r is None or not r.success for r in resolved_receipts)
+ or not resolved_evidence or any(e is None for e in resolved_evidence)):
+ return False, axiom.confidence, [{"error": "external evidence provenance cannot be resolved"}]
+ receipt_map = {r.receipt_id: r for r in resolved_receipts if r is not None}
+ if any(e.receipt_id not in receipt_map for e in resolved_evidence if e is not None):
+ return False, axiom.confidence, [{"error": "evidence is not linked to the axiom receipts"}]
+ if any(e.session_id != receipt_map[e.receipt_id].session_id
+ or e.content_hash != receipt_map[e.receipt_id].output_hash
+ or e.source_output_hash != receipt_map[e.receipt_id].output_hash
+ for e in resolved_evidence if e is not None):
+ return False, axiom.confidence, [{"error": "evidence provenance hash is inconsistent"}]
+ def producer_key(receipt: ToolReceipt) -> str:
+ return canonical_hash({
+ "executable_identity": dict(receipt.executable_identity),
+ "workspace_identity": dict(receipt.workspace_identity),
+ })
+ resolved_producers = {producer_key(receipt_map[e.receipt_id]) for e in resolved_evidence}
+ if axiom.provenance.independent_sources and set(axiom.provenance.independent_sources) != resolved_producers:
+ return False, axiom.confidence, [{"error": "provenance producer identities are fabricated or stale"}]
+ valid_inputs, input_errors = ((True, []) if legacy_local_evaluation
+ else self._validate_empirical_inputs(axiom, test_cases))
+ if not valid_inputs:
+ return False, axiom.confidence, input_errors
passed_count = 0
failed_cases: List[Dict[str, Any]] = []
for case in test_cases:
try:
- result = axiom.evaluate(case)
+ state = dict(case.get("state", case))
+ for key in ("receipt_id", "evidence_id", "boundary_coverage", "boundary"):
+ state.pop(key, None)
+ result = axiom.evaluate(state)
if result:
passed_count += 1
else:
@@ -315,14 +542,25 @@ def verify_axiom_empirically(
if failed_cases:
axiom.status = AxiomStatus.FALSIFIED
axiom.confidence = round(axiom.confidence * pass_ratio, 3)
+ axiom._seal()
return False, pass_ratio, failed_cases
+ # Do not upgrade based on positive cases alone. The caller must have
+ # supplied provenance-backed receipts/evidence (checked above).
axiom.status = AxiomStatus.PROVEN
+ axiom.metadata["provenance_resolved"] = not legacy_local_evaluation
+ axiom.metadata["verified_by_inducer"] = True
+ if legacy_local_evaluation:
+ # Explicitly mark this as a compatibility smoke-test result; it
+ # must never be treated as measured proof by strict consumers.
+ axiom.metadata["legacy_local_evaluation"] = True
axiom.confidence = min(1.0, round(0.90 + 0.10 * (passed_count / max(10, passed_count)), 3))
+ axiom._seal()
return True, pass_ratio, []
def formalize_to_proof_sketch(self, axiom: NeuroSymbolicAxiom) -> str:
"""Format formal mathematical proof sketch for publication or invariant record."""
+ axiom._validate_seal()
return (
f"### 📜 Formal Neuro-Symbolic Proof Sketch: {axiom.name} ({axiom.axiom_id})\n\n"
f"- **Formal Statement**: `{axiom.symbolic_expression}`\n"
diff --git a/fable_v2/verifiers.py b/fable_v2/verifiers.py
index 62c3484..5cd8034 100644
--- a/fable_v2/verifiers.py
+++ b/fable_v2/verifiers.py
@@ -1,16 +1,447 @@
-"""Composable verifier contracts for Fable V2.
+"""Typed, fail-closed verifier primitives for Fable V2.
-Deterministic verifiers should run before model-based judges. A verifier may
-be backed by tests, a compiler, a citation checker, a schema validator, or an
-independent model; the runtime only accepts explicit VerificationResult data.
+The objects in this module are deliberately deterministic orchestration
+contracts. A model may suggest a claim or a check, but it cannot turn an
+UNKNOWN into a PASS and it is never the final authority for adjudication.
+The original :class:`FunctionVerifier` and :class:`CompositeVerifier` APIs are
+kept intact.
"""
from __future__ import annotations
-from dataclasses import dataclass
-from typing import Callable, Iterable, Protocol
+from dataclasses import dataclass, field
+from enum import Enum
+import copy
+import hashlib
+import math
+import re
+from typing import Any, Callable, Iterable, Mapping, Protocol, Sequence
-from .protocol import Candidate, VerificationResult
+from .protocol import (
+ Candidate,
+ TaskSpec,
+ VerificationPolicy,
+ VerificationResult,
+ VerificationStatus,
+ canonical_hash,
+)
+
+
+class RiskLevel(str, Enum):
+ LOW = "LOW"
+ MEDIUM = "MEDIUM"
+ HIGH = "HIGH"
+ CRITICAL = "CRITICAL"
+
+ @property
+ def rank(self) -> int:
+ return {RiskLevel.LOW: 1, RiskLevel.MEDIUM: 2,
+ RiskLevel.HIGH: 3, RiskLevel.CRITICAL: 4}[self]
+
+
+@dataclass(frozen=True)
+class Claim:
+ """One atomic, scoped and falsifiable assertion in a task."""
+
+ claim_id: str
+ text: str
+ predicate: str
+ risk: RiskLevel | str
+ scope: str
+ falsification: str
+ source: str = ""
+ dependencies: tuple[str, ...] = ()
+
+ def __post_init__(self) -> None:
+ for name in ("claim_id", "text", "predicate", "scope", "falsification"):
+ value = getattr(self, name)
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"{name} must be a non-empty string")
+ try:
+ risk = self.risk if isinstance(self.risk, RiskLevel) else RiskLevel(str(self.risk).upper())
+ except ValueError as exc:
+ raise ValueError("risk must be LOW, MEDIUM, HIGH, or CRITICAL") from exc
+ object.__setattr__(self, "risk", risk)
+ object.__setattr__(self, "dependencies", tuple(str(x) for x in self.dependencies))
+
+ @property
+ def risk_level(self) -> RiskLevel:
+ return self.risk # type: ignore[return-value]
+
+
+_ATOMIC_SPLIT = re.compile(r"(?:\r?\n|\s*;\s*|\s+(?:&&|and)\s+)", re.IGNORECASE)
+# Phrases here describe operations whose failure can cause material, security,
+# privacy, or irreversible harm. This is deliberately an intrinsic floor: a
+# task's metadata may raise the risk classification, but never lower it.
+_CRITICAL_WORDS = re.compile(
+ r"(?:\b(?:safety|security|secure(?:ly)?|privacy|financial|money|irreversible|"
+ r"delete|deletion|deleted|destructive|destroy|destroyed|erase|erasure|"
+ r"remove|removed|wipe|wiped|overwrite|overwritten|drop|truncate|purge|"
+ r"revoke|revocation|credential|credentials|password|secret|token|"
+ r"authentication|authorization|permission|permissions|firewall|malware|"
+ r"ransomware|exfiltrat(?:e|ion)|encrypt(?:ed|ion)?|decrypt(?:ed|ion)?|"
+ r"disclos(?:e|ure)|leak|steal|privilege|root|shell|command|execute|"
+ r"execution|compliance|harm|unsafe|untrusted|vulnerability|exploit|"
+ r"tamper|integrity|sensitive|private|personal|incident)\b"
+ r"|\b(?:rm\s+-rf|format\s+(?:the\s+)?disk|drop\s+(?:the\s+)?database|"
+ r"disable\s+(?:the\s+)?(?:firewall|security)|bypass\s+(?:auth|authentication|authorization|access\s+control)|"
+ r"run\s+(?:arbitrary|untrusted)\s+code|expose\s+(?:private|personal|sensitive)\s+data)\b)",
+ re.I,
+)
+
+
+def _atomic_fragments(text: str) -> tuple[str, ...]:
+ """Split contract prose conservatively; never silently drop a clause.
+
+ ``re.split`` normally discards the empty tail of ``"a and"`` when the
+ caller filters empty strings. That is unsafe for a contract: an
+ apparently covered objective can then lose its final conjunct. Keep the
+ split result long enough to reject leading, trailing, and repeated
+ conjunctions (as well as empty semicolon/newline clauses).
+ """
+ raw = str(text).strip()
+ if not raw:
+ raise ValueError("claim text must contain a clause")
+ # The separator intentionally requires whitespace on both sides for
+ # ordinary prose, so explicitly catch a connector at either boundary
+ # (including punctuation such as ``"first and."``).
+ if re.search(r"(?:^|\s)(?:&&|and)(?:\s|[.!?,;:]*)*$", raw, re.IGNORECASE):
+ raise ValueError("compound contract text contains a missing clause")
+ if re.match(r"^(?:and|&&)(?:\s|[.!?,;:]|$)", raw, re.IGNORECASE):
+ raise ValueError("compound contract text contains a missing clause")
+ pieces = _ATOMIC_SPLIT.split(raw)
+ fragments: list[str] = []
+ for piece in pieces:
+ fragment = piece.strip(" .")
+ if not fragment or re.fullmatch(r"(?:&&|and)", fragment, re.IGNORECASE):
+ raise ValueError("compound contract text contains a missing clause")
+ fragments.append(fragment)
+ return tuple(fragments)
+
+
+def _claim_risk(text: str, source: str, task: TaskSpec) -> RiskLevel:
+ """Infer risk while treating intrinsic danger as a non-downgradable floor."""
+ if _CRITICAL_WORDS.search(text):
+ intrinsic = RiskLevel.CRITICAL
+ elif source in {"definition_of_done", "required_evidence"} or "must" in text.lower() or "required" in text.lower():
+ intrinsic = RiskLevel.HIGH
+ elif source == "constraints":
+ intrinsic = RiskLevel.MEDIUM
+ else:
+ intrinsic = RiskLevel.LOW
+
+ configured = task.metadata.get("risk")
+ if isinstance(configured, str):
+ try:
+ configured_level = RiskLevel(configured.upper())
+ except ValueError:
+ configured_level = intrinsic
+ # Metadata is allowed to make a claim stricter, never safer than its
+ # words and origin warrant. In particular ``risk=LOW`` cannot turn a
+ # destructive/security claim (or a required HIGH claim) into LOW.
+ return configured_level if configured_level.rank >= intrinsic.rank else intrinsic
+ return intrinsic
+
+
+@dataclass(frozen=True)
+class ClaimGraph:
+ """A deterministic graph of atomic claims and their dependencies.
+
+ ``ClaimGraph.from_task`` is the normal entry point. Every task objective,
+ constraint, done condition, capability, and evidence requirement becomes
+ an explicit claim. Missing coverage is therefore observable rather than
+ silently treated as a successful whole-candidate check.
+ """
+
+ claims: tuple[Claim, ...]
+ edges: tuple[tuple[str, str], ...] = ()
+ candidate_id: str = ""
+ task_id: str = ""
+
+ def __post_init__(self) -> None:
+ claims = tuple(self.claims)
+ ids = [claim.claim_id for claim in claims]
+ if len(ids) != len(set(ids)):
+ raise ValueError("claim graph contains duplicate claim ids")
+ known = set(ids)
+
+ # Dependencies are part of each claim's declaration, while ``edges``
+ # is the graph-level representation used by planners and audit code.
+ # Materialize both here so constructing a graph directly cannot silently
+ # erase dependency information (the post-merge regression did exactly
+ # that by accepting the dataclass default ``edges=()``).
+ raw_edges: list[tuple[str, str]] = []
+ for claim in claims:
+ for dependency in claim.dependencies:
+ dependency_id = str(dependency)
+ if dependency_id not in known:
+ raise ValueError("claim dependency references an unknown claim")
+ raw_edges.append((dependency_id, claim.claim_id))
+ raw_edges.extend((str(a), str(b)) for a, b in self.edges)
+ edges: list[tuple[str, str]] = []
+ seen_edges: set[tuple[str, str]] = set()
+ for edge in raw_edges:
+ if edge[0] not in known or edge[1] not in known:
+ raise ValueError("claim graph edge references an unknown claim")
+ if edge not in seen_edges:
+ seen_edges.add(edge)
+ edges.append(edge)
+
+ # A dependency cycle makes the contract ordering ambiguous and can
+ # cause an adjudicator/planner to recurse forever. Validate the full
+ # graph, including explicitly supplied graph-level edges.
+ outgoing: dict[str, list[str]] = {claim_id: [] for claim_id in ids}
+ for source, target in edges:
+ outgoing[source].append(target)
+ visiting: set[str] = set()
+ visited: set[str] = set()
+
+ def visit(node: str) -> None:
+ if node in visiting:
+ raise ValueError("claim graph dependencies must be acyclic")
+ if node in visited:
+ return
+ visiting.add(node)
+ for child in outgoing[node]:
+ visit(child)
+ visiting.remove(node)
+ visited.add(node)
+
+ for claim_id in ids:
+ visit(claim_id)
+ object.__setattr__(self, "claims", claims)
+ object.__setattr__(self, "edges", tuple(edges))
+
+ @classmethod
+ def from_task(cls, task: TaskSpec, candidate: Candidate | None = None) -> "ClaimGraph":
+ if not isinstance(task, TaskSpec):
+ raise TypeError("from_task expects a TaskSpec")
+ entries: list[tuple[str, str]] = [("objective", task.objective)]
+ entries.extend(("constraints", value) for value in task.constraints)
+ entries.extend(("definition_of_done", value) for value in task.definition_of_done)
+ entries.extend(("required_capability", value) for value in task.required_capabilities)
+ entries.extend(("required_evidence", value) for value in task.required_evidence)
+ claims: list[Claim] = []
+ for source, value in entries:
+ for index, fragment in enumerate(_atomic_fragments(value)):
+ # IDs are stable across processes and do not depend on object
+ # identity, making plans and audit records reproducible.
+ digest = hashlib.sha256(f"{task.task_id}|{source}|{index}|{fragment}".encode()).hexdigest()[:16]
+ claim_id = f"claim-{digest}"
+ claims.append(Claim(
+ claim_id=claim_id,
+ text=fragment,
+ predicate=f"satisfies({fragment})",
+ risk=_claim_risk(fragment, source, task),
+ scope=f"candidate:{candidate.candidate_id}" if candidate else f"task:{task.task_id}",
+ falsification=f"An observation showing that '{fragment}' is not true.",
+ source=source,
+ ))
+ edges = tuple((claims[i - 1].claim_id, claim.claim_id)
+ for i, claim in enumerate(claims) if i > 0)
+ return cls(tuple(claims), edges, candidate.candidate_id if candidate else "", task.task_id)
+
+ @classmethod
+ def from_candidate(cls, candidate: Candidate, task: TaskSpec) -> "ClaimGraph":
+ return cls.from_task(task, candidate)
+
+ @classmethod
+ def decompose(cls, candidate: Candidate | TaskSpec, task: TaskSpec | Candidate) -> "ClaimGraph":
+ # Accept both natural call orders (candidate, task) and (task,
+ # candidate) so adapters can migrate without a compatibility shim.
+ if isinstance(candidate, TaskSpec) and isinstance(task, Candidate):
+ return cls.from_task(candidate, task)
+ if isinstance(candidate, Candidate) and isinstance(task, TaskSpec):
+ return cls.from_task(task, candidate)
+ raise TypeError("decompose expects a Candidate and a TaskSpec")
+
+ build = from_task
+
+ @property
+ def claim_ids(self) -> tuple[str, ...]:
+ return tuple(claim.claim_id for claim in self.claims)
+
+ @property
+ def critical_claims(self) -> tuple[Claim, ...]:
+ return tuple(claim for claim in self.claims if claim.risk_level is RiskLevel.CRITICAL)
+
+ def get(self, claim_id: str) -> Claim:
+ for claim in self.claims:
+ if claim.claim_id == claim_id:
+ return claim
+ raise KeyError(claim_id)
+
+
+@dataclass(frozen=True)
+class CalibrationMetrics:
+ """Empirical rates retained for selecting, auditing, and recalibrating checks."""
+
+ false_positive_rate: float = 0.0
+ false_negative_rate: float = 0.0
+ mutation_detection_rate: float = 0.0
+
+ def __post_init__(self) -> None:
+ for name in ("false_positive_rate", "false_negative_rate", "mutation_detection_rate"):
+ value = getattr(self, name)
+ if isinstance(value, bool) or not 0 <= float(value) <= 1:
+ raise ValueError(f"{name} must be between 0 and 1")
+
+
+class _FrozenDict(dict):
+ """A JSON-compatible dict that rejects mutation at every nesting level."""
+
+ def _immutable(self, *args: Any, **kwargs: Any) -> None:
+ raise TypeError("counterexample observation is immutable")
+
+ __setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = _immutable
+ __ior__ = _immutable
+
+ def __deepcopy__(self, memo: dict[int, Any]) -> dict[Any, Any]:
+ # ``dataclasses.asdict`` and callers serializing a counterexample need
+ # deepcopy to work; the returned copy is detached and mutable, while
+ # the observation held by the record remains frozen.
+ return {copy.deepcopy(key, memo): copy.deepcopy(value, memo)
+ for key, value in self.items()}
+
+
+class _FrozenList(list):
+ """A JSON-compatible list that rejects all mutating operations."""
+
+ def _immutable(self, *args: Any, **kwargs: Any) -> None:
+ raise TypeError("counterexample observation is immutable")
+
+ __setitem__ = __delitem__ = append = extend = insert = pop = remove = reverse = sort = _immutable
+ __iadd__ = __imul__ = _immutable
+
+ def __deepcopy__(self, memo: dict[int, Any]) -> list[Any]:
+ return [copy.deepcopy(value, memo) for value in self]
+
+
+def _freeze_observation(value: Any) -> Any:
+ """Deep-copy a JSON observation into an immutable, hashable-by-content tree."""
+ if isinstance(value, Mapping):
+ return _FrozenDict({copy.deepcopy(key): _freeze_observation(child)
+ for key, child in value.items()})
+ if isinstance(value, list):
+ return _FrozenList(_freeze_observation(child) for child in value)
+ if isinstance(value, tuple):
+ return tuple(_freeze_observation(child) for child in value)
+ # canonical_hash validates JSON-compatible values and rejects sets, bytes,
+ # non-finite floats, and custom objects below.
+ return copy.deepcopy(value)
+
+
+@dataclass(frozen=True)
+class Counterexample:
+ """A concrete observation that falsifies one or more claims."""
+
+ counterexample_id: str
+ claim_ids: tuple[str, ...]
+ observation: Any
+ falsifies: str
+ verifier: str = ""
+ evidence_ids: tuple[str, ...] = ()
+ provenance_ids: tuple[str, ...] = ()
+ # Appended for positional compatibility with older counterexample records.
+ observation_hash: str = ""
+
+ def __post_init__(self) -> None:
+ if not self.counterexample_id or not self.falsifies:
+ raise ValueError("counterexample id and falsification are required")
+ object.__setattr__(self, "claim_ids", tuple(str(x) for x in self.claim_ids))
+ object.__setattr__(self, "evidence_ids", tuple(str(x) for x in self.evidence_ids))
+ object.__setattr__(self, "provenance_ids", tuple(str(x) for x in self.provenance_ids))
+ observation = _freeze_observation(self.observation)
+ expected_hash = canonical_hash(observation)
+ if self.observation_hash and self.observation_hash != expected_hash:
+ raise ValueError("observation_hash does not match observation")
+ object.__setattr__(self, "observation", observation)
+ object.__setattr__(self, "observation_hash", expected_hash)
+
+ def validate_integrity(self) -> None:
+ """Re-hash the live observation before using this record.
+
+ The frozen JSON-compatible wrappers protect ordinary callers, but a
+ determined caller can invoke ``dict.__setitem__``/``list.append`` on
+ their base classes (or use ``object.__setattr__`` on this dataclass).
+ Counterexamples are security-relevant hard FAIL inputs, so their hash
+ is checked at every trust boundary rather than treated as immutable by
+ convention.
+ """
+ try:
+ actual = canonical_hash(self.observation)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("counterexample observation is not canonical JSON") from exc
+ if not isinstance(self.observation_hash, str) or actual != self.observation_hash:
+ raise ValueError("counterexample observation hash is stale")
+
+ def to_dict(self) -> dict[str, Any]:
+ """Return a detached, JSON-serializable representation after validation."""
+ self.validate_integrity()
+ return {
+ "counterexample_id": self.counterexample_id,
+ "claim_ids": list(self.claim_ids),
+ "observation": copy.deepcopy(self.observation),
+ "falsifies": self.falsifies,
+ "verifier": self.verifier,
+ "evidence_ids": list(self.evidence_ids),
+ "provenance_ids": list(self.provenance_ids),
+ "observation_hash": self.observation_hash,
+ }
+
+
+class CounterexampleStore:
+ """Small explicit store that preserves and propagates counterexamples."""
+
+ def __init__(self, counterexamples: Iterable[Counterexample] = ()) -> None:
+ self._items: dict[str, Counterexample] = {}
+ for item in counterexamples:
+ self.add(item)
+
+ def add(self, counterexample: Counterexample) -> Counterexample:
+ if not isinstance(counterexample, Counterexample):
+ raise TypeError("counterexample must be a Counterexample")
+ counterexample.validate_integrity()
+ prior = self._items.get(counterexample.counterexample_id)
+ if prior is not None:
+ prior.validate_integrity()
+ if prior != counterexample:
+ raise ValueError("counterexample id already contains different data")
+ self._items[counterexample.counterexample_id] = counterexample
+ return counterexample
+
+ def add_many(self, counterexamples: Iterable[Counterexample]) -> tuple[Counterexample, ...]:
+ return tuple(self.add(item) for item in counterexamples)
+
+ record = add
+
+ def get(self, counterexample_id: str) -> Counterexample:
+ item = self._items[counterexample_id]
+ item.validate_integrity()
+ return item
+
+ def for_claim(self, claim_id: str) -> tuple[Counterexample, ...]:
+ items = tuple(item for item in self._items.values() if claim_id in item.claim_ids)
+ for item in items:
+ item.validate_integrity()
+ return items
+
+ def propagate(self, claim_ids: Iterable[str] | Any) -> tuple[Counterexample, ...]:
+ if hasattr(claim_ids, "claim_ids"):
+ claim_ids = getattr(claim_ids, "claim_ids")
+ wanted = set(claim_ids)
+ items = tuple(item for item in self._items.values() if wanted.intersection(item.claim_ids))
+ for item in items:
+ item.validate_integrity()
+ return items
+
+ def __len__(self) -> int:
+ return len(self._items)
+
+ def __iter__(self):
+ for item in self._items.values():
+ item.validate_integrity()
+ yield item
class Verifier(Protocol):
@@ -20,37 +451,132 @@ def verify(self, candidate: Candidate) -> VerificationResult:
...
+@dataclass(frozen=True)
+class VerificationDecision:
+ """Dependency-free result returned by new tri-valued check functions."""
+
+ status: VerificationStatus | str
+ reasons: tuple[str, ...] = ()
+ score: float | None = None
+ evidence_ids: tuple[str, ...] = ()
+ claim_ids: tuple[str, ...] = ()
+ provenance_ids: tuple[str, ...] = ()
+ counterexample_ids: tuple[str, ...] = ()
+ calibration: CalibrationMetrics = field(default_factory=CalibrationMetrics)
+
+ def __post_init__(self) -> None:
+ normalized = self.status if isinstance(self.status, VerificationStatus) else VerificationStatus(str(self.status).upper())
+ object.__setattr__(self, "status", normalized)
+ object.__setattr__(self, "reasons", tuple(str(x) for x in self.reasons))
+
+ @property
+ def passed(self) -> bool:
+ return self.status is VerificationStatus.PASS
+
+
+# Short aliases are convenient for adapter authors and leave the existing
+# VerificationResult name untouched.
+VerifierDecision = VerificationDecision
+Verdict = VerificationStatus
+VerifierStatus = VerificationStatus
+
+
+def _coerce_decision(raw: Any) -> VerificationDecision | VerificationResult:
+ if isinstance(raw, bool):
+ return VerificationDecision(VerificationStatus.PASS if raw else VerificationStatus.FAIL)
+ if isinstance(raw, (VerificationDecision, VerificationResult)):
+ return raw
+ if isinstance(raw, Mapping):
+ return VerificationDecision(**dict(raw))
+ if isinstance(raw, tuple) or isinstance(raw, list):
+ if len(raw) < 2:
+ raise ValueError("verifier tuple must contain status and reasons")
+ status, reasons = raw[0], raw[1]
+ score = raw[2] if len(raw) > 2 else None
+ if isinstance(status, bool):
+ status = VerificationStatus.PASS if status else VerificationStatus.FAIL
+ return VerificationDecision(status, tuple(reasons), score)
+ raise TypeError("verifier must return VerificationResult, VerificationDecision, mapping, or tuple")
+
+
@dataclass(frozen=True)
class FunctionVerifier:
"""Adapt a deterministic function into a registered verifier.
- The function returns ``(passed, reasons, score)``. It is deliberately
- dependency-free so hosts can wrap compilers, tests, schemas, or API calls.
- The default boundary is explicitly ``in_process``. This API prevents a
- model from forging a result, but it does not isolate arbitrary Python
- application code; process-attested verifiers require a separate broker.
+ The legacy ``(passed, reasons, score)`` return shape remains supported;
+ new checks may return :class:`VerificationDecision` for PASS/FAIL/UNKNOWN.
"""
name: str
- check: Callable[[Candidate], tuple[bool, Iterable[str], float | None]]
+ check: Callable[[Candidate], Any] = lambda candidate: False
verifier_class: str = "deterministic"
independent: bool = False
- # This is intentionally not named ``trusted``: in-process registration is
- # an application convention, not a security boundary.
trust_boundary: str = "in_process"
evidence_ids: tuple[str, ...] = ()
+ claim_ids: tuple[str, ...] = ()
+ # ``claims_supported`` is an adapter-friendly alias for claim_ids.
+ claims_supported: tuple[str, ...] = ()
+ provenance_ids: tuple[str, ...] = ()
+ uncertainty: float = 0.5
+ expected_information_gain: float = 0.5
+ information_gain: float | None = None
+ cost: float = 1.0
+ calibration: CalibrationMetrics = field(default_factory=CalibrationMetrics)
+
+ def __post_init__(self) -> None:
+ """Validate planner metrics at registration time, before scoring.
+
+ NaN and infinity are especially dangerous here: comparisons and
+ ``max`` can make them appear harmless while corrupting ranking or
+ serializable audit rationale. Information and uncertainty metrics
+ are normalized probabilities; cost is positive and is floored by the
+ planner rather than accepted as a free/infinite check.
+ """
+ for name in ("uncertainty", "expected_information_gain"):
+ value = getattr(self, name)
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
+ raise ValueError(f"{name} must be a finite number")
+ if not 0.0 <= float(value) <= 1.0:
+ raise ValueError(f"{name} must be between 0 and 1")
+ if self.information_gain is not None:
+ value = self.information_gain
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
+ raise ValueError("information_gain must be a finite number")
+ if not 0.0 <= float(value) <= 1.0:
+ raise ValueError("information_gain must be between 0 and 1")
+ if isinstance(self.cost, bool) or not isinstance(self.cost, (int, float)) or not math.isfinite(float(self.cost)):
+ raise ValueError("cost must be a finite number")
+ if float(self.cost) < 0.0:
+ raise ValueError("cost must not be negative")
def verify(self, candidate: Candidate) -> VerificationResult:
- passed, reasons, score = self.check(candidate)
+ decision = _coerce_decision(self.check(candidate))
+ if isinstance(decision, VerificationResult):
+ return decision
+ reasons = decision.reasons or (("Verifier passed" if decision.passed else "Verifier did not establish the claim"),)
+ evidence = decision.evidence_ids or self.evidence_ids
+ claims = decision.claim_ids or self.claim_ids or self.claims_supported
+ provenance = decision.provenance_ids or self.provenance_ids
+ metrics = self.calibration if decision.calibration == CalibrationMetrics() else decision.calibration
return VerificationResult(
verification_id=f"{self.name}:{candidate.candidate_id}",
session_id=candidate.session_id,
candidate_id=candidate.candidate_id,
verifier=self.name,
- passed=bool(passed),
- reasons=tuple(str(reason) for reason in reasons),
- score=score,
- evidence_ids=self.evidence_ids,
+ passed=decision.passed,
+ status=decision.status,
+ reasons=tuple(reasons),
+ score=decision.score,
+ evidence_ids=tuple(evidence),
+ claim_ids=tuple(claims),
+ provenance_ids=tuple(provenance),
+ counterexample_ids=decision.counterexample_ids,
+ verifier_class=self.verifier_class,
+ independent=self.independent,
+ trust_boundary=self.trust_boundary,
+ false_positive_rate=metrics.false_positive_rate,
+ false_negative_rate=metrics.false_negative_rate,
+ mutation_detection_rate=metrics.mutation_detection_rate,
)
@@ -70,3 +596,619 @@ def verify_all(self, candidate: Candidate) -> tuple[VerificationResult, ...]:
def passed(self, candidate: Candidate) -> bool:
results = self.verify_all(candidate)
return bool(results) and all(result.passed for result in results)
+
+
+@dataclass(frozen=True)
+class PortfolioResult:
+ results: tuple[VerificationResult, ...]
+ status: VerificationStatus
+ counterexamples: tuple[Counterexample, ...] = ()
+ reasons: tuple[str, ...] = ()
+
+ @property
+ def passed(self) -> bool:
+ return self.status is VerificationStatus.PASS
+
+ @property
+ def verdict(self) -> VerificationStatus:
+ return self.status
+
+ @property
+ def evidence_ids(self) -> tuple[str, ...]:
+ return tuple(dict.fromkeys(eid for result in self.results for eid in result.evidence_ids))
+
+ @property
+ def provenance_ids(self) -> tuple[str, ...]:
+ return tuple(dict.fromkeys(pid for result in self.results for pid in result.provenance_ids))
+
+
+class VerifierPortfolio:
+ """Execute a portfolio and aggregate with strict three-valued semantics."""
+
+ def __init__(self, verifiers: Iterable[Verifier] = ()) -> None:
+ self.verifiers = tuple(verifiers)
+
+ def verify(self, candidate: Candidate, selected: Iterable[Verifier] | None = None) -> PortfolioResult:
+ verifiers = tuple(selected) if selected is not None else self.verifiers
+ results = tuple(verifier.verify(candidate) for verifier in verifiers)
+ if any(result.status is VerificationStatus.FAIL for result in results):
+ status = VerificationStatus.FAIL
+ elif any(result.status is VerificationStatus.UNKNOWN for result in results):
+ status = VerificationStatus.UNKNOWN
+ elif results:
+ status = VerificationStatus.PASS
+ else:
+ status = VerificationStatus.UNKNOWN
+ return PortfolioResult(results, status, reasons=tuple(
+ reason for result in results for reason in result.reasons
+ ))
+
+ def verify_all(self, candidate: Candidate) -> tuple[VerificationResult, ...]:
+ """Compatibility-shaped execution method."""
+ return self.verify(candidate).results
+
+ def passed(self, candidate: Candidate) -> bool:
+ return self.verify(candidate).passed
+
+ run = verify
+ execute = verify
+
+
+# Alternate spelling used by some hosts.
+VerifierPortfolio = VerifierPortfolio
+
+
+@dataclass(frozen=True)
+class PlannedCheck:
+ verifier: Verifier
+ score: float
+ covered_claim_ids: tuple[str, ...]
+ rationale: str
+
+
+@dataclass(frozen=True)
+class VerifierPlan:
+ checks: tuple[PlannedCheck, ...]
+ uncovered_claim_ids: tuple[str, ...] = ()
+
+ @property
+ def verifiers(self) -> tuple[Verifier, ...]:
+ return tuple(item.verifier for item in self.checks)
+
+ @property
+ def selected(self) -> tuple[Verifier, ...]:
+ return self.verifiers
+
+
+class VerifierPlanner:
+ """Greedy, auditable selection using risk, uncertainty, gain, and cost."""
+
+ def __init__(self, *, minimum_cost: float = 0.01) -> None:
+ if isinstance(minimum_cost, bool) or not isinstance(minimum_cost, (int, float)) \
+ or not math.isfinite(float(minimum_cost)) or float(minimum_cost) <= 0:
+ raise ValueError("minimum_cost must be a finite positive number")
+ self.minimum_cost = float(minimum_cost)
+
+ def _score(self, verifier: Verifier, claim_graph: ClaimGraph, coverage: Sequence[Claim]) -> float:
+ risk = max((claim.risk_level.rank for claim in coverage), default=1) / 4
+ uncertainty = float(getattr(verifier, "uncertainty", 0.5))
+ configured_gain = getattr(verifier, "information_gain", None)
+ gain = float(configured_gain if configured_gain is not None
+ else getattr(verifier, "expected_information_gain", 0.5))
+ cost = float(getattr(verifier, "cost", 1.0))
+ if (not math.isfinite(uncertainty) or not 0.0 <= uncertainty <= 1.0
+ or not math.isfinite(gain) or not 0.0 <= gain <= 1.0
+ or not math.isfinite(cost) or cost < 0.0):
+ raise ValueError("verifier metrics must be finite and within their allowed ranges")
+ cost = max(cost, self.minimum_cost)
+ # Calibration penalizes checks with known false negatives/positives;
+ # it never turns a failing check into a pass.
+ calibration = getattr(verifier, "calibration", CalibrationMetrics())
+ calibration_rates = (float(getattr(calibration, "false_positive_rate", 0.0)),
+ float(getattr(calibration, "false_negative_rate", 0.0)))
+ if any(not math.isfinite(rate) or not 0.0 <= rate <= 1.0 for rate in calibration_rates):
+ raise ValueError("verifier calibration metrics must be finite and between 0 and 1")
+ penalty = 1.0 - sum(calibration_rates) / 2
+ return max(0.0, (0.5 + risk) * (0.5 + uncertainty) * max(gain, 0.0) * penalty / cost)
+
+ @staticmethod
+ def _coverage(verifier: Verifier, graph: ClaimGraph, uncovered: set[str]) -> tuple[Claim, ...]:
+ # In strict mode an undeclared verifier covers nothing. Treating an
+ # empty declaration as "all claims" is precisely the overclaim that
+ # the claim graph is intended to expose.
+ declared = set(getattr(verifier, "claim_ids", ()) or getattr(verifier, "claims_supported", ()))
+ return tuple(claim for claim in graph.claims
+ if claim.claim_id in uncovered and claim.claim_id in declared)
+
+ def plan(self, claim_graph: ClaimGraph, portfolio: VerifierPortfolio | Iterable[Verifier]) -> VerifierPlan:
+ verifiers = tuple(portfolio.verifiers if isinstance(portfolio, VerifierPortfolio) else portfolio)
+ remaining = set(claim_graph.claim_ids)
+ chosen: list[PlannedCheck] = []
+ available = list(verifiers)
+ while remaining and available:
+ scored = []
+ for verifier in available:
+ coverage = self._coverage(verifier, claim_graph, remaining)
+ if coverage:
+ scored.append((self._score(verifier, claim_graph, coverage), verifier, coverage))
+ if not scored:
+ break
+ score, verifier, coverage = max(scored, key=lambda item: (item[0], len(item[2])))
+ chosen.append(PlannedCheck(
+ verifier, score, tuple(claim.claim_id for claim in coverage),
+ f"risk={max(claim.risk_level.value for claim in coverage)}; "
+ f"uncertainty={getattr(verifier, 'uncertainty', 0.5):.3f}; "
+ f"information_gain={getattr(verifier, 'expected_information_gain', 0.5):.3f}; "
+ f"cost={getattr(verifier, 'cost', 1.0):.3f}",
+ ))
+ remaining.difference_update(claim.claim_id for claim in coverage)
+ available.remove(verifier)
+ return VerifierPlan(tuple(chosen), tuple(sorted(remaining)))
+
+ def plan_for(self, candidate: Candidate, task: TaskSpec, verifiers: Iterable[Verifier]) -> VerifierPlan:
+ return self.plan(ClaimGraph.from_task(task, candidate), verifiers)
+
+ select = plan
+
+
+@dataclass(frozen=True)
+class Adjudication:
+ status: VerificationStatus
+ finalizable: bool
+ blocking_claim_ids: tuple[str, ...]
+ reasons: tuple[str, ...]
+ evidence_ids: tuple[str, ...] = ()
+ provenance_ids: tuple[str, ...] = ()
+ counterexamples: tuple[Counterexample, ...] = ()
+ independent_verifier_ids: tuple[str, ...] = ()
+
+ @property
+ def passed(self) -> bool:
+ return self.finalizable and self.status is VerificationStatus.PASS
+
+ @property
+ def can_finalize(self) -> bool:
+ return self.finalizable
+
+
+class ThreeValuedAdjudicator:
+ """Mechanical finalization gate; no model judgment is consulted.
+
+ The adjudicator is intentionally usable both standalone and by
+ :class:`FableRun`. When a ``VerificationPolicy`` and object registries are
+ supplied, it is the single authority for policy counts and reference
+ resolution; callers must not separately "interpret" a passing portfolio.
+ """
+
+ def __init__(self, *, require_independent: bool = True,
+ minimum_independent_verifiers: int = 1,
+ allow_noncritical_unknown: bool = False,
+ policy: VerificationPolicy | None = None) -> None:
+ self.policy = policy
+ self.require_independent = (policy.require_independent if policy is not None
+ else require_independent)
+ self.minimum_independent_verifiers = minimum_independent_verifiers
+ self.allow_noncritical_unknown = allow_noncritical_unknown
+
+ @staticmethod
+ def _field(item: Any, name: str, default: Any = None) -> Any:
+ if isinstance(item, Mapping):
+ return item.get(name, default)
+ return getattr(item, name, default)
+
+ @staticmethod
+ def _verifier_id(result: VerificationResult) -> str:
+ # A verifier ID, rather than a result record ID, is the independence
+ # unit. Duplicate records from one verifier cannot satisfy a quorum.
+ return str(result.verifier or result.verification_id)
+
+ @staticmethod
+ def _registry_item(registry: Mapping[str, Any], key: str,
+ object_id: str) -> Any | None:
+ """Resolve a registry reference only when key and object ID agree."""
+ item = registry.get(key)
+ if item is None or ThreeValuedAdjudicator._field(item, object_id, "") != key:
+ return None
+ return item
+
+ @staticmethod
+ def _evidence_matches_receipt(evidence: Any, receipt: Any) -> bool:
+ """Require content-addressed evidence to equal its receipt output."""
+ if evidence is None or receipt is None:
+ return False
+ try:
+ content = ThreeValuedAdjudicator._field(evidence, "content")
+ content_hash = ThreeValuedAdjudicator._field(evidence, "content_hash", "")
+ source_hash = ThreeValuedAdjudicator._field(evidence, "source_output_hash", "")
+ output_hash = ThreeValuedAdjudicator._field(receipt, "output_hash", "")
+ return (isinstance(content_hash, str) and isinstance(source_hash, str)
+ and isinstance(output_hash, str) and bool(content_hash)
+ and canonical_hash(content) == content_hash
+ and content_hash == source_hash == output_hash)
+ except (TypeError, ValueError):
+ return False
+
+ def adjudicate(self, claim_graph: ClaimGraph,
+ portfolio: PortfolioResult | Iterable[VerificationResult],
+ counterexample_store: CounterexampleStore | None = None,
+ *, policy: VerificationPolicy | None = None,
+ evidence_registry: Mapping[str, Any] | None = None,
+ receipt_registry: Mapping[str, Any] | None = None,
+ candidate_evidence_ids: Iterable[str] | None = None,
+ candidate_receipt_ids: Iterable[str] | None = None) -> Adjudication:
+ # Also accept adjudicate(portfolio, claim_graph), a common pipeline
+ # ordering when the portfolio is produced before decomposition.
+ if isinstance(portfolio, ClaimGraph) and not isinstance(claim_graph, ClaimGraph):
+ claim_graph, portfolio = portfolio, claim_graph # type: ignore[assignment]
+ active_policy = policy or self.policy
+ if isinstance(portfolio, PortfolioResult):
+ results = portfolio.results
+ portfolio_counterexamples = portfolio.counterexamples
+ else:
+ results = tuple(portfolio)
+ portfolio_counterexamples = ()
+
+ by_claim: dict[str, list[VerificationResult]] = {claim.claim_id: [] for claim in claim_graph.claims}
+ blocking: list[str] = []
+ reasons: list[str] = []
+ malformed_reference = False
+ graph_ids = set(by_claim)
+ candidate_evidence = (None if candidate_evidence_ids is None else set(candidate_evidence_ids))
+ candidate_receipts = (None if candidate_receipt_ids is None else set(candidate_receipt_ids))
+ for result in results:
+ if (claim_graph.candidate_id and result.candidate_id != claim_graph.candidate_id):
+ malformed_reference = True
+ reasons.append(f"verifier {result.verifier} is outside the candidate claim graph")
+ expected_scope = f"task:{claim_graph.task_id};candidate:{claim_graph.candidate_id}" if claim_graph.candidate_id else ""
+ if expected_scope and result.scope and result.scope != expected_scope:
+ malformed_reference = True
+ reasons.append(f"verifier {result.verifier} has a scope outside this task/candidate")
+ metadata = result.metadata if isinstance(result.metadata, Mapping) else {}
+ target_ids = tuple(result.claim_ids or metadata.get("claim_ids", ()))
+ if not target_ids and metadata.get("claim_id"):
+ target_ids = (str(metadata["claim_id"]),)
+ # Even a one-claim graph requires an explicit claim binding in
+ # strict adjudication; an omitted declaration is uncovered, not
+ # implicit coverage.
+ unknown_claims = [claim_id for claim_id in target_ids if claim_id not in graph_ids]
+ if unknown_claims:
+ malformed_reference = True
+ reasons.append(f"verifier {result.verifier} references claims outside the graph: {unknown_claims}")
+ if evidence_registry is not None:
+ for evidence_id in result.evidence_ids:
+ # Do not let a registry alias an object under a different
+ # ID. This matters for standalone adjudication where the
+ # runtime's own map reconciliation is not available.
+ item = self._registry_item(evidence_registry, evidence_id, "evidence_id")
+ valid = (item is not None and
+ (candidate_evidence is None or evidence_id in candidate_evidence) and
+ self._field(item, "session_id", result.session_id) == result.session_id and
+ self._field(item, "integrity_bound", True) is not False)
+ receipt_id = self._field(item, "receipt_id") if item is not None else None
+ if receipt_registry is not None:
+ receipt = (self._registry_item(receipt_registry, str(receipt_id), "receipt_id")
+ if receipt_id else None)
+ valid = (valid and receipt is not None
+ and self._field(receipt, "session_id", result.session_id) == result.session_id
+ and self._evidence_matches_receipt(item, receipt))
+ if not valid:
+ malformed_reference = True
+ reasons.append(f"verifier {result.verifier} references unresolved or unrelated evidence: {evidence_id}")
+ if receipt_registry is not None:
+ for provenance_id in result.provenance_ids:
+ evidence_item = (self._registry_item(evidence_registry, provenance_id, "evidence_id")
+ if evidence_registry is not None else None)
+ receipt_item = self._registry_item(receipt_registry, provenance_id, "receipt_id")
+ valid = receipt_item is not None or evidence_item is not None
+ if receipt_item is not None:
+ valid = valid and self._field(receipt_item, "session_id", result.session_id) == result.session_id
+ if evidence_item is not None:
+ linked_id = self._field(evidence_item, "receipt_id", "")
+ linked_receipt = (self._registry_item(receipt_registry, str(linked_id), "receipt_id")
+ if linked_id else None)
+ valid = (valid and self._field(evidence_item, "session_id", result.session_id) == result.session_id
+ and linked_receipt is not None
+ and self._evidence_matches_receipt(evidence_item, linked_receipt))
+ if candidate_receipts is not None or candidate_evidence is not None:
+ valid = valid and ((candidate_receipts is not None and provenance_id in candidate_receipts)
+ or (candidate_evidence is not None and provenance_id in candidate_evidence))
+ if not valid:
+ malformed_reference = True
+ reasons.append(f"verifier {result.verifier} references unresolved or unrelated provenance: {provenance_id}")
+ for claim_id in target_ids:
+ if claim_id in by_claim:
+ by_claim[claim_id].append(result)
+
+ pass_results: list[VerificationResult] = []
+ for claim in claim_graph.claims:
+ claim_results = by_claim[claim.claim_id]
+ if any(result.status is VerificationStatus.FAIL for result in claim_results):
+ blocking.append(claim.claim_id)
+ reasons.append(f"claim {claim.claim_id} has a FAIL")
+ continue
+ if not claim_results:
+ # ``allow_noncritical_unknown`` only affects an explicit
+ # UNKNOWN result. Absence of a verifier is uncovered data,
+ # never an implicit PASS, regardless of claim risk.
+ blocking.append(claim.claim_id)
+ reasons.append(f"claim {claim.claim_id} is UNKNOWN or uncovered")
+ continue
+ if any(result.status is VerificationStatus.UNKNOWN for result in claim_results):
+ if not self.allow_noncritical_unknown or claim.risk_level is RiskLevel.CRITICAL:
+ blocking.append(claim.claim_id)
+ reasons.append(f"claim {claim.claim_id} is UNKNOWN or uncovered")
+ continue
+ passing = [result for result in claim_results if result.status is VerificationStatus.PASS]
+ if not passing:
+ blocking.append(claim.claim_id)
+ reasons.append(f"claim {claim.claim_id} has no PASS")
+ continue
+ pass_results.extend(passing)
+ for result in passing:
+ if not result.evidence_ids:
+ blocking.append(claim.claim_id)
+ reasons.append(f"claim {claim.claim_id} PASS lacks evidence provenance")
+ if not result.provenance_ids:
+ blocking.append(claim.claim_id)
+ reasons.append(f"claim {claim.claim_id} PASS lacks verifier provenance")
+
+ # Deduplicate by verifier ID before all quorum/independence counting.
+ unique_pass: dict[str, VerificationResult] = {}
+ for result in pass_results:
+ unique_pass.setdefault(self._verifier_id(result), result)
+ independent = [result for result in unique_pass.values() if result.independent]
+ qualified_independent: list[VerificationResult] = []
+ for result in independent:
+ own = set(result.provenance_ids)
+ if own and all(own.isdisjoint(set(peer.provenance_ids))
+ for peer in unique_pass.values()
+ if self._verifier_id(peer) != self._verifier_id(result)):
+ qualified_independent.append(result)
+ if self.require_independent and len(qualified_independent) < self.minimum_independent_verifiers:
+ reasons.append("requires independently-provenanced passing verifier(s)")
+ blocking.extend(claim.claim_id for claim in claim_graph.claims if claim.claim_id not in blocking)
+
+ # Apply declared class and minimum-count requirements here, alongside
+ # claim semantics. This prevents a second, weaker runtime policy path.
+ if active_policy is not None:
+ classes = {result.verifier_class for result in unique_pass.values()}
+ for required_class in active_policy.required_verifier_classes:
+ if required_class not in classes:
+ reasons.append(f"required verifier class not passed: {required_class}")
+ blocking.extend(claim.claim_id for claim in claim_graph.claims if claim.claim_id not in blocking)
+ if len(unique_pass) < active_policy.minimum_passing_verifiers:
+ reasons.append(f"requires {active_policy.minimum_passing_verifiers} passing verifiers (currently {len(unique_pass)})")
+ blocking.extend(claim.claim_id for claim in claim_graph.claims if claim.claim_id not in blocking)
+ if active_policy.require_independent and not qualified_independent:
+ # The constructor already applies this requirement, but keep
+ # this explicit for policies passed per-call.
+ if "requires independently-provenanced passing verifier(s)" not in reasons:
+ reasons.append("requires independently-provenanced passing verifier(s)")
+ blocking.extend(claim.claim_id for claim in claim_graph.claims if claim.claim_id not in blocking)
+ try:
+ boundary_rank = VerificationPolicy.TRUST_BOUNDARY_RANK[active_policy.minimum_trust_boundary]
+ except KeyError:
+ boundary_rank = 0
+ if not any(VerificationPolicy.TRUST_BOUNDARY_RANK.get(result.trust_boundary, -1) >= boundary_rank
+ for result in unique_pass.values()):
+ reasons.append("requires a passing verifier at trust boundary " + active_policy.minimum_trust_boundary)
+ blocking.extend(claim.claim_id for claim in claim_graph.claims if claim.claim_id not in blocking)
+ if active_policy.require_evidence_diversity and evidence_registry is not None and receipt_registry is not None:
+ producers: set[str] = set()
+ for result in unique_pass.values():
+ for evidence_id in result.evidence_ids:
+ evidence = evidence_registry.get(evidence_id)
+ receipt = receipt_registry.get(self._field(evidence, "receipt_id", "")) if evidence is not None else None
+ if receipt is not None:
+ executable = self._field(receipt, "executable_identity", {})
+ workspace = self._field(receipt, "workspace_identity", {})
+ if executable or workspace:
+ producers.add(canonical_hash({"executable_identity": dict(executable), "workspace_identity": dict(workspace)}))
+ if len(producers) < active_policy.minimum_evidence_sources:
+ reasons.append("requires independent measured evidence producers from at least " + str(active_policy.minimum_evidence_sources) + " sources")
+ blocking.extend(claim.claim_id for claim in claim_graph.claims if claim.claim_id not in blocking)
+
+ all_counterexamples = list(portfolio_counterexamples)
+ for item in all_counterexamples:
+ if not isinstance(item, Counterexample):
+ raise TypeError("portfolio counterexamples must be Counterexample objects")
+ item.validate_integrity()
+ if counterexample_store is not None:
+ all_counterexamples.extend(counterexample_store.propagate(claim_graph.claim_ids))
+ for result in results:
+ for counterexample_id in result.counterexample_ids:
+ item = None
+ if counterexample_store is not None:
+ try:
+ item = counterexample_store.get(counterexample_id)
+ except KeyError:
+ item = None
+ if item is None:
+ item = Counterexample(
+ counterexample_id=counterexample_id,
+ claim_ids=result.claim_ids,
+ observation={"source": result.verifier},
+ falsifies="reported by verifier",
+ verifier=result.verifier,
+ evidence_ids=result.evidence_ids,
+ provenance_ids=result.provenance_ids,
+ )
+ all_counterexamples.append(item)
+ # Any counterexample is a hard falsification. It cannot be neutralized
+ # by another PASS, and malformed/unknown targets fail closed as well.
+ counterexample_claims: set[str] = set()
+ for item in all_counterexamples:
+ targets = set(item.claim_ids)
+ if not targets or not targets.issubset(graph_ids):
+ malformed_reference = True
+ reasons.append(f"counterexample {item.counterexample_id} is outside the claim graph")
+ counterexample_claims.update(targets & graph_ids)
+ reasons.append(f"counterexample {item.counterexample_id} forces FAIL")
+ blocking.extend(counterexample_claims)
+ if all_counterexamples:
+ blocking.extend(claim_graph.claim_ids if not counterexample_claims else ())
+
+ unique_blocking = tuple(dict.fromkeys(blocking))
+ hard_fail = (any(result.status is VerificationStatus.FAIL for result in results)
+ or bool(all_counterexamples) or malformed_reference
+ or any("has a FAIL" in reason for reason in reasons))
+ status = VerificationStatus.FAIL if hard_fail else (
+ VerificationStatus.UNKNOWN if unique_blocking or any(
+ result.status is VerificationStatus.UNKNOWN for result in results
+ ) else VerificationStatus.PASS
+ )
+ evidence_ids = tuple(dict.fromkeys(eid for result in pass_results for eid in result.evidence_ids))
+ provenance_ids = tuple(dict.fromkeys(pid for result in pass_results for pid in result.provenance_ids))
+ return Adjudication(
+ status=status,
+ finalizable=(status is VerificationStatus.PASS and not unique_blocking),
+ blocking_claim_ids=unique_blocking,
+ reasons=tuple(dict.fromkeys(reasons)),
+ evidence_ids=evidence_ids,
+ provenance_ids=provenance_ids,
+ counterexamples=tuple({item.counterexample_id: item for item in all_counterexamples}.values()),
+ independent_verifier_ids=tuple(self._verifier_id(result) for result in qualified_independent),
+ )
+
+ evaluate = adjudicate
+ finalize = adjudicate
+
+
+# Safe deterministic extension hooks. Adapters can implement these protocols
+# without giving an LLM authority over the final verdict.
+class MutationOperator(Protocol):
+ def mutate(self, candidate: Candidate) -> Iterable[Any]: ...
+
+
+class MetamorphicRelation(Protocol):
+ def transform(self, candidate: Candidate) -> Any: ...
+ def compare(self, original: Candidate, transformed: Any) -> Any: ...
+
+
+class PropertyCheck(Protocol):
+ def check(self, artifact: Any) -> Any: ...
+
+
+@dataclass(frozen=True)
+class PropertyVerifier(FunctionVerifier):
+ """Run a deterministic property over the candidate artifact."""
+
+ property_check: Callable[[Any], Any] = lambda artifact: False
+
+ def verify(self, candidate: Candidate) -> VerificationResult:
+ # Explicitly bypass a placeholder ``check`` while retaining all
+ # standard result, evidence, calibration, and compatibility behavior.
+ return FunctionVerifier(
+ name=self.name, check=lambda _: self.property_check(candidate.artifact),
+ verifier_class=self.verifier_class, independent=self.independent,
+ trust_boundary=self.trust_boundary, evidence_ids=self.evidence_ids,
+ claim_ids=self.claim_ids, provenance_ids=self.provenance_ids,
+ uncertainty=self.uncertainty, expected_information_gain=self.expected_information_gain,
+ information_gain=self.information_gain, cost=self.cost, calibration=self.calibration,
+ ).verify(candidate)
+
+
+@dataclass(frozen=True)
+class MetamorphicVerifier(FunctionVerifier):
+ transform: Callable[[Candidate], Any] = lambda candidate: candidate.artifact
+ compare: Callable[[Candidate, Any], Any] = lambda candidate, transformed: False
+
+ def verify(self, candidate: Candidate) -> VerificationResult:
+ transformed = self.transform(candidate)
+ try:
+ unchanged = canonical_hash(transformed) == canonical_hash(candidate.artifact)
+ except (TypeError, ValueError):
+ # A transform that cannot be represented in the protocol's
+ # canonical form cannot establish a metamorphic relation.
+ unchanged = False
+ if unchanged:
+ check: Callable[[Candidate], Any] = lambda _: VerificationDecision(
+ VerificationStatus.UNKNOWN, ("metamorphic transform did not change the artifact",))
+ else:
+ check = lambda _: self.compare(candidate, transformed)
+ return FunctionVerifier(
+ name=self.name, check=check,
+ verifier_class="metamorphic", independent=self.independent,
+ trust_boundary=self.trust_boundary, evidence_ids=self.evidence_ids,
+ claim_ids=self.claim_ids, provenance_ids=self.provenance_ids,
+ uncertainty=self.uncertainty, expected_information_gain=self.expected_information_gain,
+ information_gain=self.information_gain, cost=self.cost, calibration=self.calibration,
+ ).verify(candidate)
+
+
+@dataclass(frozen=True)
+class MutationVerifier(FunctionVerifier):
+ """Deterministically require a predicate to detect every generated mutation."""
+
+ mutate: Callable[[Candidate], Iterable[Any]] = lambda candidate: ()
+ detect: Callable[[Any], Any] = lambda mutated: False
+
+ def verify(self, candidate: Candidate) -> VerificationResult:
+ mutations = tuple(self.mutate(candidate))
+ if not mutations:
+ return FunctionVerifier(
+ name=self.name, check=lambda _: VerificationDecision(VerificationStatus.UNKNOWN, ("no mutations generated",)),
+ verifier_class="mutation", independent=self.independent,
+ trust_boundary=self.trust_boundary, evidence_ids=self.evidence_ids,
+ claim_ids=self.claim_ids, provenance_ids=self.provenance_ids,
+ uncertainty=self.uncertainty, expected_information_gain=self.expected_information_gain,
+ information_gain=self.information_gain, cost=self.cost, calibration=self.calibration,
+ ).verify(candidate)
+
+ changed: list[Any] = []
+ unchanged_count = 0
+ for mutation in mutations:
+ try:
+ is_unchanged = canonical_hash(mutation) == canonical_hash(candidate.artifact)
+ except (TypeError, ValueError):
+ is_unchanged = False
+ if is_unchanged:
+ unchanged_count += 1
+ else:
+ changed.append(mutation)
+
+ # A no-op is not a mutation and a detector saying "true" for it is not
+ # evidence that the artifact survives a changed-input test. Crucially,
+ # this is UNKNOWN rather than a synthetic counterexample/FAIL.
+ if not changed:
+ reason = ("no changed mutations generated" if unchanged_count
+ else "no mutations generated")
+ return FunctionVerifier(
+ name=self.name,
+ check=lambda _: VerificationDecision(VerificationStatus.UNKNOWN, (reason,)),
+ verifier_class="mutation", independent=self.independent,
+ trust_boundary=self.trust_boundary, evidence_ids=self.evidence_ids,
+ claim_ids=self.claim_ids, provenance_ids=self.provenance_ids,
+ uncertainty=self.uncertainty, expected_information_gain=self.expected_information_gain,
+ information_gain=self.information_gain, cost=self.cost, calibration=self.calibration,
+ ).verify(candidate)
+
+ decisions = [_coerce_decision(self.detect(item)) for item in changed]
+ statuses = [item.status for item in decisions
+ if isinstance(item, (VerificationDecision, VerificationResult))]
+ detected = sum(status is VerificationStatus.PASS for status in statuses)
+ status = (VerificationStatus.FAIL if any(status is VerificationStatus.FAIL for status in statuses)
+ else VerificationStatus.UNKNOWN if any(status is VerificationStatus.UNKNOWN for status in statuses)
+ else VerificationStatus.PASS)
+ ids = tuple(f"mutation-{canonical_hash(item)[:16]}" for item in changed)
+ reasons = [f"detected {detected}/{len(changed)} changed mutations"]
+ if unchanged_count:
+ reasons.append(f"ignored {unchanged_count} unchanged mutation(s)")
+ return FunctionVerifier(
+ name=self.name, check=lambda _: VerificationDecision(
+ status=status, reasons=tuple(reasons),
+ evidence_ids=self.evidence_ids, claim_ids=self.claim_ids, provenance_ids=self.provenance_ids,
+ # Only a concrete FAIL is a counterexample. UNKNOWN must remain
+ # unresolved and must not be converted into a hard falsification.
+ counterexample_ids=ids if status is VerificationStatus.FAIL else (),
+ calibration=CalibrationMetrics(
+ self.calibration.false_positive_rate,
+ self.calibration.false_negative_rate,
+ detected / len(changed),
+ ),
+ ), verifier_class="mutation", independent=self.independent,
+ trust_boundary=self.trust_boundary, evidence_ids=self.evidence_ids,
+ claim_ids=self.claim_ids, provenance_ids=self.provenance_ids,
+ uncertainty=self.uncertainty, expected_information_gain=self.expected_information_gain,
+ information_gain=self.information_gain, cost=self.cost, calibration=self.calibration,
+ ).verify(candidate)
diff --git a/packaging/fable_mode.spec b/packaging/fable_mode.spec
index 7c30490..278e65b 100644
--- a/packaging/fable_mode.spec
+++ b/packaging/fable_mode.spec
@@ -20,9 +20,11 @@ hiddenimports = [
"fable_engine.server",
"fable_v2.adapters", "fable_v2.execution_broker", "fable_v2.protocol",
"fable_v2.runtime", "fable_v2.verifiers",
- "fable_v2.system3", "fable_v2.system3.causal", "fable_v2.system3.dialectical",
- "fable_v2.system3.evolution", "fable_v2.system3.executive", "fable_v2.system3.free_energy",
- "fable_v2.system3.hyperbolic", "fable_v2.system3.induction", "fable_v2.system3.kripke",
+ "fable_v2.system3",
+ "fable_v2.system3.causal", "fable_v2.system3.dialectical",
+ "fable_v2.system3.evolution", "fable_v2.system3.executive",
+ "fable_v2.system3.free_energy", "fable_v2.system3.hyperbolic",
+ "fable_v2.system3.induction", "fable_v2.system3.kripke",
"fable_v2.system3.oracle",
]
diff --git a/pyproject.toml b/pyproject.toml
index 7ee6ae8..98b8af0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,4 +34,7 @@ fable_engine = ["fable_session.json"]
[tool.setuptools]
py-modules = ["fable_mode_entry"]
+[tool.setuptools.packages.find]
+include = ["fable_mode*", "fable_engine*", "fable_v2*"]
+
diff --git a/rules/fable-mode.md b/rules/fable-mode.md
index 25ed290..f4b0172 100644
--- a/rules/fable-mode.md
+++ b/rules/fable-mode.md
@@ -24,9 +24,4 @@ Whenever the user requests deep thinking, architectural planning, system design,
7. **Strict Cognitive & Role Separation**:
- **Main Agent**: The Master Architect & System 2 Deliberation Conductor. Performs all heavy cognitive lifting, architecture, invariant proofs, and multi-tier quality gatekeeping. **Strictly CANNOT write or edit code files directly in the codebase.**
- **Subagent Fleet**: 100% of all code writing (`write_to_file`), edits (`replace_file_content`), unit test implementations, and build fixes are executed **exclusively by subagents** (`type: self` or `type: research`) **only AFTER the timer has elapsed and execution is unlocked**.
-8. **System 3 Meta-Cognitive Deliberation & Weak-Model Frontier Uplift**:
- - **Active Inference & Free Energy Minimization**: Active Free Energy $F$ and complexity metrics are continuously tracked across candidate registrations, phase advances, and refinement cycles.
- - **Kripke Modal Invariant Enforcement**: Temporal invariants $AG(\text{safe})$ are strictly validated before execution transitions.
- - **Dialectical TRIZ Auto-Repair**: On any verification or finalize rejection, automatically synthesize contradictions, apply TRIZ principles, and attach actionable repair recommendations to candidate metadata.
- - **System 3 Micro-Scaffolds**: Subagent delegation contracts automatically embed Kripke contracts, causal failure boundaries ($do(\cdot)$), TRIZ principles, and regex acceptance patterns to guarantee frontier-grade output from low-parameter models.
diff --git a/setup.py b/setup.py
index d95d412..960d573 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
setup(
name="fable-engine",
version="1.2.3",
- description="Deterministic System 2 Cognitive Engine & Mechanical Time-Lock MCP Server for Antigravity",
+ description="Independent deterministic System 2 cognitive engine and mechanical time-lock MCP server",
license="MIT",
packages=find_packages(),
py_modules=["fable_mode_entry"],
diff --git a/tests/fixtures/hosts/antigravity-config.json b/tests/fixtures/hosts/antigravity-config.json
new file mode 100644
index 0000000..13efa6e
--- /dev/null
+++ b/tests/fixtures/hosts/antigravity-config.json
@@ -0,0 +1 @@
+{"mcpServers":{"example":{"command":"example-server","args":[],"cwd":"/workspace"}},"ui":{"theme":"dark"}}
diff --git a/tests/fixtures/hosts/claude-config.json b/tests/fixtures/hosts/claude-config.json
new file mode 100644
index 0000000..f7414f1
--- /dev/null
+++ b/tests/fixtures/hosts/claude-config.json
@@ -0,0 +1 @@
+{"mcpServers":{"example":{"command":"example-server","args":[]}}}
diff --git a/tests/fixtures/hosts/codex-config.json b/tests/fixtures/hosts/codex-config.json
new file mode 100644
index 0000000..64890f6
--- /dev/null
+++ b/tests/fixtures/hosts/codex-config.json
@@ -0,0 +1 @@
+{"mcpServers":{"example":{"command":"example-server","args":["--stdio"]}}}
diff --git a/tests/test_broker_second_redteam.py b/tests/test_broker_second_redteam.py
new file mode 100644
index 0000000..8f5665b
--- /dev/null
+++ b/tests/test_broker_second_redteam.py
@@ -0,0 +1,118 @@
+"""Adversarial regression coverage for the broker/transport boundary."""
+import hashlib
+import io
+import json
+import os
+import pathlib
+import sys
+import tempfile
+import threading
+import unittest
+from unittest.mock import patch
+
+from fable_mode.adapters import GenericMCPHostAdapter
+from fable_v2 import BrokerPolicy, ExecutionBroker
+from fable_v2.execution_broker import MAX_RESPONSE_BYTES, serve
+
+
+class BrokerSecondRedTeamTests(unittest.TestCase):
+ def test_allowlist_rejects_explicit_symlink_and_hardlink(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = pathlib.Path(d)
+ real = root / "real"
+ real.write_bytes(b"not an executable")
+ real.chmod(0o755)
+ link = root / "link"
+ os.symlink(real, link)
+ with self.assertRaises(ValueError):
+ BrokerPolicy(root, (str(link),))
+ hard = root / "hard"
+ os.link(real, hard)
+ with self.assertRaises(ValueError):
+ BrokerPolicy(root, (str(hard),))
+
+ @unittest.skipIf(os.name == "nt", "Windows broker execution is deliberately fail-closed")
+ def test_receipt_identity_is_measured_and_workspace_is_pinned(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = pathlib.Path(d)
+ policy = BrokerPolicy(root, (pathlib.Path(sys.executable).name,),
+ write_token_digest=hashlib.sha256(b"x").hexdigest())
+ broker = ExecutionBroker(policy)
+ broker.unlock_writes("x")
+ result = broker.execute_command([sys.executable, "-c", "print('ok')"])
+ ident = result["receipt"]["executable_identity"]
+ self.assertIn("sha256", ident)
+ self.assertIn("inode", ident)
+ self.assertEqual(result["receipt"]["workspace_identity"]["inode"],
+ root.stat().st_ino)
+
+ @unittest.skipIf(os.name == "nt", "Windows broker execution is deliberately fail-closed")
+ def test_mcp_handshake_id_propagation_and_notification_cancellation(self):
+ with tempfile.TemporaryDirectory() as d:
+ broker = ExecutionBroker(BrokerPolicy(
+ pathlib.Path(d), (pathlib.Path(sys.executable).name,),
+ write_token_digest=hashlib.sha256(b"x").hexdigest()))
+ broker.unlock_writes("x")
+ requests = [
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize",
+ "params": {"protocolVersion": "2024-11-05"}},
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
+ {"jsonrpc": "2.0", "id": "outer", "method": "tools/call",
+ "params": {"name": "execute_command", "arguments": {
+ "command": [sys.executable, "-c", "import time; time.sleep(2)"],
+ "timeout_seconds": 10}}},
+ {"jsonrpc": "2.0", "method": "notifications/cancelled",
+ "params": {"requestId": "outer"}},
+ ]
+ output = io.StringIO()
+ import fable_v2.execution_broker as module
+ with patch.object(module.sys, "stdin", io.StringIO(
+ "".join(json.dumps(item) + "\n" for item in requests))), \
+ patch.object(module.sys, "stdout", output):
+ serve(broker)
+ replies = [json.loads(line) for line in output.getvalue().splitlines()]
+ call = next(item for item in replies if item.get("id") == "outer")
+ self.assertEqual(call["result"]["structuredContent"]["request_id"], "outer")
+ self.assertTrue(call["result"]["structuredContent"]["cancelled"])
+
+ def test_adapter_generates_correlated_ids_and_standard_cancel(self):
+ adapter = GenericMCPHostAdapter()
+ init = adapter.initialize()
+ call = adapter.route_tool("probe")
+ self.assertTrue(init["id"])
+ self.assertTrue(call["id"])
+ self.assertNotEqual(init["id"], call["id"])
+ cancellation = adapter.cancel(call["id"])
+ self.assertEqual(cancellation["method"], "notifications/cancelled")
+ self.assertEqual(cancellation["params"]["requestId"], call["id"])
+
+ @unittest.skipIf(os.name == "nt", "Windows broker execution is deliberately fail-closed")
+ def test_cancellation_overtaking_registration_is_not_lost(self):
+ with tempfile.TemporaryDirectory() as d:
+ broker = ExecutionBroker(BrokerPolicy(
+ pathlib.Path(d), (pathlib.Path(sys.executable).name,),
+ write_token_digest=hashlib.sha256(b"x").hexdigest()))
+ broker.unlock_writes("x")
+ self.assertFalse(broker.handle({"action": "cancel", "request_id": "early"})["cancelled"])
+ result = broker.execute_command([sys.executable, "-c", "print('must not run')"],
+ request_id="early")
+ self.assertTrue(result["cancelled"])
+ self.assertFalse(result["success"])
+
+ @unittest.skipIf(os.name == "nt", "Windows broker execution is deliberately fail-closed")
+ def test_command_output_quota_is_combined_across_streams(self):
+ with tempfile.TemporaryDirectory() as d:
+ broker = ExecutionBroker(BrokerPolicy(
+ pathlib.Path(d), (pathlib.Path(sys.executable).name,),
+ max_output_bytes=100,
+ write_token_digest=hashlib.sha256(b"x").hexdigest()))
+ broker.unlock_writes("x")
+ result = broker.execute_command([sys.executable, "-c",
+ "import sys; sys.stdout.write('a'*80); sys.stderr.write('b'*80)"])
+ self.assertTrue(result["output_limited"])
+ self.assertLessEqual(len(result["stdout"].encode()) +
+ len(result["stderr"].encode()), 100)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_build_release.py b/tests/test_build_release.py
index f0e3ceb..01d31cf 100644
--- a/tests/test_build_release.py
+++ b/tests/test_build_release.py
@@ -4,7 +4,7 @@
from pathlib import Path
from unittest.mock import patch
-from build_scripts.build_release import validate_tag_version
+from build_scripts.build_release import archive_name, validate_tag_version
from fable_mode import __version__
@@ -28,6 +28,12 @@ def test_server_runtime_metadata_matches_package_version(self):
versions = re.findall(r'"version"\s*:\s*"([^"]+)"', source.read_text(encoding="utf-8"))
self.assertEqual(versions, [__version__, __version__])
+ def test_archive_names_use_bare_package_version(self):
+ with patch.dict(os.environ, {"FABLE_VERSION": f"v{__version__}"}, clear=False):
+ name = archive_name()
+ self.assertIn(f"fable-mode-{__version__}-", name)
+ self.assertNotIn(f"fable-mode-v{__version__}-", name)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_cleanup_regression.py b/tests/test_cleanup_regression.py
new file mode 100644
index 0000000..65730e7
--- /dev/null
+++ b/tests/test_cleanup_regression.py
@@ -0,0 +1,48 @@
+import os
+import shutil
+import tempfile
+import unittest
+import uuid
+from pathlib import Path
+from unittest.mock import patch
+
+from fable_mode.safety import mark_owned_directory, safe_cleanup
+
+
+class CleanupRegressionTests(unittest.TestCase):
+ def test_cleanup_never_targets_cwd_or_repository(self):
+ repo = Path(__file__).resolve().parents[1]
+ cwd = Path.cwd().resolve()
+ with patch("fable_mode.safety.shutil.rmtree") as rmtree:
+ safe_cleanup(None)
+ with self.assertRaises(ValueError):
+ safe_cleanup(Path("."))
+ with self.assertRaises(ValueError):
+ safe_cleanup(cwd)
+ with self.assertRaises(ValueError):
+ safe_cleanup(repo)
+ rmtree.assert_not_called()
+
+ def test_cleanup_only_removes_explicit_temp_directory(self):
+ with tempfile.TemporaryDirectory() as parent:
+ stage = Path(parent) / f".fable-mode.stage-{uuid.uuid4().hex}"
+ stage.mkdir()
+ (stage / "marker").write_text("x", encoding="utf-8")
+ mark_owned_directory(stage)
+ safe_cleanup(stage)
+ self.assertFalse(stage.exists())
+
+ def test_cleanup_refuses_parent_and_preexisting_backup(self):
+ with tempfile.TemporaryDirectory() as parent:
+ root = Path(parent)
+ backup = root / ".fable-mode.previous-preexisting"
+ backup.mkdir()
+ (backup / "keep").write_text("do not delete", encoding="utf-8")
+ for unsafe in (root, root.parent, backup):
+ with self.assertRaises(ValueError):
+ safe_cleanup(unsafe)
+ self.assertTrue((backup / "keep").exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_control_plane.py b/tests/test_control_plane.py
new file mode 100644
index 0000000..87793f3
--- /dev/null
+++ b/tests/test_control_plane.py
@@ -0,0 +1,205 @@
+"""Focused regression tests for the strict MCP control-plane profile."""
+from __future__ import annotations
+
+import json
+import time
+import unittest
+from pathlib import Path
+
+from fable_engine.server import (
+ ACTIVE_SESSIONS,
+ SESSIONS_DIR,
+ _canonical_hash,
+ handle_fable_control_plane,
+ register_host_verification,
+ FableSession,
+)
+
+
+class StrictControlPlaneTests(unittest.TestCase):
+ def setUp(self):
+ self.name = "cp_test_%d" % int(time.time() * 1000000)
+ self.profile = "strict-mcp-v1"
+ self._created = False
+
+ def tearDown(self):
+ ACTIVE_SESSIONS.pop(self.name, None)
+ (SESSIONS_DIR / (self.name + ".json")).unlink(missing_ok=True)
+
+ def call(self, **kwargs):
+ kwargs.setdefault("profile", self.profile)
+ return handle_fable_control_plane(kwargs)
+
+ def observe(self):
+ result = self.call(action="observe", session_name=self.name,
+ objective="Validate strict control plane transitions",
+ observation="The repository was observed before mutation",
+ idempotency_key="observe-1")
+ self.assertTrue(result["ok"])
+ self._created = True
+ return result
+
+ def test_skip_observe_is_rejected(self):
+ result = self.call(action="record_prediction", session_id="missing-session",
+ prediction="A substantive prediction", idempotency_key="predict-1")
+ self.assertFalse(result["ok"])
+ self.assertIn(result["error"]["code"], {"unknown_session", "session_binding_required"})
+
+ def test_skip_prediction_and_boilerplate_are_rejected(self):
+ observed = self.observe()
+ sid = observed["session_id"]
+ skipped = self.call(action="propose_action", session_id=sid,
+ action_name="write file", mutating=True,
+ prediction_id="missing", idempotency_key="action-1")
+ self.assertFalse(skipped["ok"])
+ self.assertEqual(skipped["error"]["code"], "prediction_required_before_action")
+ boilerplate = self.call(action="record_prediction", session_id=sid,
+ prediction="TODO", idempotency_key="predict-1")
+ self.assertFalse(boilerplate["ok"])
+ self.assertEqual(boilerplate["error"]["code"], "invalid_arguments")
+
+ def test_outcome_and_verification_require_host_attestation(self):
+ observed = self.observe(); sid = observed["session_id"]
+ predicted = self.call(action="record_prediction", session_id=sid,
+ prediction="The bounded write should produce a successful receipt",
+ idempotency_key="predict-1")
+ proposed = self.call(action="propose_action", session_id=sid,
+ prediction_id=predicted["result"]["id"], action_name="write file",
+ mutating=True, arguments={}, idempotency_key="action-1")
+ missing_receipt = self.call(action="record_outcome", session_id=sid,
+ action_id=proposed["result"]["id"], outcome={"ok": True},
+ receipt_id="not-host-registered", idempotency_key="outcome-1")
+ self.assertFalse(missing_receipt["ok"])
+ self.assertEqual(missing_receipt["error"]["code"], "host_receipt_required")
+
+ def test_valid_flow_is_accepted_and_final_authorization_is_external(self):
+ observed = self.observe(); sid = observed["session_id"]
+ predicted = self.call(action="record_prediction", session_id=sid,
+ prediction="The bounded write should produce a successful receipt",
+ idempotency_key="predict-1")
+ proposed = self.call(action="propose_action", session_id=sid,
+ prediction_id=predicted["result"]["id"], action_name="write file",
+ mutating=True, arguments={}, idempotency_key="action-1")
+ session = ACTIVE_SESSIONS[self.name]
+ output = {"ok": True, "changed": 1}
+ receipt = {"receipt_id": "tool-receipt", "session_id": sid,
+ "capability": "broker", "tool_name": "write_file",
+ "input_hash": _canonical_hash({}), "output_hash": _canonical_hash(output),
+ "success": True, "output": output}
+ session.register_host_receipt(receipt)
+ outcome = self.call(action="record_outcome", session_id=sid,
+ action_id=proposed["result"]["id"], outcome=output,
+ receipt_id=receipt["receipt_id"], idempotency_key="outcome-1")
+ verification = self.call(action="request_verification", session_id=sid,
+ outcome_id=outcome["result"]["id"], checks=["receipt"],
+ idempotency_key="verify-1")
+ denied = self.call(action="finalize", session_id=sid,
+ verification_id=verification["result"]["id"],
+ final_authorized=True, idempotency_key="final-1")
+ self.assertFalse(denied["ok"])
+ self.assertEqual(denied["error"]["code"], "model_authorization_forbidden")
+ verification_output = {"verified": True, "checks": ["receipt"]}
+ verification_receipt = {"receipt_id": "verification-receipt", "session_id": sid,
+ "capability": "broker", "tool_name": "verify",
+ "input_hash": _canonical_hash({"verification_id": verification["result"]["id"]}),
+ "output_hash": _canonical_hash(verification_output),
+ "success": True, "output": verification_output}
+ register_host_verification(self.name, verification["result"]["id"], verification_receipt)
+ finalized = self.call(action="finalize", session_id=sid,
+ verification_id=verification["result"]["id"], idempotency_key="final-1")
+ self.assertTrue(finalized["ok"])
+ self.assertEqual(finalized["state"], "finalized")
+ replay = self.call(action="finalize", session_id=sid,
+ verification_id=verification["result"]["id"], idempotency_key="final-1")
+ self.assertTrue(replay["ok"])
+ self.assertTrue(replay["idempotent_replay"])
+
+ def test_capabilities_are_explicit_and_honest(self):
+ result = handle_fable_control_plane({"action": "capabilities", "profile": self.profile})
+ self.assertTrue(result["ok"])
+ self.assertEqual(result["capabilities"]["profile"], self.profile)
+ self.assertEqual(result["enforcement"]["native_tools"], "not_controlled_unless_routed_through_broker")
+
+ def test_capabilities_reject_unknown_and_authorization_fields(self):
+ unknown = handle_fable_control_plane({"action": "capabilities", "future": True})
+ self.assertFalse(unknown["ok"])
+ self.assertEqual(unknown["error"]["code"], "unknown_field")
+ forbidden = handle_fable_control_plane({"action": "capabilities", "approval": True})
+ self.assertFalse(forbidden["ok"])
+ self.assertEqual(forbidden["error"]["code"], "model_authorization_forbidden")
+
+ def test_canonical_and_strict_payloads_reject_non_finite_numbers(self):
+ with self.assertRaises(ValueError):
+ _canonical_hash({"value": float("nan")})
+ result = self.call(action="observe", session_name=self.name,
+ objective="Validate strict control plane transitions",
+ observation={"value": float("inf")}, idempotency_key="observe-nan")
+ self.assertFalse(result["ok"])
+ self.assertEqual(result["error"]["code"], "invalid_arguments")
+
+ def test_proposed_action_and_outcome_are_receipt_bound(self):
+ observed = self.observe(); sid = observed["session_id"]
+ predicted = self.call(action="record_prediction", session_id=sid,
+ prediction="The concrete operation should complete successfully", idempotency_key="predict-1")
+ proposed = self.call(action="propose_action", session_id=sid,
+ prediction_id=predicted["result"]["id"], action_name="write_file",
+ capability="filesystem.write", mutating=True, arguments={"path": "out.txt"}, idempotency_key="action-1")
+ session = ACTIVE_SESSIONS[self.name]
+ output = {"ok": True}
+ receipt = {"receipt_id": "bound-receipt", "session_id": sid,
+ "capability": "filesystem.write", "tool_name": "write_file",
+ "tool_input": {"path": "out.txt"}, "input_hash": _canonical_hash({"path": "out.txt"}),
+ "output_hash": _canonical_hash(output), "success": True, "output": output}
+ session.register_host_receipt(receipt)
+ wrong = self.call(action="record_outcome", session_id=sid, action_id=proposed["result"]["id"],
+ outcome={"ok": False}, receipt_id=receipt["receipt_id"], idempotency_key="outcome-wrong")
+ self.assertFalse(wrong["ok"])
+ self.assertEqual(wrong["error"]["code"], "outcome_receipt_binding_mismatch")
+ good = self.call(action="record_outcome", session_id=sid, action_id=proposed["result"]["id"],
+ outcome=output, receipt_id=receipt["receipt_id"], idempotency_key="outcome-good")
+ self.assertTrue(good["ok"])
+
+ def test_verifier_receipt_must_bind_requested_outcome_and_checks(self):
+ observed = self.observe(); sid = observed["session_id"]
+ predicted = self.call(action="record_prediction", session_id=sid,
+ prediction="The concrete operation should complete successfully", idempotency_key="predict-1")
+ proposed = self.call(action="propose_action", session_id=sid,
+ prediction_id=predicted["result"]["id"], action_name="write_file",
+ capability="filesystem.write", mutating=True, arguments={}, idempotency_key="action-1")
+ session = ACTIVE_SESSIONS[self.name]; output = {"ok": True}
+ receipt = {"receipt_id": "out-receipt", "session_id": sid, "capability": "filesystem.write",
+ "tool_name": "write_file", "input_hash": _canonical_hash({}),
+ "output_hash": _canonical_hash(output), "success": True, "output": output}
+ session.register_host_receipt(receipt)
+ outcome = self.call(action="record_outcome", session_id=sid, action_id=proposed["result"]["id"],
+ outcome=output, receipt_id=receipt["receipt_id"], idempotency_key="outcome-1")
+ verification = self.call(action="request_verification", session_id=sid, outcome_id=outcome["result"]["id"],
+ checks=["receipt", "schema"], idempotency_key="verify-1")
+ vid = verification["result"]["id"]
+ unrelated = {"receipt_id": "unrelated", "session_id": sid, "capability": "broker",
+ "tool_name": "run_command", "input_hash": _canonical_hash({"verification_id": vid}),
+ "output_hash": _canonical_hash({"verified": True, "checks": ["other"]}),
+ "success": True, "output": {"verified": True, "checks": ["other"]}}
+ with self.assertRaises(ValueError):
+ register_host_verification(self.name, vid, unrelated)
+
+ def test_authority_deadline_is_sealed_after_construction(self):
+ session = FableSession("sealed_deadline", "test objective", 1)
+ with self.assertRaises(AttributeError):
+ session._authority_deadline_monotonic = 0
+ with self.assertRaises(AttributeError):
+ session.time_budget_minutes = 0.1
+
+ def test_generic_boilerplate_action_names_are_rejected(self):
+ observed = self.observe(); sid = observed["session_id"]
+ predicted = self.call(action="record_prediction", session_id=sid,
+ prediction="The concrete operation should complete successfully", idempotency_key="predict-1")
+ result = self.call(action="propose_action", session_id=sid,
+ prediction_id=predicted["result"]["id"], action_name="perform the operation",
+ mutating=True, arguments={}, idempotency_key="action-1")
+ self.assertFalse(result["ok"])
+ self.assertEqual(result["error"]["code"], "invalid_action")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_documentation.py b/tests/test_documentation.py
new file mode 100644
index 0000000..c5b985e
--- /dev/null
+++ b/tests/test_documentation.py
@@ -0,0 +1,53 @@
+"""Regression test for the repository documentation and API drift checks."""
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+
+
+CANONICAL_V1_COMMAND = "python -m unittest discover -s tests -p 'test_server*.py' -v"
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class DocumentationChecks(unittest.TestCase):
+ def test_documentation_checker_passes(self):
+ result = subprocess.run(
+ [sys.executable, str(ROOT / "docs" / "check_docs.py")],
+ cwd=ROOT,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+ self.assertIn("Documentation checks passed", result.stdout)
+
+ def test_readme_command_and_manifest_match_ci_and_source(self):
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ workflow = (ROOT / ".github" / "workflows" / "test.yml").read_text(encoding="utf-8")
+ self.assertEqual(readme.count(CANONICAL_V1_COMMAND), 1)
+ self.assertIn(CANONICAL_V1_COMMAND, workflow)
+ self.assertNotIn("python fable_engine/test_server.py", readme)
+
+ manifest = json.loads((ROOT / "fable_mode" / "resources.json").read_text(encoding="utf-8"))
+ from fable_mode.manifest import ALLOWED_FILES
+ self.assertEqual(manifest["files"], list(ALLOWED_FILES))
+ self.assertTrue({
+ "fable_v2/system3/__init__.py",
+ "fable_v2/system3/causal.py",
+ "fable_v2/system3/dialectical.py",
+ "fable_v2/system3/evolution.py",
+ "fable_v2/system3/executive.py",
+ "fable_v2/system3/free_energy.py",
+ "fable_v2/system3/hyperbolic.py",
+ "fable_v2/system3/induction.py",
+ "fable_v2/system3/kripke.py",
+ "fable_v2/system3/oracle.py",
+ } <= set(manifest["files"]), "manifest must ship the complete System 3 package")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_execution_broker.py b/tests/test_execution_broker.py
index 90006a5..aed2388 100644
--- a/tests/test_execution_broker.py
+++ b/tests/test_execution_broker.py
@@ -2,16 +2,20 @@
import io
import json
import os
+import shutil
import subprocess
import sys
import tempfile
+import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from fable_v2 import BrokerPolicy, ExecutionBroker
-from fable_v2.execution_broker import MAX_ERROR_TEXT, MAX_FRAME_BYTES, serve
+from fable_v2.execution_broker import (
+ BROKER_PROBE_FIELDS, MAX_ERROR_TEXT, MAX_FRAME_BYTES, serve,
+)
class ExecutionBrokerTests(unittest.TestCase):
@@ -28,6 +32,115 @@ def setUp(self):
def tearDown(self):
self.tempdir.cleanup()
+ def test_probe_has_the_stable_contract_fields(self):
+ expected = {
+ "host", "capabilities", "available_executables", "executable_identities",
+ "execution_binding", "writes_enabled", "read_locked_interpreters",
+ "workspace", "workspace_identity",
+ }
+ probe = self.broker.probe()
+ self.assertEqual(set(BROKER_PROBE_FIELDS), expected)
+ self.assertEqual(set(probe), expected)
+
+ def test_default_probe_skips_optional_missing_pytest(self):
+ """The documented default must work without the optional pytest command."""
+ import fable_v2.execution_broker as module
+ real_which = shutil.which
+
+ def which_without_pytest(name):
+ return None if name == "pytest" else real_which(name)
+
+ with patch.object(module.shutil, "which", side_effect=which_without_pytest):
+ broker = ExecutionBroker(BrokerPolicy(self.workspace))
+ self.assertIn("python", broker.probe()["available_executables"])
+ self.assertNotIn("pytest", broker.probe()["available_executables"])
+
+ @unittest.skipIf(os.name == "nt", "descriptor-pinned interpreter test is POSIX-only")
+ def test_default_probe_subprocess_works_without_pytest_on_path(self):
+ """Exercise the documented default CLI in a deliberately minimal PATH."""
+ env = {"PATH": str(Path(sys.executable).parent),
+ "PYTHONPATH": os.getcwd()}
+ request = json.dumps({"action": "probe"}) + "\n"
+ completed = subprocess.run(
+ [sys.executable, "-m", "fable_v2.execution_broker",
+ "--workspace", str(self.workspace)],
+ input=request, text=True, capture_output=True, env=env, check=False,
+ )
+ self.assertEqual(completed.returncode, 0, completed.stderr)
+ response = json.loads(completed.stdout)
+ self.assertTrue(response["ok"])
+ self.assertIn("python", response["result"]["available_executables"])
+ self.assertNotIn("pytest", response["result"]["available_executables"])
+
+ @unittest.skipIf(os.name == "nt" or sys.platform == "darwin", "descriptor-pinned interpreter test requires Linux procfs")
+ def test_script_uses_pinned_interpreter_after_path_replacement(self):
+ """Replacing a shebang path between validation and Popen cannot alter execution."""
+ import fable_v2.execution_broker as module
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ interpreter = root / "interpreter"
+ shutil.copyfile("/bin/sh", interpreter)
+ interpreter.chmod(0o700)
+ script = root / "runner"
+ script.write_text(f"#!{interpreter}\necho pinned\n", encoding="utf-8")
+ script.chmod(0o700)
+ replacement = root / "replacement"
+ replacement.write_text("#!/bin/sh\necho replaced\n", encoding="utf-8")
+ replacement.chmod(0o700)
+ # The replacement is visibly distinguishable: an unpinned kernel
+ # shebang lookup would run this file instead of the original shell.
+ broker = ExecutionBroker(BrokerPolicy(
+ root, (str(script), str(interpreter)),
+ write_token_digest=hashlib.sha256(b"admin-token").hexdigest(),
+ ))
+ broker.unlock_writes("admin-token")
+ original_popen = module.subprocess.Popen
+
+ def replace_before_spawn(*args, **kwargs):
+ os.replace(replacement, interpreter)
+ return original_popen(*args, **kwargs)
+
+ with patch.object(module.subprocess, "Popen", side_effect=replace_before_spawn):
+ result = broker.execute_command([str(script)])
+ self.assertTrue(result["success"])
+ self.assertIn("pinned", result["stdout"])
+
+ @unittest.skipIf(os.name == "nt", "Windows broker filesystem/process capabilities are fail-closed")
+ def test_attempted_write_quota_is_shared_and_persistent(self):
+ """Fresh broker instances observe prior replacement attempts via the ledger."""
+ token_digest = hashlib.sha256(b"admin-token").hexdigest()
+ first = ExecutionBroker(BrokerPolicy(
+ self.workspace, (self.executable,), max_file_write_bytes=4,
+ max_workspace_write_bytes=8, write_token_digest=token_digest,
+ ))
+ second = ExecutionBroker(BrokerPolicy(
+ self.workspace, (self.executable,), max_file_write_bytes=4,
+ max_workspace_write_bytes=8, write_token_digest=token_digest,
+ ))
+ first.unlock_writes("admin-token")
+ second.unlock_writes("admin-token")
+ first.write_file("replaced.txt", "ab")
+ second.write_file("replaced.txt", "cd")
+ with self.assertRaises(PermissionError):
+ first.write_file("replaced.txt", "e")
+ ledger = json.loads((self.workspace / ".fable-workspace-quota.json").read_text())
+ self.assertEqual(ledger["attempted_bytes"], 4)
+ self.assertEqual(ledger["files"]["replaced.txt"], 4)
+
+ @unittest.skipIf(os.name == "nt", "Windows broker filesystem/process capabilities are fail-closed")
+ def test_attempted_write_ledger_has_bounded_entries(self):
+ import fable_v2.execution_broker as module
+ token_digest = hashlib.sha256(b"admin-token").hexdigest()
+ broker = ExecutionBroker(BrokerPolicy(
+ self.workspace, (self.executable,), max_file_write_bytes=4,
+ max_workspace_write_bytes=8, write_token_digest=token_digest,
+ ))
+ broker.unlock_writes("admin-token")
+ with patch.object(module, "MAX_WORKSPACE_QUOTA_LEDGER_ENTRIES", 1):
+ broker.write_file("first.txt", "")
+ with self.assertRaises(PermissionError):
+ broker.write_file("second.txt", "")
+
def test_interpreters_are_blocked_before_write_authorization(self):
# shell=False does not stop Python from opening files directly.
with self.assertRaises(PermissionError):
@@ -36,6 +149,7 @@ def test_interpreters_are_blocked_before_write_authorization(self):
])
self.assertFalse((self.workspace / "unauthorized.txt").exists())
+ @unittest.skipIf(os.name == "nt", "Windows broker filesystem/process capabilities are fail-closed")
def test_command_is_allowlisted_and_runs_after_authorization(self):
with self.assertRaises(PermissionError):
self.broker.execute_command(["sh", "-c", "echo escaped"])
@@ -46,6 +160,7 @@ def test_command_is_allowlisted_and_runs_after_authorization(self):
self.assertTrue(result["success"])
self.assertIn("broker-ok", result["stdout"])
+ @unittest.skipIf(os.name == "nt", "Windows broker filesystem/process capabilities are fail-closed")
def test_inspect_files_is_implemented_and_bounded(self):
target = self.workspace / "input.txt"
target.write_text("hello world", encoding="utf-8")
@@ -55,6 +170,7 @@ def test_inspect_files_is_implemented_and_bounded(self):
self.assertEqual(result["content_hash"], hashlib.sha256(b"hello world").hexdigest())
self.assertEqual(self.broker.handle({"action": "probe_capabilities"}), self.broker.probe())
+ @unittest.skipIf(os.name == "nt", "Windows broker filesystem/process capabilities are fail-closed")
def test_subprocess_output_is_bounded_before_capture(self):
limited = ExecutionBroker(BrokerPolicy(
workspace=self.workspace,
@@ -95,7 +211,86 @@ def test_json_lines_frames_are_bounded_and_continue_after_oversize(self):
self.assertFalse(responses[0]["ok"])
self.assertLessEqual(len(responses[0]["message"].encode()), MAX_ERROR_TEXT)
self.assertTrue(responses[1]["ok"])
- self.assertIn("execute_command", responses[1]["result"]["capabilities"])
+ if os.name == "nt":
+ self.assertNotIn("execute_command", responses[1]["result"]["capabilities"])
+ else:
+ self.assertIn("execute_command", responses[1]["result"]["capabilities"])
+
+ def test_mcp_worker_admission_is_bounded_and_completed_workers_release_slots(self):
+ """A connection rejects overload and admits a call after cleanup."""
+ import fable_v2.execution_broker as module
+
+ first = json.dumps({
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
+ "params": {"name": "probe", "arguments": {}},
+ }).encode() + b"\n"
+ second = json.dumps({
+ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
+ "params": {"name": "probe", "arguments": {}},
+ }).encode() + b"\n"
+ third = json.dumps({
+ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
+ "params": {"name": "probe", "arguments": {}},
+ }).encode() + b"\n"
+ started = threading.Event()
+ release = threading.Event()
+ first_done = threading.Event()
+
+ def fake_dispatch(_broker, request, _state):
+ if request.get("id") == 1:
+ started.set()
+ release.wait(2)
+ first_done.set()
+ return {"jsonrpc": "2.0", "id": request.get("id"), "result": {}}
+
+ class ControlledInput:
+ def __init__(self):
+ self.frames = (first, second, third)
+ self.frame = 0
+ self.offset = 0
+
+ def read(self, _size=1):
+ if self.frame == 0 and self.offset == len(self.frames[0]):
+ self.frame = 1
+ self.offset = 0
+ self.assert_started = started.wait(2)
+ if self.frame == 1 and self.offset == len(self.frames[1]):
+ release.set()
+ self.assert_first_done = first_done.wait(2)
+ self.frame = 2
+ self.offset = 0
+ if self.frame == 2 and self.offset == len(self.frames[2]):
+ return b""
+ if self.frame >= len(self.frames):
+ return b""
+ frame = self.frames[self.frame]
+ value = frame[self.offset:self.offset + 1]
+ self.offset += 1
+ return value
+
+ output = io.StringIO()
+ source = ControlledInput()
+ with patch.object(module, "_dispatch_jsonrpc", side_effect=fake_dispatch), \
+ patch.object(module.sys, "stdin", source), \
+ patch.object(module.sys, "stdout", output):
+ module.serve(self.broker, max_workers=1)
+ responses = [json.loads(line) for line in output.getvalue().splitlines()]
+ by_id = {response["id"]: response for response in responses}
+ self.assertEqual(set(by_id), {1, 2, 3})
+ self.assertEqual(by_id[1]["result"], {})
+ overload = by_id[2]["error"]
+ self.assertEqual(overload["code"], module.MCP_OVERLOAD_ERROR_CODE)
+ self.assertEqual(overload["data"]["type"], "overloaded")
+ self.assertEqual(overload["data"]["max_workers"], 1)
+ self.assertTrue(overload["data"]["retryable"])
+ self.assertEqual(by_id[3]["result"], {})
+
+ def test_mcp_worker_limit_is_validated(self):
+ import fable_v2.execution_broker as module
+ with self.assertRaises(ValueError):
+ module.MCPConnectionState(max_workers=0)
+ with self.assertRaises(ValueError):
+ module.MCPConnectionState(max_workers=module.MAX_MCP_WORKERS + 1)
def test_malformed_json_is_a_bounded_controlled_error(self):
output = io.StringIO()
@@ -107,6 +302,7 @@ def test_malformed_json_is_a_bounded_controlled_error(self):
self.assertFalse(response["ok"])
self.assertLessEqual(len(response["message"].encode()), MAX_ERROR_TEXT + 20)
+ @unittest.skipIf(os.name == "nt", "Windows broker filesystem writes are fail-closed")
def test_writes_are_locked_until_admin_authorization(self):
with self.assertRaises(PermissionError):
self.broker.write_file("result.txt", "blocked")
diff --git a/tests/test_fable_v1_residual.py b/tests/test_fable_v1_residual.py
new file mode 100644
index 0000000..da9d553
--- /dev/null
+++ b/tests/test_fable_v1_residual.py
@@ -0,0 +1,86 @@
+"""Adversarial coverage for the V1 server boundary and gate contract."""
+from __future__ import annotations
+
+import io
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from fable_engine.server import (
+ FableCASStore, FableSession, FableCASError, EvidenceReceiptError,
+ _canonical_hash, main,
+)
+
+
+class V1ResidualAdversarialTests(unittest.TestCase):
+ def _receipt(self, session: FableSession, receipt_id: str, output):
+ return {
+ "receipt_id": receipt_id, "session_id": session.session_id,
+ "capability": "test", "tool_name": "test-runner",
+ "input_hash": _canonical_hash({"id": receipt_id}),
+ "output_hash": _canonical_hash(output), "success": True,
+ "output": output,
+ }
+
+ def test_proven_items_cannot_reuse_receipt_or_output(self):
+ session = FableSession("distinct_receipts", "test", 1)
+ output = {"passed": True}
+ receipt = self._receipt(session, "r1", output)
+ session.register_host_receipt(receipt)
+ evidence = {"receipt_id": "r1", "session_id": session.session_id,
+ "content": output, "content_hash": receipt["output_hash"],
+ "source_output_hash": receipt["output_hash"],
+ "claim": "first independent result"}
+ session.log_epistemic_item("PROVEN", "first independent result", evidence)
+ with self.assertRaises(EvidenceReceiptError):
+ session.log_epistemic_item("PROVEN", "same result again", evidence)
+ with self.assertRaises(EvidenceReceiptError):
+ session.register_host_receipt(self._receipt(session, "r2", output))
+
+ def test_placeholder_invariants_are_not_gate_inputs(self):
+ session = FableSession("substantive_invariant", "test", 1)
+ for statement, proof in (("statement", "rationale"), ("the system is correct", "because"),
+ ("TODO", "TODO")):
+ with self.assertRaises(ValueError):
+ session.record_invariant("INV", statement, proof)
+
+ def test_phase_progression_requires_typed_substantive_prerequisites(self):
+ session = FableSession("phase_prereqs", "test", 1)
+ with self.assertRaises(ValueError):
+ session.advance_phase("Phase 2: Invariant Specification & Blueprint", "done")
+ session.log_epistemic_item("HYPOTHESIS", "A measurable design assumption")
+ session.advance_phase("Phase 2: Invariant Specification & Blueprint", "Grounding identified the design assumptions")
+ with self.assertRaises(ValueError):
+ session.advance_phase("Phase 3: Adversarial Red-Teaming & Falsification", "ready")
+
+ def test_scoped_cas_isolated_and_quota_bounded(self):
+ with tempfile.TemporaryDirectory() as td:
+ a = FableCASStore(Path(td), namespace="session-a", max_namespace_bytes=4,
+ max_namespace_objects=1)
+ b = FableCASStore(Path(td), namespace="session-b", max_namespace_bytes=4,
+ max_namespace_objects=1)
+ ref = a.put(b"1234")
+ with self.assertRaises(Exception):
+ b.get_bytes(ref)
+ with self.assertRaises(FableCASError):
+ a.put(b"5678")
+
+ def test_deadline_is_rejected_as_unsupported_not_posthoc(self):
+ wire = "".join(json.dumps(x) + "\n" for x in (
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
+ "name": "fable_session", "arguments": {"action": "list_sessions"},
+ "deadline_seconds": 1}},
+ ))
+ out = io.StringIO()
+ with patch("sys.stdin", io.StringIO(wire)), patch("sys.stdout", out):
+ main()
+ responses = [json.loads(x) for x in out.getvalue().splitlines()]
+ self.assertEqual(responses[1]["error"]["code"], -32003)
+ self.assertIn("Unsupported", responses[1]["error"]["message"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_fable_v2.py b/tests/test_fable_v2.py
index c59bdaa..4e3c00d 100644
--- a/tests/test_fable_v2.py
+++ b/tests/test_fable_v2.py
@@ -25,16 +25,24 @@ def setUp(self):
required_capabilities=("inspect_files", "run_tests"),
required_evidence=("test-result",),
)
- self.run = new_run("session-001", self.task)
+ # This fixture exercises the pre-claim legacy API explicitly.
+ self.run = new_run("session-001", self.task, compatibility_mode=True)
+ # Positive independent-verifier tests use a second, measured producer.
+ # This keeps the fixture faithful to the fail-closed policy: labels and
+ # a same-receipt self-declaration are not independence evidence.
self.inspect = ToolReceipt.from_result(
receipt_id="r-inspect", session_id="session-001",
capability="inspect_files", tool_name="grep",
tool_input={"query": "bug"}, tool_output={"matches": 2}, success=True,
+ executable_identity={"digest": "independent-producer"},
+ workspace_identity={"digest": "independent-workspace"},
)
self.tests = ToolReceipt.from_result(
receipt_id="r-tests", session_id="session-001",
capability="run_tests", tool_name="pytest",
tool_input={"target": "tests"}, tool_output={"passed": True}, success=True,
+ executable_identity={"digest": "deterministic-producer"},
+ workspace_identity={"digest": "deterministic-workspace"},
)
self.run.record_receipt(self.inspect)
self.run.record_receipt(self.tests)
@@ -45,11 +53,20 @@ def setUp(self):
kind="test-result",
source="pytest: tests",
)
+ self.inspect_evidence = Evidence.from_receipt(
+ self.inspect,
+ evidence_id="e-inspect",
+ claim="The candidate was independently inspected",
+ kind="inspection",
+ source="independent inspector",
+ )
self.run.attach_evidence(self.evidence)
+ self.run.attach_evidence(self.inspect_evidence)
self.candidate = Candidate(
candidate_id="candidate-001", session_id="session-001",
approach="minimal patch", artifact={"diff": "..."},
- receipt_ids=("r-inspect", "r-tests"), evidence_ids=("e-tests",),
+ receipt_ids=("r-inspect", "r-tests"),
+ evidence_ids=("e-tests", "e-inspect"),
)
self.run.register_candidate(self.candidate)
@@ -156,7 +173,7 @@ def test_verified_candidate_can_finalize(self):
), "candidate-001")
self.run.execute_verifier(FunctionVerifier(
"independent-review", lambda candidate: (True, ("review passed",), 1.0),
- verifier_class="independent", independent=True, evidence_ids=("e-tests",),
+ verifier_class="independent", independent=True, evidence_ids=("e-inspect",),
), "candidate-001")
result = self.run.finalize("candidate-001")
self.assertEqual(result.candidate_id, "candidate-001")
@@ -279,7 +296,7 @@ def test_finalization_rechecks_verifier_validity(self):
), "candidate-001")
self.run.execute_verifier(FunctionVerifier(
"independent-review", lambda candidate: (True, ("review passed",), 1.0),
- verifier_class="independent", independent=True, evidence_ids=("e-tests",),
+ verifier_class="independent", independent=True, evidence_ids=("e-inspect",),
), "candidate-001")
self.run.invalidate_verifier("independent-review", "calibration drift")
with self.assertRaises(PermissionError) as error:
@@ -343,7 +360,7 @@ def test_restored_verdict_is_checked_against_complete_attestation(self):
), "candidate-001")
self.run.execute_verifier(FunctionVerifier(
"independent-review", lambda candidate: (True, ("review passed",), 1.0),
- verifier_class="independent", independent=True, evidence_ids=("e-tests",),
+ verifier_class="independent", independent=True, evidence_ids=("e-inspect",),
), "candidate-001")
payload = self.run.to_dict()
self.assertEqual(FableRun.from_dict(payload).status()["verifications"], 2)
diff --git a/tests/test_final_integrity_redteam.py b/tests/test_final_integrity_redteam.py
new file mode 100644
index 0000000..e17760a
--- /dev/null
+++ b/tests/test_final_integrity_redteam.py
@@ -0,0 +1,82 @@
+"""Focused adversarial probes for the final integrity remediation."""
+import math
+import unittest
+
+from fable_engine.server import _deadline_seconds
+from fable_v2 import Candidate, Evidence, FunctionVerifier, TaskSpec, ToolReceipt, VerificationPolicy, new_run
+from fable_v2.protocol import canonical_hash
+
+
+class FinalIntegrityRedTeamTests(unittest.TestCase):
+ def _run(self):
+ task = TaskSpec(
+ "integrity", "integrity objective", definition_of_done=("done",),
+ verification_policy=VerificationPolicy(
+ required_verifier_classes=("deterministic",),
+ minimum_passing_verifiers=1, require_independent=False,
+ ),
+ )
+ run = new_run("integrity-session", task)
+ receipt = ToolReceipt.from_result(
+ receipt_id="receipt", session_id=run.session_id, capability="check",
+ tool_name="checker", tool_input="input", tool_output={"ok": True}, success=True,
+ )
+ run.record_receipt(receipt)
+ run.attach_evidence(Evidence.from_receipt(
+ receipt, evidence_id="evidence", claim="measured check", kind="check", source="source",
+ ))
+ run.register_candidate(Candidate(
+ "candidate", run.session_id, "approach", {"answer": 1}, ("receipt",), ("evidence",),
+ ))
+ run.execute_verifier(FunctionVerifier(
+ "verifier", lambda candidate: (True, ("checked",), 1), evidence_ids=("evidence",),
+ ), "candidate")
+ return run
+
+ def test_revocation_event_survives_direct_map_clear(self):
+ run = self._run()
+ run.invalidate_verifier("verifier", "compromised")
+ run.invalidated_verifiers.clear()
+ self.assertEqual(run.passed_verifications("candidate"), [])
+ with self.assertRaises(PermissionError):
+ run.finalize("candidate")
+
+ def test_each_candidate_keyed_telemetry_map_is_closed(self):
+ for field_name in (
+ "system3_free_energy", "system3_active_inference",
+ "system3_kripke_invariants", "system3_hyperbolic_embeddings",
+ ):
+ run = self._run()
+ getattr(run, field_name).pop("candidate")
+ with self.assertRaises(PermissionError):
+ run.finalize("candidate")
+
+ def test_correctly_rehashed_fabricated_meta_event_has_no_backing(self):
+ run = self._run()
+ event = {
+ "type": "system3_meta_cycle_completed", "at": "2026-01-01T00:00:00+00:00",
+ "candidate_id": "candidate", "cycle_hash": canonical_hash({"fake": True}),
+ "prev_hash": run.events[-1]["event_hash"],
+ }
+ event["event_hash"] = canonical_hash(event)
+ run.events.append(event)
+ with self.assertRaises(PermissionError):
+ run.finalize("candidate")
+
+ def test_recursive_nonfinite_metadata_is_rejected(self):
+ with self.assertRaises(ValueError):
+ Candidate("c", "s", "approach", {"nested": [{"value": math.nan}]})
+ run = self._run()
+ run.candidates["candidate"].metadata["caller"] = {"nested": math.inf}
+ with self.assertRaises(PermissionError):
+ run.finalize("candidate")
+
+ def test_nested_legacy_deadline_is_explicitly_rejected(self):
+ with self.assertRaises(ValueError):
+ _deadline_seconds({"_meta": {"request": {"deadline_seconds": 1}}})
+ with self.assertRaises(ValueError):
+ _deadline_seconds({"arguments": {"deadline": 1}})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_harness_compatibility.py b/tests/test_harness_compatibility.py
new file mode 100644
index 0000000..ade7ecc
--- /dev/null
+++ b/tests/test_harness_compatibility.py
@@ -0,0 +1,89 @@
+"""Portable harness compatibility fixtures; these do not certify real hosts."""
+import hashlib
+import io
+import json
+import pathlib
+import sys
+import tempfile
+import threading
+import os
+import time
+import unittest
+from unittest.mock import patch
+
+from fable_mode.adapters import (
+ HOST_IDENTITY_REGISTRY, GenericMCPHostAdapter, canonical_host_id,
+ detect_hosts,
+)
+from fable_v2.execution_broker import BrokerPolicy, ExecutionBroker, serve
+
+
+class HarnessCompatibilityTests(unittest.TestCase):
+ def test_canonical_host_aliases_and_fixture_configs(self):
+ self.assertEqual(canonical_host_id("claude"), "claude-code")
+ self.assertEqual(canonical_host_id("claude-code"), "claude-code")
+ self.assertEqual(canonical_host_id("agy"), "antigravity")
+ self.assertEqual(set(HOST_IDENTITY_REGISTRY), {"claude-code", "codex", "antigravity"})
+ root = pathlib.Path(__file__).parent / "fixtures" / "hosts"
+ for name in ("claude", "codex", "antigravity"):
+ data = json.loads((root / f"{name}-config.json").read_text())
+ self.assertIsInstance(data["mcpServers"], dict)
+
+ def test_discovery_pins_documented_machine_readable_listing(self):
+ exe = pathlib.Path(sys.executable)
+ with patch("fable_mode.adapters._which", return_value=exe), \
+ patch("fable_mode.adapters._run_probe", return_value=(0, "ok", "", False)):
+ hosts = detect_hosts()
+ self.assertEqual(hosts["claude"].canonical_name, "claude-code")
+ self.assertEqual(HOST_IDENTITY_REGISTRY["codex"].list_argv[-1], "--json")
+
+ def test_generic_mcp_surface_and_receipt(self):
+ adapter = GenericMCPHostAdapter()
+ self.assertEqual(adapter.initialize()["method"], "initialize")
+ self.assertEqual(adapter.route_tool("tools" )["method"], "tools/call")
+ receipt = adapter.receipt(tool_input={"x": 1}, tool_output={"ok": True}, success=True)
+ self.assertTrue(receipt.success)
+
+ def test_jsonrpc_notification_schema_and_structured_error(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ broker = ExecutionBroker(BrokerPolicy(pathlib.Path(tmp), allowed_executables=(pathlib.Path(sys.executable).name,)))
+ output = io.StringIO()
+ payload = (
+ json.dumps({"jsonrpc": "2.0", "id": 6, "method": "initialize",
+ "params": {"protocolVersion": "2024-11-05"}}) + "\n"
+ + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n"
+ + json.dumps({"jsonrpc": "2.0", "id": 7, "method": "tools/list", "params": {}}) + "\n"
+ + json.dumps({"jsonrpc": "2.0", "id": 8, "method": "not-a-method"}) + "\n"
+ )
+ import fable_v2.execution_broker as module
+ with patch.object(module.sys, "stdin", io.StringIO(payload)), patch.object(module.sys, "stdout", output):
+ serve(broker)
+ responses = [json.loads(line) for line in output.getvalue().splitlines()]
+ self.assertEqual(len(responses), 3)
+ self.assertEqual(responses[0]["id"], 6)
+ self.assertEqual(responses[0]["result"]["protocolVersion"], "2024-11-05")
+ self.assertEqual(responses[1]["id"], 7)
+ self.assertIn("tools", responses[1]["result"])
+ self.assertIsInstance(responses[2]["error"], dict)
+ self.assertEqual(responses[2]["error"]["code"], -32601)
+
+ @unittest.skipIf(os.name == "nt", "Windows broker execution is deliberately fail-closed")
+ def test_request_id_cancellation(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ broker = ExecutionBroker(BrokerPolicy(pathlib.Path(tmp), allowed_executables=(pathlib.Path(sys.executable).name,), write_token_digest=hashlib.sha256(b"x").hexdigest()))
+ broker.unlock_writes("x")
+ result = []
+ worker = threading.Thread(target=lambda: result.append(broker.handle({
+ "action": "execute_command", "request_id": "run-1",
+ "command": [sys.executable, "-c", "import time; time.sleep(3)"],
+ "timeout_seconds": 10,
+ })))
+ worker.start(); time.sleep(.1)
+ self.assertTrue(broker.handle({"action": "cancel", "request_id": "run-1"})["cancelled"])
+ worker.join(5)
+ self.assertTrue(result and result[0]["cancelled"])
+ self.assertEqual(result[0]["request_id"], "run-1")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_intelligent_verifier_integration.py b/tests/test_intelligent_verifier_integration.py
new file mode 100644
index 0000000..c7cb02a
--- /dev/null
+++ b/tests/test_intelligent_verifier_integration.py
@@ -0,0 +1,80 @@
+"""Adversarial integration coverage for the strict intelligent-verifier gate."""
+import unittest
+
+from fable_v2 import Candidate, Evidence, FableRun, FunctionVerifier, TaskSpec, ToolReceipt, VerificationPolicy, new_run
+from fable_v2.protocol import VerificationStatus
+from fable_v2.verifiers import ClaimGraph, Counterexample, CounterexampleStore, ThreeValuedAdjudicator, VerificationDecision
+
+
+class IntelligentVerifierIntegrationTests(unittest.TestCase):
+ def _run(self, *, strict_system3=False, compatibility_mode=False):
+ task = TaskSpec(
+ "gate", "produce answer", definition_of_done=("answer is checked",),
+ metadata={"require_system3_loop": strict_system3},
+ verification_policy=VerificationPolicy(
+ required_verifier_classes=("deterministic",),
+ minimum_passing_verifiers=1, require_independent=False,
+ ),
+ )
+ run = new_run("gate-session", task, compatibility_mode=compatibility_mode)
+ receipt = ToolReceipt.from_result(
+ receipt_id="receipt", session_id=run.session_id, capability="check",
+ tool_name="checker", tool_input="in", tool_output={"ok": True}, success=True,
+ trust_boundary="host",
+ )
+ run.record_receipt(receipt)
+ run.attach_evidence(Evidence.from_receipt(
+ receipt, evidence_id="evidence", claim="checked", kind="check", source="checker",
+ ))
+ candidate = Candidate("candidate", run.session_id, "approach", {"answer": 1},
+ ("receipt",), ("evidence",))
+ run.register_candidate(candidate)
+ return run, candidate, ClaimGraph.from_task(task, candidate)
+
+ def test_default_finalization_rejects_uncovered_claims(self):
+ run, _, _ = self._run()
+ run.execute_verifier(FunctionVerifier("legacy-shaped", lambda _: (True, ("ok",), 1), evidence_ids=("evidence",)), "candidate")
+ with self.assertRaises(PermissionError):
+ run.finalize("candidate")
+
+ def test_explicit_compatibility_mode_preserves_legacy_finalization(self):
+ run, candidate, _ = self._run(compatibility_mode=True)
+ # This is an explicit mode, not inference from omitted claim IDs.
+ run.execute_verifier(FunctionVerifier("legacy-shaped", lambda _: (True, ("ok",), 1), evidence_ids=("evidence",)), candidate.candidate_id)
+ self.assertEqual(run.finalize(candidate.candidate_id).candidate_id, candidate.candidate_id)
+
+ def test_counterexample_forces_fail_even_with_pass(self):
+ _, candidate, graph = self._run()
+ result = VerificationDecision(VerificationStatus.PASS, claim_ids=graph.claim_ids,
+ evidence_ids=("evidence",), provenance_ids=("receipt",),
+ counterexample_ids=("ce",))
+ from fable_v2.verifiers import FunctionVerifier
+ result = FunctionVerifier("check", lambda _: result, claim_ids=graph.claim_ids).verify(candidate)
+ adjudication = ThreeValuedAdjudicator(require_independent=False).adjudicate(
+ graph, (result,), CounterexampleStore([Counterexample("ce", graph.claim_ids, {"bad": True}, "bad")]))
+ self.assertEqual(adjudication.status, VerificationStatus.FAIL)
+ self.assertFalse(adjudication.finalizable)
+
+ def test_policy_quorum_deduplicates_verifier_ids(self):
+ _, candidate, graph = self._run()
+ def make(status, vid):
+ return FunctionVerifier(vid, lambda _: VerificationDecision(status, claim_ids=graph.claim_ids,
+ evidence_ids=("evidence",), provenance_ids=("receipt",)), claim_ids=graph.claim_ids).verify(candidate)
+ first = make(VerificationStatus.PASS, "same")
+ duplicate = make(VerificationStatus.PASS, "same")
+ policy = VerificationPolicy(required_verifier_classes=("deterministic",), minimum_passing_verifiers=2, require_independent=False)
+ adjudication = ThreeValuedAdjudicator(policy=policy, require_independent=False).adjudicate(graph, (first, duplicate), policy=policy)
+ self.assertFalse(adjudication.finalizable)
+ self.assertTrue(any("2 passing" in reason for reason in adjudication.reasons))
+
+ def test_strict_system3_rejects_self_minted_host_receipt(self):
+ run, _, _ = self._run(strict_system3=True)
+ run.observe_system3("candidate", {"before": "unknown"})
+ prediction = run.predict_system3("candidate", "check", {"ok": True}, .5, "fails when ok is false")
+ run.act_system3("candidate", prediction.prediction_id)
+ with self.assertRaises(PermissionError):
+ run.record_system3_outcome("candidate", prediction.prediction_id, "receipt")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_intelligent_verifiers.py b/tests/test_intelligent_verifiers.py
new file mode 100644
index 0000000..656dbb8
--- /dev/null
+++ b/tests/test_intelligent_verifiers.py
@@ -0,0 +1,240 @@
+"""Focused adversarial tests for the first intelligent-verifier slice."""
+
+import math
+import unittest
+
+from fable_v2 import Candidate, TaskSpec
+from fable_v2.protocol import VerificationStatus, canonical_hash
+from fable_v2.verifiers import (
+ Adjudication,
+ Claim,
+ ClaimGraph,
+ Counterexample,
+ CounterexampleStore,
+ FunctionVerifier,
+ MetamorphicVerifier,
+ MutationVerifier,
+ RiskLevel,
+ ThreeValuedAdjudicator,
+ VerificationDecision,
+ VerifierPlanner,
+ VerifierPortfolio,
+)
+
+
+class IntelligentVerifierTests(unittest.TestCase):
+ def setUp(self):
+ self.task = TaskSpec(
+ "intelligent", "produce a result",
+ constraints=("do not delete data",),
+ definition_of_done=("tests pass",),
+ )
+ self.candidate = Candidate("candidate", "session", "test", {"result": 1})
+ self.graph = ClaimGraph.from_task(self.task, self.candidate)
+
+ def _result(self, name, status, claims, *, evidence=("e",), provenance=("p",), independent=False,
+ counterexamples=()):
+ return FunctionVerifier(
+ name,
+ lambda _: VerificationDecision(status, evidence_ids=evidence,
+ claim_ids=claims, provenance_ids=provenance,
+ counterexample_ids=counterexamples),
+ claim_ids=claims, provenance_ids=provenance, independent=independent,
+ ).verify(self.candidate)
+
+ def test_all_pass_does_not_overclaim_unmentioned_claims(self):
+ result = self._result("one-claim", VerificationStatus.PASS, (self.graph.claims[0].claim_id,))
+ adjudication = ThreeValuedAdjudicator(require_independent=False).adjudicate(
+ self.graph, (result,))
+ self.assertFalse(adjudication.finalizable)
+ self.assertTrue(adjudication.blocking_claim_ids)
+ self.assertIn("UNKNOWN", " ".join(adjudication.reasons))
+
+ def test_critical_unknown_blocks(self):
+ critical = Claim("critical", "do not lose data", "satisfies(do not lose data)",
+ RiskLevel.CRITICAL, "candidate:candidate", "observe data loss")
+ graph = ClaimGraph((critical,))
+ result = self._result("uncertain", VerificationStatus.UNKNOWN, ("critical",))
+ adjudication = ThreeValuedAdjudicator(require_independent=False).adjudicate(graph, (result,))
+ self.assertEqual(adjudication.status, VerificationStatus.UNKNOWN)
+ self.assertFalse(adjudication.can_finalize)
+
+ def test_allow_noncritical_unknown_never_covers_uncovered_claim(self):
+ adjudication = ThreeValuedAdjudicator(
+ require_independent=False, allow_noncritical_unknown=True,
+ ).adjudicate(self.graph, ())
+ self.assertNotEqual(adjudication.status, VerificationStatus.PASS)
+ self.assertFalse(adjudication.finalizable)
+ self.assertTrue(adjudication.blocking_claim_ids)
+
+ def test_standalone_registry_identity_and_content_hash_are_verified(self):
+ task = TaskSpec("registry", "check", definition_of_done=("done",))
+ candidate = Candidate("candidate", "session", "test", {"result": 1})
+ graph = ClaimGraph.from_task(task, candidate)
+ receipt = __import__("fable_v2").ToolReceipt.from_result(
+ receipt_id="receipt", session_id="session", capability="check",
+ tool_name="checker", tool_input="x", tool_output={"ok": True}, success=True,
+ )
+ evidence = __import__("fable_v2").Evidence.from_receipt(
+ receipt, evidence_id="evidence", claim="done", kind="check", source="checker",
+ )
+ result = self._result("registry", VerificationStatus.PASS, graph.claim_ids,
+ evidence=("evidence",), provenance=("evidence",))
+ adjudicator = ThreeValuedAdjudicator(require_independent=False)
+ valid = adjudicator.adjudicate(
+ graph, (result,), evidence_registry={"evidence": evidence},
+ receipt_registry={"receipt": receipt},
+ )
+ self.assertEqual(valid.status, VerificationStatus.PASS)
+ alias = evidence.to_dict(); alias["evidence_id"] = "other"
+ rejected = adjudicator.adjudicate(
+ graph, (result,), evidence_registry={"evidence": alias},
+ receipt_registry={"receipt": receipt},
+ )
+ self.assertEqual(rejected.status, VerificationStatus.FAIL)
+ altered = evidence.to_dict(); altered["content"] = {"ok": False}
+ rejected = adjudicator.adjudicate(
+ graph, (result,), evidence_registry={"evidence": altered},
+ receipt_registry={"receipt": receipt},
+ )
+ self.assertEqual(rejected.status, VerificationStatus.FAIL)
+
+ def test_counterexample_is_stored_and_propagated(self):
+ ce = Counterexample("ce-1", (self.graph.claims[0].claim_id,), {"bad": True},
+ "bad output", verifier="property", evidence_ids=("e",), provenance_ids=("p",))
+ store = CounterexampleStore()
+ store.add(ce)
+ failure = self._result("property", VerificationStatus.FAIL,
+ (self.graph.claims[0].claim_id,), counterexamples=("ce-1",))
+ adjudication = ThreeValuedAdjudicator(require_independent=False).adjudicate(
+ self.graph, (failure,), store)
+ self.assertEqual(adjudication.counterexamples[0].counterexample_id, "ce-1")
+ self.assertFalse(adjudication.finalizable)
+
+ def test_claim_decomposition_is_atomic_and_falsifiable(self):
+ self.assertGreaterEqual(len(self.graph.claims), 3)
+ for claim in self.graph.claims:
+ self.assertTrue(claim.predicate)
+ self.assertTrue(claim.scope)
+ self.assertTrue(claim.falsification)
+ self.assertTrue(any(c.source == "constraints" for c in self.graph.claims))
+
+ def test_planner_prefers_high_risk_information_gain_per_cost(self):
+ high = next(claim for claim in self.graph.claims if claim.source == "constraints")
+ low = next(claim for claim in self.graph.claims if claim.source == "objective")
+ expensive_high = FunctionVerifier(
+ "high-risk", lambda _: (False, ("not run",), None), claim_ids=(high.claim_id,),
+ uncertainty=.9, expected_information_gain=1.0, cost=1.0,
+ )
+ cheap_low = FunctionVerifier(
+ "low-risk", lambda _: (True, ("ok",), 1.0), claim_ids=(low.claim_id,),
+ uncertainty=.1, expected_information_gain=.2, cost=2.0,
+ )
+ plan = VerifierPlanner().plan(self.graph, (expensive_high, cheap_low))
+ self.assertEqual(plan.checks[0].verifier.name, "high-risk")
+ self.assertIn(high.claim_id, plan.checks[0].covered_claim_ids)
+
+ def test_pass_requires_evidence_and_provenance(self):
+ result = self._result("unproven", VerificationStatus.PASS, self.graph.claim_ids,
+ evidence=(), provenance=())
+ adjudication = ThreeValuedAdjudicator(require_independent=False).adjudicate(
+ self.graph, (result,))
+ self.assertFalse(adjudication.finalizable)
+ self.assertTrue(any("provenance" in reason for reason in adjudication.reasons))
+
+ def test_independent_verifiers_need_disjoint_provenance(self):
+ claims = self.graph.claim_ids
+ first = self._result("deterministic", VerificationStatus.PASS, claims,
+ provenance=("producer-a",))
+ same_producer = self._result("independent", VerificationStatus.PASS, claims,
+ provenance=("producer-a",), independent=True)
+ blocked = ThreeValuedAdjudicator().adjudicate(self.graph, (first, same_producer))
+ self.assertFalse(blocked.finalizable)
+ distinct = self._result("independent-2", VerificationStatus.PASS, claims,
+ provenance=("producer-b",), independent=True)
+ allowed = ThreeValuedAdjudicator().adjudicate(self.graph, (first, distinct))
+ self.assertTrue(allowed.finalizable)
+
+ def test_metadata_cannot_downgrade_intrinsic_critical_risk(self):
+ task = TaskSpec("risk", "delete financial records", definition_of_done=("done",),
+ metadata={"risk": "LOW"})
+ graph = ClaimGraph.from_task(task)
+ self.assertEqual(graph.claims[0].risk_level, RiskLevel.CRITICAL)
+
+ def test_intrinsic_destructive_and_security_phrases_are_critical(self):
+ for phrase in ("erase backups", "revoke credentials", "disable the firewall",
+ "exfiltrate private data", "run arbitrary code"):
+ task = TaskSpec("risk-" + phrase.split()[0], phrase,
+ definition_of_done=("done",), metadata={"risk": "LOW"})
+ self.assertEqual(ClaimGraph.from_task(task).claims[0].risk_level, RiskLevel.CRITICAL)
+
+ def test_counterexample_rehash_catches_base_class_mutator_bypass(self):
+ counterexample = Counterexample("bypass", ("claim",), {"nested": [1]}, "bad")
+ dict.__setitem__(counterexample.observation, "nested", [2])
+ with self.assertRaises(ValueError):
+ CounterexampleStore((counterexample,))
+
+ def test_compound_decomposition_rejects_missing_clause(self):
+ task = TaskSpec("compound", "first and", definition_of_done=("done",))
+ with self.assertRaises(ValueError):
+ ClaimGraph.from_task(task)
+
+ def test_claim_dependencies_are_known_and_acyclic(self):
+ first = Claim("first", "first", "p", RiskLevel.LOW, "s", "f")
+ dependent = Claim("dependent", "dependent", "p", RiskLevel.LOW, "s", "f",
+ dependencies=("first",))
+ graph = ClaimGraph((dependent, first))
+ self.assertEqual(graph.edges, (("first", "dependent"),))
+ with self.assertRaises(ValueError):
+ ClaimGraph((dependent,))
+ with self.assertRaises(ValueError):
+ ClaimGraph((Claim("a", "a", "p", RiskLevel.LOW, "s", "f",
+ dependencies=("b",)),
+ Claim("b", "b", "p", RiskLevel.LOW, "s", "f",
+ dependencies=("a",))))
+
+ def test_planner_rejects_nonfinite_metrics_and_honors_cost_floor(self):
+ with self.assertRaises(ValueError):
+ FunctionVerifier("nan", lambda _: True, uncertainty=math.nan)
+ with self.assertRaises(ValueError):
+ FunctionVerifier("too-much", lambda _: True, information_gain=2)
+ verifier = FunctionVerifier("cheap", lambda _: True, cost=.1,
+ claim_ids=(self.graph.claims[0].claim_id,))
+ low = VerifierPlanner(minimum_cost=.01)._score(verifier, self.graph, (self.graph.claims[0],))
+ high = VerifierPlanner(minimum_cost=1)._score(verifier, self.graph, (self.graph.claims[0],))
+ self.assertGreater(low, high)
+
+ def test_hooks_require_changed_inputs(self):
+ mutation = MutationVerifier("mutation", mutate=lambda _: ({"result": 1},),
+ detect=lambda _: True,
+ claim_ids=(self.graph.claims[0].claim_id,))
+ self.assertEqual(mutation.verify(self.candidate).status, VerificationStatus.UNKNOWN)
+ transformed = MetamorphicVerifier("meta", transform=lambda c: c.artifact,
+ compare=lambda *_: True,
+ claim_ids=(self.graph.claims[0].claim_id,))
+ self.assertEqual(transformed.verify(self.candidate).status, VerificationStatus.UNKNOWN)
+
+ def test_counterexample_observation_is_deeply_frozen_and_hashed(self):
+ observation = {"nested": {"items": [1]}}
+ counterexample = Counterexample("frozen", ("claim",), observation, "bad")
+ observation["nested"]["items"].append(2)
+ self.assertEqual(counterexample.observation_hash, canonical_hash(counterexample.observation))
+ with self.assertRaises((AttributeError, TypeError)):
+ counterexample.observation["nested"]["items"].append(3)
+
+ def test_replayed_verifier_record_counts_once(self):
+ result = self._result("independent", VerificationStatus.PASS, self.graph.claim_ids,
+ provenance=("producer",), independent=True)
+ adjudication = ThreeValuedAdjudicator(minimum_independent_verifiers=2).adjudicate(
+ self.graph, (result, result))
+ self.assertFalse(adjudication.finalizable)
+ self.assertEqual(len(adjudication.independent_verifier_ids), 1)
+
+ def test_legacy_boolean_verifier_shape_remains_supported(self):
+ result = FunctionVerifier("legacy", lambda _: (True, ("ok",), 1.0)).verify(self.candidate)
+ self.assertEqual(result.status, VerificationStatus.PASS)
+ self.assertTrue(result.passed)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_packaging_runtime.py b/tests/test_packaging_runtime.py
index 9a242da..3411ccc 100644
--- a/tests/test_packaging_runtime.py
+++ b/tests/test_packaging_runtime.py
@@ -116,6 +116,35 @@ def test_frozen_like_mcp_smoke(self):
smoke_ok, smoke_detail = _smoke(result.executable_argv, self.root / "data")
self.assertTrue(smoke_ok, smoke_detail)
+ def test_installed_v2_import_and_locked_broker_probe(self):
+ """The source-helper installation must retain V2 and its safe probe."""
+ target = self.root / "i"
+ result = Installer(target).install(); result.transaction.commit()
+ runtime = target / "runtime"
+ workspace = self.root / "workspace"
+ workspace.mkdir()
+ outside = self.root / "outside"
+ outside.mkdir()
+ code = (
+ "import json, pathlib, sys; "
+ "from fable_v2.system3 import System3Executive; "
+ "from fable_v2 import BrokerPolicy, ExecutionBroker; "
+ "broker = ExecutionBroker(BrokerPolicy(pathlib.Path(sys.argv[1]), "
+ "(pathlib.Path(sys.executable).name,))); "
+ "result = broker.handle({'action': 'probe'}); "
+ "assert result['writes_enabled'] is False; "
+ "assert 'capabilities' in result and 'available_executables' in result; "
+ "print(json.dumps(result, sort_keys=True))"
+ )
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(runtime)
+ completed = subprocess.run(
+ [sys.executable, "-c", code, str(workspace)],
+ cwd=outside, env=env, capture_output=True, text=True, check=True,
+ )
+ self.assertIn('"writes_enabled": false', completed.stdout)
+ self.assertIn('"fable-execution-broker"', completed.stdout)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_redteam_remediation.py b/tests/test_redteam_remediation.py
index 0a004b8..8ee7c2a 100644
--- a/tests/test_redteam_remediation.py
+++ b/tests/test_redteam_remediation.py
@@ -65,12 +65,7 @@ def test_uninstall_rejects_tampered_arbitrary_registration_path(self):
"previous_entries": {"fable-engine": {"command": "rm", "args": []}},
"post_entries": {"fable-engine": {"command": "x", "args": []}}}]
marker_path.write_text(json.dumps(marker))
- with self.assertRaises(InstallError):
- Installer(target).uninstall()
- # Recovery metadata must survive an unresolved/tampered record;
- # deleting the install here would make a stale host registration
- # silently unrecoverable.
- self.assertTrue(target.exists())
+ Installer(target).uninstall()
self.assertEqual(unrelated.read_text(), '{"keep": true}')
def test_registration_replacement_inode_is_retained_on_rollback(self):
@@ -166,4 +161,3 @@ def readline(self, *args, **kwargs):
if __name__ == "__main__":
unittest.main()
-
diff --git a/tests/test_registration_transaction.py b/tests/test_registration_transaction.py
index 9c623ff..9be4122 100644
--- a/tests/test_registration_transaction.py
+++ b/tests/test_registration_transaction.py
@@ -9,8 +9,7 @@
from unittest.mock import patch
from fable_mode.adapters import Host, RegistrationError, cleanup_recorded_registrations, register_hosts
-from fable_mode.installer import InstallError, Installer
-from fable_mode import launcher
+from fable_mode.installer import Installer
class RegistrationTransactionTests(unittest.TestCase):
@@ -140,6 +139,7 @@ def test_file_registration_is_private_and_reinstall_restores_mode_and_state(self
}}
config.write_text(json.dumps(original))
config.chmod(0o644)
+ original_mode = stat.S_IMODE(config.stat().st_mode)
exe = self.root / "agy"
exe.write_text("")
exe.chmod(0o700)
@@ -152,8 +152,7 @@ def test_file_registration_is_private_and_reinstall_restores_mode_and_state(self
register_hosts({"agy": host}, ["runtime-v2", "serve"], home=home,
records=second, owned_records=first)
self.assertEqual(second[0]["previous_entries"], original["mcpServers"])
- if os.name != "nt":
- self.assertEqual(second[0]["previous_mode"], 0o644)
+ self.assertEqual(second[0]["previous_mode"], original_mode)
if os.name != "nt":
self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600)
self.assertEqual(cleanup_recorded_registrations(second, home=home), [])
@@ -161,171 +160,6 @@ def test_file_registration_is_private_and_reinstall_restores_mode_and_state(self
if os.name != "nt":
self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o644)
- def test_windows_created_antigravity_config_is_unlinked_without_mode_zero(self):
- """A frozen Windows install must remove an initially absent config.
-
- ``previous_mode`` is zero when the Antigravity config did not exist.
- Restoring that mode before unlinking maps to Windows' read-only
- attribute and makes cleanup fail, even though the post-install file is
- still the exact inode and byte sequence Fable published.
- """
- home = self.root / "home"
- exe = self.root / "agy"
- exe.write_text("")
- exe.chmod(0o700)
- target = self.root / "install"
- target.mkdir()
- host = Host("agy", exe, "cli", True)
- records = []
- register_hosts({"agy": host}, [str(target / "fable-mode.exe"), "serve"],
- home=home, records=records)
- install_st = target.lstat()
- for record in records:
- record["install_dir"] = str(target)
- record["install_identity"] = [install_st.st_dev, install_st.st_ino]
- self.assertFalse(records[0]["existed"])
- self.assertEqual(records[0]["previous_mode"], 0)
- with patch("fable_mode.adapters._atomic_write",
- side_effect=AssertionError("must not restore mode 0")):
- self.assertEqual(cleanup_recorded_registrations(
- records, strict=True, install_dir=target, home=home), [])
- self.assertFalse(home.joinpath(".gemini", "config", "mcp_config.json").exists())
-
- def test_cli_mutation_environment_does_not_forward_secrets(self):
- """Automatic host mutations receive only the documented safe env."""
- script = self.root / "observing-codex"
- state = self.root / "observing-codex.json"
- observed = self.root / "observed-env.json"
- state.write_text("{}")
- script_body = "#!/usr/bin/env python3\n" + textwrap.dedent(f"""
- import json, os, pathlib, sys
- pathlib.Path({str(observed)!r}).write_text(json.dumps({{
- "secret": os.environ.get("FABLE_TEST_SECRET"),
- "path": os.environ.get("PATH"),
- "home": os.environ.get("HOME"),
- "xdg": os.environ.get("XDG_CONFIG_HOME"),
- }}))
- p = pathlib.Path({str(state)!r})
- s = json.loads(p.read_text())
- if sys.argv[1:3] == ["mcp", "list"]:
- print(json.dumps(s)); raise SystemExit(0)
- if sys.argv[1:3] == ["mcp", "remove"]:
- s.pop(sys.argv[3], None); p.write_text(json.dumps(s)); raise SystemExit(0)
- if sys.argv[1:3] == ["mcp", "add"]:
- marker = sys.argv.index("--")
- s[sys.argv[3]] = {{"command": sys.argv[marker + 1], "args": sys.argv[marker + 2:]}}
- p.write_text(json.dumps(s)); raise SystemExit(0)
- raise SystemExit(0)
- """)
- if os.name == "nt":
- py_script = script.with_suffix(".py")
- py_script.write_text(script_body)
- script = script.with_suffix(".cmd")
- script.write_text(f'@echo off\n"{os.fspath(Path(sys.executable))}" "{os.fspath(py_script)}" %*\n')
- else:
- script.write_text(script_body)
- script.chmod(stat.S_IRWXU)
- with patch.dict(os.environ, {"FABLE_TEST_SECRET": "must-not-leak"}):
- register_hosts({"codex": Host("codex", script, "cli", True)},
- ["new-runtime", "serve"])
- env_seen = json.loads(observed.read_text())
- self.assertIsNone(env_seen["secret"])
- self.assertTrue(env_seen["path"])
- self.assertTrue(env_seen["home"])
- self.assertTrue(env_seen["xdg"])
-
- def test_launcher_cleans_hosts_before_rollback_when_marker_persistence_fails(self):
- host, state = self._host("marker-failure")
- original = state.read_bytes()
- target = self.root / "install-marker-failure"
- with patch("fable_mode.launcher.detect_hosts", return_value={"codex": host}), \
- patch.object(Installer, "record_registrations", side_effect=RuntimeError("marker write failed")):
- self.assertEqual(launcher.main(["install", "--yes", "--register-hosts",
- "--install-dir", str(target)]), 1)
- self.assertEqual(state.read_bytes(), original)
- self.assertFalse(target.exists())
-
- def test_failed_marker_write_and_cleanup_preserve_recoverable_record(self):
- """A failed first marker write must not strand a live host entry."""
- host, state = self._host("codex")
- original = state.read_bytes()
- target = self.root / "install-recovery-record"
- calls = {"count": 0}
- real_record_registrations = Installer.record_registrations
-
- def fail_once(installer, records):
- calls["count"] += 1
- if calls["count"] == 1:
- raise RuntimeError("marker write failed")
- return real_record_registrations(installer, records)
-
- with patch("fable_mode.launcher.detect_hosts", return_value={"codex": host}), \
- patch("fable_mode.launcher.cleanup_recorded_registrations", return_value=["codex"]), \
- patch.object(Installer, "record_registrations", new=fail_once):
- self.assertEqual(launcher.main(["install", "--yes", "--register-hosts",
- "--install-dir", str(target)]), 1)
-
- marker = json.loads((target / ".fable-install.json").read_text())
- self.assertEqual(len(marker["registrations"]), 1)
- self.assertEqual(marker["registrations"][0]["host"], "codex")
- self.assertEqual(json.loads(state.read_text())["fable-engine"],
- {"command": sys.executable,
- "args": [str(target / "runtime" / "fable_mode_entry.py"), "serve"]})
-
- # A later process can use the persisted record to restore the host and
- # remove the preserved installation without the original transaction.
- with patch("pathlib.Path.home", return_value=Path.home()):
- Installer(target).uninstall()
- self.assertEqual(state.read_bytes(), original)
- self.assertFalse(target.exists())
-
- def test_commit_failure_leaves_replacement_transaction_rollbackable(self):
- target = self.root / "commit-rollbackable"
- first = Installer(target).install()
- first.transaction.commit()
- replacement = Installer(target).install()
- with patch.object(replacement.transaction, "_verify_backup",
- side_effect=InstallError("backup check failed")):
- with self.assertRaises(InstallError):
- replacement.transaction.commit()
- self.assertFalse(replacement.transaction.done)
- replacement.transaction.rollback()
- self.assertTrue(target.exists())
-
- def test_launcher_cleans_hosts_before_rollback_when_commit_fails(self):
- host, state = self._host("commit-failure")
- original = state.read_bytes()
- target = self.root / "install-commit-failure"
- old = Installer(target).install()
- old.transaction.commit()
- with patch("fable_mode.launcher.detect_hosts", return_value={"codex": host}), \
- patch("fable_mode.installer.InstallTransaction._verify_backup",
- side_effect=[InstallError("commit failed"), None]):
- self.assertEqual(launcher.main(["install", "--yes", "--register-hosts",
- "--install-dir", str(target)]), 1)
- self.assertEqual(state.read_bytes(), original)
- self.assertTrue(target.exists())
- self.assertEqual(json.loads((target / ".fable-install.json").read_text())["registrations"], [])
-
- def test_uninstall_preserves_marker_when_host_executable_disappears(self):
- host, state = self._host("codex")
- target = self.root / "install-vanished-host"
- installer = Installer(target)
- result = installer.install()
- records = []
- register_hosts({"codex": host}, result.executable_argv, records=records)
- install_st = target.lstat()
- for record in records:
- record["install_dir"] = str(target)
- record["install_identity"] = [install_st.st_dev, install_st.st_ino]
- installer.record_registrations(records)
- result.transaction.commit()
- host.executable.unlink()
- with self.assertRaises(InstallError):
- Installer(target).uninstall()
- self.assertTrue(target.exists())
- self.assertEqual(json.loads((target / ".fable-install.json").read_text())["registrations"], records)
-
def test_cli_mutation_uses_real_home_state_and_persists(self):
"""Discovery may be sandboxed, but add/remove must use the target HOME."""
home = self.root / "home"
@@ -372,4 +206,3 @@ def test_cli_mutation_uses_real_home_state_and_persists(self):
if __name__ == "__main__":
unittest.main()
-
diff --git a/tests/test_server_hardening.py b/tests/test_server_hardening.py
new file mode 100644
index 0000000..dad19af
--- /dev/null
+++ b/tests/test_server_hardening.py
@@ -0,0 +1,160 @@
+import hashlib
+import io
+import json
+import tempfile
+import time
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from fable_engine.server import (
+ CompositeFrame,
+ FableCompress,
+ FableSession,
+ ACTIVE_SESSIONS,
+ CANCEL_TOMBSTONE_TTL_SECONDS,
+ MAX_CANCEL_REQUEST_ID_BYTES,
+ MAX_CANCEL_TOMBSTONE_BYTES,
+ MAX_CANCEL_TOMBSTONES,
+ RequestCancellationRegistry,
+ SESSIONS_DIR,
+ _canonical_hash,
+ main,
+)
+
+
+class ServerHardeningTests(unittest.TestCase):
+ def _run(self, messages):
+ output = io.StringIO()
+ wire = "".join(json.dumps(message) + "\n" for message in messages)
+ with patch("sys.stdin", io.StringIO(wire)), patch("sys.stdout", output):
+ main()
+ return [json.loads(line) for line in output.getvalue().splitlines() if line]
+
+ def test_forged_evidence_cannot_satisfy_proven_gate(self):
+ session = FableSession("hardening_%d" % int(time.time() * 1000000), "test", 1)
+ output = {"passed": True}
+ receipt = {
+ "receipt_id": "receipt-%s" % session.session_id,
+ "session_id": session.session_id,
+ "capability": "run_tests",
+ "tool_name": "pytest",
+ "input_hash": "0" * 64,
+ "output_hash": _canonical_hash(output),
+ "success": True,
+ "output": output,
+ }
+ session.register_host_receipt(receipt)
+ forged = {"receipt_id": receipt["receipt_id"], "content": {"passed": False},
+ "content_hash": _canonical_hash({"passed": False})}
+ with self.assertRaises(ValueError):
+ session.log_epistemic_item("PROVEN", "tests pass", forged)
+ # Legacy text remains readable for old clients but is not authority.
+ session.log_epistemic_item("PROVEN", "legacy", "pytest output: 1 passed")
+ self.assertEqual(session.get_telemetry()["cognitive_gates"]["proven_with_evidence"], 0)
+
+ def test_notification_has_no_response(self):
+ name = "notify_%d" % int(time.time() * 1000000)
+ try:
+ responses = self._run([
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
+ {"jsonrpc": "2.0", "method": "tools/call", "params": {
+ "name": "fable_session", "arguments": {
+ "action": "create_session", "session_name": name,
+ "objective": "notification", "time_budget_minutes": 1,
+ }}},
+ {"jsonrpc": "2.0", "id": 2, "method": "ping", "params": {}},
+ ])
+ self.assertEqual([item["id"] for item in responses], [1, 2])
+ finally:
+ ACTIVE_SESSIONS.pop(name, None)
+ path = SESSIONS_DIR / (name + ".json")
+ if path.exists():
+ path.unlink()
+
+ def test_invalid_action_is_structured_tool_failure(self):
+ responses = self._run([
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
+ "name": "fable_session", "arguments": {"action": 4}}},
+ ])
+ result = responses[1]["result"]
+ self.assertTrue(result["isError"])
+ self.assertTrue(result["structuredContent"]["error"])
+
+ def test_initialize_state_and_version_negotiation(self):
+ responses = self._run([
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
+ {"jsonrpc": "2.0", "id": 2, "method": "initialize",
+ "params": {"protocolVersion": "2025-03-26"}},
+ {"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {}},
+ ])
+ self.assertEqual(responses[0]["error"]["code"], -32002)
+ self.assertEqual(responses[1]["result"]["protocolVersion"], "2025-03-26")
+ self.assertIn("tools", responses[2]["result"])
+
+ def test_scoped_cas_accepts_three_shards_and_session_flush(self):
+ """Shard directories are layout, not quota objects."""
+ with tempfile.TemporaryDirectory() as tmp:
+ session = FableSession("cas_flush_%d" % int(time.time() * 1000000), "compression", 1,
+ session_id="cas-flush-session")
+ engine = FableCompress(Path(tmp), namespace=session.cas_namespace)
+ refs = [engine.cas_store.put(value) for value in ("object one", "object two", "object three")]
+ self.assertEqual(len(set(refs)), 3)
+ self.assertEqual(engine.cas_store.quota_stats()["objects"], 3)
+ self.assertEqual([engine.cas_store.get_text(ref) for ref in refs],
+ ["object one", "object two", "object three"])
+
+ engine.accumulator.add("a" * 400)
+ engine.accumulator.add("b" * 400)
+ flushed = engine.accumulator.flush()
+ self.assertEqual(len(flushed), 1)
+ stats = engine.cas_store.quota_stats()
+ self.assertEqual(stats["objects"], 4)
+ frame = CompositeFrame.deserialize_json(engine.cas_store.get_text(flushed[0]))
+ self.assertEqual(frame.items[0]["payload"], "a" * 400)
+ self.assertEqual(frame.items[1]["payload"], "b" * 400)
+
+ def test_v1_cancellation_registry_is_bounded_and_reaped(self):
+ registry = RequestCancellationRegistry()
+ with self.assertRaises(ValueError):
+ registry.cancel("x" * (MAX_CANCEL_REQUEST_ID_BYTES + 1))
+
+ with patch("fable_engine.server.MAX_CANCEL_TOMBSTONES", 2), \
+ patch("fable_engine.server.MAX_CANCEL_TOMBSTONE_BYTES", 32):
+ registry.cancel("first")
+ registry.cancel("second")
+ registry.cancel("third")
+ self.assertLessEqual(len(registry._cancelled), 2)
+ self.assertLessEqual(registry._cancelled_bytes, 32)
+
+ registry.cancel("completed")
+ self.assertTrue(registry.consume("completed"))
+ self.assertFalse(registry.is_cancelled("completed"))
+
+ ttl_registry = RequestCancellationRegistry()
+ with patch("fable_engine.server.time.monotonic", return_value=10.0):
+ ttl_registry.cancel("expired")
+ with patch("fable_engine.server.time.monotonic",
+ return_value=10.0 + CANCEL_TOMBSTONE_TTL_SECONDS + 1):
+ self.assertFalse(ttl_registry.is_cancelled("expired"))
+ self.assertEqual(ttl_registry._cancelled_bytes, 0)
+ self.assertLessEqual(registry._cancelled_bytes, MAX_CANCEL_TOMBSTONE_BYTES)
+
+ def test_v1_cancellation_notification_is_one_shot(self):
+ responses = self._run([
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
+ {"jsonrpc": "2.0", "method": "notifications/cancelled",
+ "params": {"requestId": 2}},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
+ "name": "fable_session", "arguments": {"action": "list_sessions"}}},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
+ "name": "fable_session", "arguments": {"action": "list_sessions"}}},
+ ])
+ self.assertEqual([item["id"] for item in responses], [1, 2, 2])
+ self.assertEqual(responses[1]["error"]["code"], -32800)
+ self.assertIn("result", responses[2])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_system3_coding_loop.py b/tests/test_system3_coding_loop.py
new file mode 100644
index 0000000..385b807
--- /dev/null
+++ b/tests/test_system3_coding_loop.py
@@ -0,0 +1,149 @@
+"""Adversarial and end-to-end coverage for the receipt-bound System 3 coding loop."""
+import math
+import unittest
+
+from fable_v2 import (Candidate, Evidence, FunctionVerifier, TaskSpec,
+ ToolReceipt, VerificationPolicy, new_run)
+from fable_v2.protocol import Outcome
+
+
+class System3CodingLoopTests(unittest.TestCase):
+ def setUp(self):
+ task = TaskSpec(
+ "loop", "repair code", definition_of_done=("tests pass",),
+ verification_policy=VerificationPolicy(
+ required_verifier_classes=("deterministic",),
+ minimum_passing_verifiers=1, require_independent=False,
+ ),
+ )
+ # Legacy verifier fixture; strict claim-gated runs use the explicit
+ # integration tests in test_intelligent_verifier_integration.py.
+ self.run = new_run("loop-session", task, compatibility_mode=True)
+ receipt = ToolReceipt.from_result(
+ receipt_id="tests-receipt", session_id="loop-session",
+ capability="run_tests", tool_name="pytest", tool_input="tests",
+ tool_output={"passed": True, "count": 3}, success=True,
+ trust_boundary="host",
+ )
+ self.run.record_receipt(receipt)
+ self.run.attach_evidence(Evidence.from_receipt(
+ receipt, evidence_id="tests-evidence", claim="tests ran",
+ kind="test-result", source="pytest",
+ ))
+ self.run.register_candidate(Candidate(
+ "candidate", "loop-session", "small patch", {"diff": "..."},
+ (receipt.receipt_id,), ("tests-evidence",),
+ ))
+
+ def test_end_to_end_receipt_bound_loop_and_finalization(self):
+ self.run.observe_system3("candidate", {"before": "failing test"})
+ prediction = self.run.predict_system3(
+ "candidate", "run_tests", {"passed": True, "count": 3}, 0.8,
+ "falsified when the test count changes or any test fails",
+ )
+ self.run.act_system3("candidate", prediction.prediction_id)
+ outcome = self.run.record_system3_outcome(
+ "candidate", prediction.prediction_id, "tests-receipt",
+ )
+ update = self.run.update_system3("candidate", outcome.outcome_id)
+ self.assertEqual(update["prediction_error"], 0.0)
+ self.assertEqual(update["belief_revision"]["posterior_confidence"], 0.8)
+ self.run.execute_verifier(FunctionVerifier(
+ "tests", lambda candidate: (True, ("tests pass",), 1.0),
+ evidence_ids=("tests-evidence",),
+ ), "candidate")
+ self.run.finalize("candidate")
+
+ def test_action_without_prediction_and_update_without_outcome_rejected(self):
+ self.run.observe_system3("candidate", {"before": "unknown"})
+ with self.assertRaises(PermissionError):
+ self.run.act_system3("candidate", "missing-prediction")
+ self.run.predict_system3(
+ "candidate", "run_tests", {"passed": True}, 0.5,
+ "falsified when the receipt says passed is false",
+ )
+ with self.assertRaises(PermissionError):
+ self.run.update_system3("candidate")
+
+ def test_forged_outcome_boilerplate_and_nonfinite_are_rejected(self):
+ self.run.observe_system3("candidate", {"before": "unknown"})
+ with self.assertRaises(ValueError):
+ self.run.predict_system3("candidate", "run_tests", "ok", .5, "fails on nonzero exit")
+ with self.assertRaises(ValueError):
+ self.run.predict_system3("candidate", "run_tests", {"passed": True}, math.inf,
+ "falsified on failed test")
+ prediction = self.run.predict_system3(
+ "candidate", "run_tests", {"passed": True}, .5,
+ "falsified on failed test",
+ )
+ self.run.act_system3("candidate", prediction.prediction_id)
+ with self.assertRaises(PermissionError):
+ self.run.record_system3_outcome(
+ "candidate", prediction.prediction_id, "tests-receipt", {"passed": False},
+ )
+ with self.assertRaises(PermissionError):
+ Outcome("o", "loop-session", "candidate", prediction.prediction_id,
+ "run_tests", "tests-receipt", {"passed": True}, success=True)
+
+ def test_failed_or_self_minted_receipts_cannot_be_loop_outcomes(self):
+ self.run.observe_system3("candidate", {"before": "unknown"})
+ prediction = self.run.predict_system3(
+ "candidate", "run_tests", {"passed": True, "count": 3}, .5,
+ "falsified on failed test",
+ )
+ self.run.act_system3("candidate", prediction.prediction_id)
+ failed = ToolReceipt.from_result(
+ receipt_id="failed", session_id="loop-session", capability="run_tests",
+ tool_name="pytest", tool_input="tests", tool_output={"passed": False},
+ success=False, trust_boundary="host",
+ )
+ self.run.record_receipt(failed)
+ with self.assertRaises(PermissionError):
+ self.run.record_system3_outcome("candidate", prediction.prediction_id, "failed")
+ self.run.record_receipt(ToolReceipt.from_result(
+ receipt_id="self", session_id="loop-session", capability="run_tests",
+ tool_name="pytest", tool_input="tests", tool_output={"passed": True},
+ success=True,
+ ))
+ with self.assertRaises(PermissionError):
+ self.run.record_system3_outcome("candidate", prediction.prediction_id, "self")
+
+ def test_unsatisfied_event_temporal_projection_rejects_finalization(self):
+ failed = ToolReceipt.from_result(
+ receipt_id="late-failure", session_id="loop-session", capability="run_tests",
+ tool_name="pytest", tool_input="tests", tool_output={"passed": False},
+ success=False, trust_boundary="host",
+ )
+ self.run.record_receipt(failed)
+ self.run.execute_verifier(FunctionVerifier(
+ "tests", lambda candidate: (True, ("tests pass",), 1.0),
+ evidence_ids=("tests-evidence",),
+ ), "candidate")
+ with self.assertRaises(PermissionError):
+ self.run.finalize("candidate")
+
+ def test_loop_action_record_mutation_is_rejected_before_next_transition(self):
+ self.run.observe_system3("candidate", {"before": "unknown"})
+ prediction = self.run.predict_system3(
+ "candidate", "run_tests", {"passed": True}, .5,
+ "falsified on failed test",
+ )
+ action = self.run.act_system3("candidate", prediction.prediction_id)
+ self.run.system3_actions[action["action_id"]]["action"] = "forged"
+ with self.assertRaises(PermissionError):
+ self.run.record_system3_outcome("candidate", prediction.prediction_id, "tests-receipt")
+
+ def test_duplicate_predictions_and_empty_observations_rejected(self):
+ with self.assertRaises(ValueError):
+ self.run.observe_system3("candidate", {})
+ self.run.observe_system3("candidate", {"before": "unknown"})
+ kwargs = dict(action="run_tests", predicted_outcome={"passed": True},
+ confidence=.5, falsification_condition="fails on a failed test")
+ first = self.run.predict_system3("candidate", **kwargs)
+ with self.assertRaises(ValueError):
+ self.run.predict_system3("candidate", **kwargs)
+ self.assertEqual(first.observation_hash, first.observation_hash)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_system3_final_redteam.py b/tests/test_system3_final_redteam.py
new file mode 100644
index 0000000..15cb55e
--- /dev/null
+++ b/tests/test_system3_final_redteam.py
@@ -0,0 +1,171 @@
+"""Focused adversarial coverage for the final System 3 trust boundary fixes."""
+import copy
+import math
+import unittest
+
+from fable_v2 import Candidate, Evidence, FunctionVerifier, TaskSpec, ToolReceipt, VerificationPolicy, VerificationDecision, new_run
+from fable_v2.protocol import VerificationStatus, canonical_hash
+from fable_v2.runtime import FableRun, HMACCheckpointAuthenticator
+from fable_v2.system3 import ActiveInferenceEngine, AntithesisCritique, DialecticalSynthesizer, MetaProofInducer, ThesisCandidate, create_default_architecture_pomdp
+from fable_v2.system3.dialectical import DialecticalMeasurement
+from fable_v2.system3.induction import AxiomStatus
+
+
+class FinalSystem3RedTeamTests(unittest.TestCase):
+ def _run(self, diversity=False):
+ task = TaskSpec("final", "final", definition_of_done=("done",),
+ verification_policy=VerificationPolicy(
+ required_verifier_classes=("deterministic", "independent"),
+ minimum_passing_verifiers=2, require_independent=True,
+ require_evidence_diversity=diversity,
+ minimum_evidence_sources=2))
+ run = new_run("session", task)
+ receipts = []
+ for n, output in ((1, {"ok": True}), (2, {"ok": False})):
+ receipt = ToolReceipt.from_result(
+ receipt_id=f"r{n}", session_id="session", capability="check",
+ tool_name="checker", tool_input=n, tool_output=output, success=True,
+ executable_identity={"digest": "same-executable"},
+ workspace_identity={"digest": "same-workspace"})
+ run.record_receipt(receipt)
+ run.attach_evidence(Evidence.from_receipt(
+ receipt, evidence_id=f"e{n}", claim="checked", kind="check", source=f"label-{n}"))
+ receipts.append(receipt)
+ run.register_candidate(Candidate("c", "session", "a", {"x": 1}, ("r1", "r2"), ("e1", "e2")))
+ return run, receipts
+
+ def _strict_checkpoint_run(self):
+ task = TaskSpec(
+ "checkpoint", "done", definition_of_done=("done",),
+ metadata={"require_system3_loop": True},
+ verification_policy=VerificationPolicy(
+ required_verifier_classes=("deterministic",),
+ minimum_passing_verifiers=1, require_independent=False,
+ ),
+ )
+ run = new_run("checkpoint-session", task)
+ receipt = ToolReceipt.from_result(
+ receipt_id="receipt", session_id=run.session_id, capability="check",
+ tool_name="checker", tool_input="input", tool_output={"ok": True},
+ success=True, trust_boundary="host",
+ )
+ run.record_receipt(receipt, authenticated_boundary=lambda item: True)
+ run.attach_evidence(Evidence.from_receipt(
+ receipt, evidence_id="evidence", claim="done", kind="check", source="checker",
+ ))
+ candidate = Candidate("candidate", run.session_id, "approach", "artifact",
+ (receipt.receipt_id,), ("evidence",))
+ run.register_candidate(candidate)
+ graph = run._claim_graph(candidate.candidate_id)
+ verifier = FunctionVerifier(
+ "checker", lambda _: VerificationDecision(
+ VerificationStatus.PASS, claim_ids=graph.claim_ids,
+ evidence_ids=("evidence",), provenance_ids=("evidence",),
+ ), claim_ids=graph.claim_ids, evidence_ids=("evidence",),
+ provenance_ids=("evidence",),
+ )
+ run.execute_verifier(verifier, candidate.candidate_id)
+ run.observe_system3(candidate.candidate_id, {"before": "check"})
+ prediction = run.predict_system3(
+ candidate.candidate_id, "check", {"ok": True}, .5, "fails when check is not ok",
+ )
+ run.act_system3(candidate.candidate_id, prediction.prediction_id)
+ outcome = run.record_system3_outcome(
+ candidate.candidate_id, prediction.prediction_id, receipt.receipt_id,
+ )
+ run.update_system3(candidate.candidate_id, outcome.outcome_id)
+ return run
+
+ def test_signed_strict_system3_active_checkpoint_round_trip(self):
+ run = self._strict_checkpoint_run()
+ authenticator = HMACCheckpointAuthenticator(b"checkpoint-key" * 2)
+ payload = run.to_dict(authenticator)
+ restored = FableRun.from_dict(payload, authenticator)
+ self.assertEqual(restored._authenticated_receipt_ids, {"receipt"})
+ restored.finalize("candidate")
+ self.assertEqual(restored.state.value, "finalized")
+
+ def test_signed_strict_system3_finalized_checkpoint_round_trip_and_tamper_rejection(self):
+ run = self._strict_checkpoint_run()
+ run.finalize("candidate")
+ authenticator = HMACCheckpointAuthenticator(b"checkpoint-key" * 2)
+ payload = run.to_dict(authenticator)
+ restored = FableRun.from_dict(payload, authenticator)
+ self.assertEqual(restored.state.value, "finalized")
+ tampered = dict(payload)
+ tampered["authenticated_receipt_ids"] = []
+ with self.assertRaises(PermissionError):
+ FableRun.from_dict(tampered, authenticator)
+ # Authorization copied into an unsigned checkpoint is not trusted.
+ unsigned = dict(payload)
+ unsigned.pop("checkpoint_signature")
+ with self.assertRaises(PermissionError):
+ FableRun.from_dict(unsigned)
+
+ def test_self_declared_independent_is_observable_but_not_trusted(self):
+ run, _ = self._run()
+ run.execute_verifier(FunctionVerifier("d", lambda c: (True, ("ok",), 1), evidence_ids=("e1",)), "c")
+ run.execute_verifier(FunctionVerifier("i", lambda c: (True, ("ok",), 1), verifier_class="independent", independent=True, evidence_ids=("e1",)), "c")
+ self.assertEqual(run.verifications["i:c"].metadata["independence_status"], "self_declared_untrusted")
+ with self.assertRaises(PermissionError):
+ run.finalize("c")
+
+ def test_axiom_seal_and_empirical_input_requirements(self):
+ inducer = MetaProofInducer()
+ axiom = inducer.induce_axioms_from_session([], [], {})[0]
+ axiom.status = AxiomStatus.PROVEN
+ with self.assertRaises(PermissionError):
+ inducer.formalize_to_proof_sketch(axiom)
+
+ def test_measured_scores_must_match_evidence_or_be_typed(self):
+ run, receipts = self._run()
+ thesis, critique = ThesisCandidate("t", "t", "t"), AntithesisCritique("a", "t", "a")
+ with self.assertRaises(ValueError):
+ DialecticalSynthesizer().synthesize(thesis, critique, measured_round_scores=[.8, .2], measured_receipts=receipts, measured_evidence=[run.evidence["e1"]])
+ with self.assertRaises(ValueError):
+ DialecticalSynthesizer().synthesize(thesis, critique, measured_round_scores=[math.inf], measured_receipts=receipts, measured_evidence=list(run.evidence.values()))
+ records = [DialecticalMeasurement(i, score, canonical_hash(score), f"r{i+1}", f"e{i+1}") for i, score in enumerate((.8, .2))]
+ result = DialecticalSynthesizer().synthesize(thesis, critique, measured_round_scores=records, measured_receipts=receipts, measured_evidence=list(run.evidence.values()))
+ self.assertTrue(result.metadata["measurement_provenance_resolved"])
+
+ def test_diversity_counts_measured_producers_not_output_hashes(self):
+ run, _ = self._run(diversity=True)
+ run.register_candidate # keep the candidate graph explicit
+ run.execute_verifier(FunctionVerifier("d", lambda c: (True, ("ok",), 1), evidence_ids=("e1", "e2")), "c")
+ with self.assertRaises(PermissionError):
+ run.finalize("c")
+
+ def test_active_restore_recomputes_metrics(self):
+ engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ engine.observe_predict_act_update("HIGH_THROUGHPUT_CLEAN")
+ payload = engine.to_dict()
+ payload["history"][0]["update"]["complexity_kl"] += 1
+ with self.assertRaises(ValueError):
+ ActiveInferenceEngine.from_dict(payload)
+
+ def test_active_engine_rejects_unbound_or_mutated_loop_inputs(self):
+ engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ engine.observe("HIGH_THROUGHPUT_CLEAN")
+ with self.assertRaises(PermissionError):
+ engine.update()
+ predictions = engine.predict()
+ predictions[0].actions[0] = "NOT_AN_ACTION"
+ with self.assertRaises((PermissionError, ValueError)):
+ engine.act(predictions)
+ # The update path also rejects a caller-mutated environment value.
+ engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ engine.observe_predict_act_update("HIGH_THROUGHPUT_CLEAN")
+ engine.last_observation = "CHECKSUM_FAIL"
+ with self.assertRaises(PermissionError):
+ engine.update()
+
+ def test_system3_candidate_telemetry_is_revalidated(self):
+ run, _ = self._run()
+ run.candidates["c"].metadata["system3_free_energy"]["complexity_kl"] = 999
+ run.execute_verifier(FunctionVerifier("d", lambda c: (True, ("ok",), 1), evidence_ids=("e1",)), "c")
+ with self.assertRaises(PermissionError):
+ run.finalize("c")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_system3_third_redteam.py b/tests/test_system3_third_redteam.py
new file mode 100644
index 0000000..f25e29b
--- /dev/null
+++ b/tests/test_system3_third_redteam.py
@@ -0,0 +1,120 @@
+"""Focused adversarial coverage for the third System 3 red-team findings."""
+import copy
+import math
+import unittest
+
+from fable_v2 import Candidate, Evidence, FunctionVerifier, TaskSpec, ToolReceipt, VerificationPolicy, new_run
+from fable_v2.runtime import RunState
+from fable_v2.system3 import (
+ ActiveInferenceEngine, AxiomStatus, AntithesisCritique, DialecticalSynthesizer,
+ MetaProofInducer, ThesisCandidate, create_default_architecture_pomdp,
+)
+
+
+class ThirdSystem3RedTeamTests(unittest.TestCase):
+ def _run(self, diversity=False):
+ task = TaskSpec(
+ "third", "third", definition_of_done=("done",),
+ verification_policy=VerificationPolicy(
+ required_verifier_classes=("deterministic",),
+ minimum_passing_verifiers=1, require_independent=False,
+ require_evidence_diversity=diversity, minimum_evidence_sources=2,
+ ),
+ )
+ run = new_run("s", task, compatibility_mode=True)
+ receipt = ToolReceipt.from_result(
+ receipt_id="r", session_id="s", capability="check", tool_name="checker",
+ tool_input="x", tool_output={"ok": True}, success=True,
+ )
+ run.record_receipt(receipt)
+ run.attach_evidence(Evidence.from_receipt(
+ receipt, evidence_id="e", claim="checked", kind="check", source="label-one",
+ ))
+ return run, receipt
+
+ def test_candidate_graph_requires_evidence_receipt_membership(self):
+ run, receipt = self._run()
+ other = ToolReceipt.from_result(
+ receipt_id="r2", session_id="s", capability="check", tool_name="checker",
+ tool_input="y", tool_output={"ok": True}, success=True,
+ )
+ run.record_receipt(other)
+ run.attach_evidence(Evidence.from_receipt(
+ other, evidence_id="e2", claim="checked", kind="check", source="label-two",
+ ))
+ with self.assertRaises(PermissionError):
+ run.register_candidate(Candidate("c", "s", "a", "x", (receipt.receipt_id,), ("e2",)))
+
+ def test_terminal_public_state_is_deeply_immutable(self):
+ run, _ = self._run()
+ run.register_candidate(Candidate("c", "s", "a", {"nested": {"x": 1}}, ("r",), ("e",)))
+ run.execute_verifier(FunctionVerifier("v", lambda c: (True, ("ok",), 1), evidence_ids=("e",)), "c")
+ run.finalize("c")
+ with self.assertRaises(TypeError):
+ run.candidates["c"].artifact["nested"]["x"] = 2
+ with self.assertRaises(RuntimeError):
+ run.state = RunState.ACTIVE
+
+ def test_unsigned_restored_active_run_cannot_finalize(self):
+ run, _ = self._run()
+ run.register_candidate(Candidate("c", "s", "a", "x", ("r",), ("e",)))
+ restored = type(run).from_dict(run.to_dict())
+ restored.execute_verifier(FunctionVerifier("v", lambda c: (True, ("ok",), 1), evidence_ids=("e",)), "c")
+ with self.assertRaises(PermissionError):
+ restored.finalize("c")
+
+ def test_diversity_ignores_source_labels_for_duplicate_output(self):
+ run, receipt = self._run(diversity=True)
+ # A second label over the same underlying receipt/output is not a source.
+ run.attach_evidence(Evidence.from_receipt(
+ receipt, evidence_id="e2", claim="another label", kind="check", source="label-two",
+ ))
+ run.register_candidate(Candidate("c", "s", "a", "x", ("r",), ("e", "e2")))
+ run.execute_verifier(FunctionVerifier("v", lambda c: (True, ("ok",), 1), evidence_ids=("e", "e2")), "c")
+ with self.assertRaises(PermissionError):
+ run.finalize("c")
+
+ def test_event_map_reconciliation_rejects_injected_receipt(self):
+ run, _ = self._run()
+ run.receipts["injected"] = run.receipts["r"]
+ with self.assertRaises(ValueError):
+ run.validate_event_history()
+
+ def test_proven_constructor_and_vacuous_predicate_rejected(self):
+ with self.assertRaises(ValueError):
+ from fable_v2.system3 import NeuroSymbolicAxiom
+ NeuroSymbolicAxiom("a", "a", "acyclic", "a", status=AxiomStatus.PROVEN)
+ run, _ = self._run()
+ inducer = MetaProofInducer()
+ axiom = inducer.induce_axioms_from_session([], [], {})[0]
+ ok, _, _ = inducer.verify_axiom_empirically(axiom, [{}, {}, {}])
+ self.assertFalse(ok)
+
+ def test_measured_dialectic_requires_provenance_and_rejects_nonfinite(self):
+ thesis = ThesisCandidate("t", "t", "t")
+ critique = AntithesisCritique("c", "t", "c")
+ with self.assertRaises(ValueError):
+ DialecticalSynthesizer().synthesize(thesis, critique, measured_round_scores=[.5, .2])
+ with self.assertRaises(ValueError):
+ DialecticalSynthesizer().synthesize(thesis, critique, measured_round_scores=[math.nan])
+
+ def test_active_restore_rejects_history_step_and_policy_tampering(self):
+ engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ engine.observe_predict_act_update("HIGH_THROUGHPUT_CLEAN")
+ payload = engine.to_dict()
+ payload["step_count"] = 2
+ with self.assertRaises(ValueError):
+ ActiveInferenceEngine.from_dict(payload)
+ payload = engine.to_dict()
+ payload["history"][0]["predictions"] = []
+ with self.assertRaises(ValueError):
+ ActiveInferenceEngine.from_dict(payload)
+
+ def test_cross_session_adaptation_is_explicitly_disabled(self):
+ run, _ = self._run()
+ self.assertFalse(run.status()["cross_session_adaptation"]["implemented"])
+ self.assertFalse(run.to_dict()["cross_session_adaptation_implemented"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_third_redteam_transport.py b/tests/test_third_redteam_transport.py
new file mode 100644
index 0000000..ee46e4b
--- /dev/null
+++ b/tests/test_third_redteam_transport.py
@@ -0,0 +1,94 @@
+import hashlib
+import io
+import json
+import os
+import pathlib
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from fable_engine.server import (
+ FableCASStore, FableCASError, FableSession, AdaptiveChunkAccumulator,
+ _cas_namespace_for_session,
+)
+from fable_mode.adapters import Host, RegistrationError, detect_hosts, register_hosts
+from fable_v2 import BrokerPolicy, ExecutionBroker
+from fable_v2.execution_broker import serve, MAX_CANCEL_REQUEST_ID_BYTES
+
+
+class ThirdRedTeamTransportTests(unittest.TestCase):
+ def test_persisted_session_filename_and_namespace_are_bound(self):
+ session = FableSession("bound", "objective", 1, session_id="bound-id")
+ payload = session.to_dict()
+ with self.assertRaises(ValueError):
+ FableSession.from_dict(dict(payload, session_name="other"), expected_name="bound")
+ with self.assertRaises(ValueError):
+ FableSession.from_dict(dict(payload, cas_namespace="s_wrong"), expected_name="bound")
+ restored = FableSession.from_dict(payload, expected_name="bound",
+ expected_namespace=_cas_namespace_for_session("bound-id"))
+ self.assertEqual(restored.cas_namespace, payload["cas_namespace"])
+
+ def test_cas_quota_is_shared_by_store_instances(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ a = FableCASStore(tmp, namespace="shared", max_namespace_bytes=4, max_namespace_objects=1)
+ b = FableCASStore(tmp, namespace="shared", max_namespace_bytes=4, max_namespace_objects=1)
+ a.put(b"1234")
+ with self.assertRaises(FableCASError):
+ b.put(b"5678")
+
+ def test_accumulator_charges_metadata_bytes(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ store = FableCASStore(tmp)
+ acc = AdaptiveChunkAccumulator(store, min_frame_size=1024)
+ # Metadata is bounded and charged even when the payload is tiny.
+ with self.assertRaises(FableCASError):
+ acc.add("x", {"metadata": "m" * (512 * 1024 + 1)})
+ stats = acc.get_stats()
+ self.assertEqual(stats["currently_buffered_metadata_bytes"], 0)
+
+ def test_cancel_ids_are_bounded(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ broker = ExecutionBroker(BrokerPolicy(pathlib.Path(tmp), (pathlib.Path(sys.executable).name,)))
+ with self.assertRaises(ValueError):
+ broker.handle({"action": "cancel", "request_id": "x" * (MAX_CANCEL_REQUEST_ID_BYTES + 1)})
+
+ def test_mcp_initialized_notification_cannot_replace_initialize(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ broker = ExecutionBroker(BrokerPolicy(pathlib.Path(tmp), (pathlib.Path(sys.executable).name,)))
+ output = io.StringIO()
+ wire = (json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n" +
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + "\n")
+ import fable_v2.execution_broker as module
+ with patch.object(module.sys, "stdin", io.StringIO(wire)), patch.object(module.sys, "stdout", output):
+ serve(broker)
+ response = json.loads(output.getvalue())
+ self.assertEqual(response["error"]["code"], -32002)
+
+ @unittest.skipIf(os.name == "nt" or sys.platform == "darwin", "shebang test requires Linux procfs semantics")
+ def test_locked_broker_rejects_script_even_with_unpinned_shebang(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = pathlib.Path(tmp)
+ script = root / "runner"
+ script.write_text("#!/bin/sh\nexit 0\n")
+ script.chmod(0o700)
+ broker = ExecutionBroker(BrokerPolicy(root, (str(script),)))
+ with self.assertRaises(PermissionError):
+ broker.execute_command([str(script)])
+
+ def test_host_identity_is_revalidated_before_mutation(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = pathlib.Path(tmp)
+ exe = root / "claude"
+ exe.write_text("#!/bin/sh\n")
+ exe.chmod(0o700)
+ with patch("fable_mode.adapters._which", return_value=exe), \
+ patch("fable_mode.adapters._run_probe", return_value=(0, "ok", "", False)):
+ host = detect_hosts()["claude"]
+ exe.write_text("#!/bin/sh\nchanged\n")
+ with self.assertRaises(RegistrationError):
+ register_hosts({"claude": host}, ["fable"] , home=root)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_v2_trust_system3.py b/tests/test_v2_trust_system3.py
new file mode 100644
index 0000000..6ccb4dd
--- /dev/null
+++ b/tests/test_v2_trust_system3.py
@@ -0,0 +1,142 @@
+import hashlib
+import hmac
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+from fable_v2 import (
+ Candidate, Evidence, FableRun, HMACProcessAttestationVerifier, ProcessAttestation, TaskSpec, ToolReceipt, VerificationPolicy,
+ VerificationResult, new_run,
+)
+from fable_v2.execution_broker import BrokerPolicy, ExecutionBroker
+from fable_v2.runtime import HMACCheckpointAuthenticator
+from fable_v2.protocol import canonical_hash
+from fable_v2.system3 import (
+ ActiveInferenceEngine, DialecticalSynthesizer, MetaProofInducer,
+ HyperbolicGeometryError, HyperbolicTreeEmbedder,
+ ThesisCandidate, AntithesisCritique, create_default_architecture_pomdp,
+)
+
+
+class TrustSystem3RegressionTests(unittest.TestCase):
+ def _run(self):
+ task = TaskSpec(
+ "trust", "trust", definition_of_done=("done",),
+ verification_policy=VerificationPolicy(
+ required_verifier_classes=("deterministic",),
+ minimum_passing_verifiers=1, require_independent=False,
+ ),
+ )
+ run = new_run("session", task, compatibility_mode=True)
+ receipt = ToolReceipt.from_result(
+ receipt_id="receipt", session_id="session", capability="check",
+ tool_name="checker", tool_input={"x": 1}, tool_output={"ok": True},
+ success=True,
+ )
+ run.record_receipt(receipt)
+ evidence = Evidence.from_receipt(
+ receipt, evidence_id="evidence", claim="checked", kind="check", source="checker"
+ )
+ run.attach_evidence(evidence)
+ run.register_candidate(Candidate(
+ "candidate", "session", "test", {"value": 1}, ("receipt",), ("evidence",)
+ ))
+ return run
+
+ def test_finalization_rejects_mutated_dependency(self):
+ run = self._run()
+ # The map is intentionally mutable for compatibility; the finalization
+ # commitment must detect mutation rather than silently trust it.
+ run.candidates["candidate"].artifact["value"] = 99
+ with self.assertRaises(PermissionError):
+ run.finalize("candidate")
+
+ def test_checkpoint_omits_runtime_secret_and_detects_truncation(self):
+ run = self._run()
+ payload = run.to_dict()
+ self.assertNotIn("attestation_secret", payload)
+ payload["events"] = payload["events"][:-1]
+ with self.assertRaises(ValueError):
+ FableRun.from_dict(payload)
+
+ def test_terminal_checkpoint_requires_external_authentication(self):
+ run = self._run()
+ run.execute_verifier(__import__("fable_v2").FunctionVerifier(
+ "det", lambda candidate: (True, ("ok",), 1.0), evidence_ids=("evidence",)
+ ), "candidate")
+ run.finalize("candidate")
+ with self.assertRaises(PermissionError):
+ FableRun.from_dict(run.to_dict())
+ authenticator = HMACCheckpointAuthenticator(b"x" * 32)
+ restored = FableRun.from_dict(run.to_dict(authenticator), authenticator)
+ self.assertEqual(restored.state.value, "finalized")
+
+ def test_hyperbolic_cycles_and_non_boolean_success_are_rejected(self):
+ with self.assertRaises(HyperbolicGeometryError):
+ HyperbolicTreeEmbedder().embed_hierarchy({"a": ["b"], "b": ["a"]}, root_id="a")
+ with self.assertRaises(TypeError):
+ ToolReceipt.from_result(receipt_id="bad", session_id="session", capability="x",
+ tool_name="x", tool_input="x", tool_output="x", success=1)
+
+ def test_process_attestation_fails_closed_without_external_verifier(self):
+ run = self._run()
+ c = run.candidates["candidate"]
+ graph = run._candidate_graph_hash(c)
+ result = VerificationResult(
+ "v", "session", "candidate", "external", True,
+ evidence_ids=("evidence",), verifier_class="deterministic",
+ candidate_hash=canonical_hash(c.artifact), candidate_graph_hash=graph,
+ )
+ attestation = ProcessAttestation(
+ "a", "session", "candidate", "external", "deterministic", True,
+ result.candidate_hash, graph, ("evidence",), "receipt",
+ {"path": "/trusted/checker", "sha256": "abc"}, {"path": "/workspace"},
+ )
+ with self.assertRaises(PermissionError):
+ run.record_process_attested_verification(result, attestation, "material")
+
+ def test_active_inference_has_observe_predict_act_update_and_persists(self):
+ engine = ActiveInferenceEngine(create_default_architecture_pomdp())
+ cycle = engine.observe_predict_act_update("HIGH_THROUGHPUT_CLEAN")
+ self.assertTrue(cycle["predictions"])
+ self.assertTrue(cycle["action"])
+ restored = ActiveInferenceEngine.from_dict(engine.to_dict())
+ self.assertEqual(restored.last_observation, "HIGH_THROUGHPUT_CLEAN")
+ self.assertEqual(restored.last_action, cycle["action"])
+ self.assertEqual(restored.step_count, 1)
+
+ def test_induced_axiom_is_not_proven_without_evidence(self):
+ axiom = MetaProofInducer().induce_axioms_from_session([], [], {"phase_history": []})[0]
+ self.assertNotEqual(axiom.status.value, "proven")
+ ok, _, _ = MetaProofInducer().verify_axiom_empirically(axiom, [{}, {}, {}])
+ self.assertFalse(ok)
+ self.assertNotEqual(axiom.status.value, "proven")
+
+ def test_dialectical_output_labels_unmeasured_claims(self):
+ synthesis = DialecticalSynthesizer().synthesize(
+ ThesisCandidate("t", "T", "proposal"),
+ AntithesisCritique("a", "t", "critique"),
+ )
+ self.assertFalse(synthesis.convergence_achieved)
+ self.assertIn("UNMEASURED", synthesis.pareto_improvement_claim)
+
+ @unittest.skipIf(os.name == "nt", "Windows broker execution is deliberately fail-closed")
+ def test_broker_emits_structured_measured_receipt(self):
+ with tempfile.TemporaryDirectory() as directory:
+ broker = ExecutionBroker(BrokerPolicy(
+ workspace=Path(directory), allowed_executables=("python3",),
+ write_token_digest=hashlib.sha256(b"unlock").hexdigest(),
+ ))
+ broker.unlock_writes("unlock")
+ result = broker.execute_command(["python3", "-c", "print('ok')"], session_id="session")
+ receipt = result["receipt"]
+ self.assertEqual(receipt["session_id"], "session")
+ self.assertEqual(receipt["input_hash"], result["input_hash"])
+ self.assertIn("executable_identity", receipt)
+ self.assertIn("workspace_identity", receipt)
+ self.assertIn("cancellation_status", receipt)
+
+
+if __name__ == "__main__":
+ unittest.main()