diff --git a/.github/scripts/validate-eclexiaiser.py b/.github/scripts/validate-eclexiaiser.py new file mode 100755 index 00000000..5b5a8c45 --- /dev/null +++ b/.github/scripts/validate-eclexiaiser.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# validate-eclexiaiser.py — structural validation for eclexiaiser.toml +# Extracted from dogfood-gate.yml: an inline `python3 -c` heredoc at column 1 +# cannot satisfy both YAML block-scalar indentation and Python's no-indent +# rule for top-level statements, so the workflow failed YAML validation at +# startup (0s, no jobs). Keeping the validator in a real file fixes that and +# makes the logic testable on its own. +"""Validate the structure of ./eclexiaiser.toml (Python 3.11+ tomllib).""" +import tomllib +import sys + +with open("eclexiaiser.toml", "rb") as f: + data = tomllib.load(f) + +project = data.get("project", {}) +if not project.get("name", "").strip(): + print("ERROR: project.name is required", file=sys.stderr) + sys.exit(1) + +functions = data.get("functions", []) +if not functions: + print("ERROR: at least one [[functions]] entry is required", file=sys.stderr) + sys.exit(1) + +for fn in functions: + if not fn.get("name", "").strip(): + print("ERROR: function name cannot be empty", file=sys.stderr) + sys.exit(1) + if not fn.get("source", "").strip(): + print(f'ERROR: function {fn["name"]} has no source path', file=sys.stderr) + sys.exit(1) + +print(f'Valid: {project["name"]} ({len(functions)} function(s))') diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 98f74091..46b7214e 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -256,28 +256,10 @@ jobs: echo "has_manifest=true" >> "$GITHUB_OUTPUT" - # Validate TOML structure using Python 3.11+ tomllib - python3 -c " -import tomllib, sys -with open('eclexiaiser.toml', 'rb') as f: - data = tomllib.load(f) -project = data.get('project', {}) -if not project.get('name', '').strip(): - print('ERROR: project.name is required', file=sys.stderr) - sys.exit(1) -functions = data.get('functions', []) -if not functions: - print('ERROR: at least one [[functions]] entry is required', file=sys.stderr) - sys.exit(1) -for fn in functions: - if not fn.get('name', '').strip(): - print('ERROR: function name cannot be empty', file=sys.stderr) - sys.exit(1) - if not fn.get('source', '').strip(): - print(f'ERROR: function {fn[\"name\"]} has no source path', file=sys.stderr) - sys.exit(1) -print(f'Valid: {project[\"name\"]} ({len(functions)} function(s))') -" || { + # Validate TOML structure using Python 3.11+ tomllib. + # Script lives in .github/scripts/ so the YAML block scalar and + # Python's indentation rules don't conflict (see that file's header). + python3 .github/scripts/validate-eclexiaiser.py || { echo "::error file=eclexiaiser.toml::Invalid eclexiaiser.toml — see step output for details" exit 1 } diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 399aa393..30116321 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -73,6 +73,7 @@ test-coverage = "CLOSED 2026-04-25 — 165 ExUnit tests; CRG C met" [session-history] entries = [ + { date = "2026-05-18", description = "k9iser-mcp PR #73 session: (1) Added k9iser-mcp cartridge — reference -iser regeneration-cartridge pattern (central K9 contract regeneration) mirroring ssg-mcp: cartridge.json, mod.js, Idris2 ABI, Zig FFI, panels. (2) Unified transaction-gated adapter: ONE internal/loopback listener, protocol-routed REST+SSE+GraphQL+gRPC-compat → SINGLE dispatch → one Zig ABI, replacing the ssg-era 3-parallel-port anti-pattern; trust gate runs before every dispatch mirroring the Idris2 exposureSatisfied contract (no gatekeeperless path); internal-only behind http-capability-gateway per ADR-0004. (3) boj-rest SSE: POST /cartridge/:name/sse on the same single Cowboy listener + trust-gated dispatch, text/event-stream. (4) Doc reconciliation: elixir/README.adoc, mcp-bridge/api-clients.js, OPERATOR-QUICKSTART.md corrected to the verified runtime + ADR-0004 tiered model (previously wrongly 'skeleton/501/pending rewrite'). (5) CI ROOT-CAUSE FIX: dogfood-gate.yml failed YAML validation at startup (0s, no jobs) on every branch incl. main — inline `python3 -c \"` placed Python at column 1 inside a `run: |` block scalar, terminating the scalar early; since 'Dogfood Gate' is a required status check this silently blocked EVERY PR in the repo. Validator extracted to .github/scripts/validate-eclexiaiser.py and invoked from the workflow (commit 891b162). Verification: Elixir 177/177 (incl. 2 SSE tests); Zig ffi 16/16 + unified adapter 5/5 (exposure-gate truth table mirroring Idris2 contract); idris2 --check K9iserMcp/SafeK9iser.idr passes. Out of scope / separately tracked: http-capability-gateway production-wiring (ADR-0004 tier-2, ~8-12wk) and iseriser-scaffold rollout. Refs hyperpolymath/k9iser#8. NOTE: merge held — GitHub Actions runner-starved estate-wide (standards#122), required checks cannot execute until that clears." }, { date = "2026-04-27", description = "Worker briefing + hook session: (1) Created .boj/project-memories.a2ml — standing worker briefing template covering coord registration, completion contract ('push succeeds, not TODO list'), backend wiring checklist, banned patterns (unimplemented!/sorry/Admitted/believe_me/.unwrap()-as-debt), comms protocol, git hygiene, and echidna language policy. Committed 039908a + pushed to main. (2) Updated ~/.config/coord-tui/coord-hooks.sh claude() function to auto-prepend project-memories.a2ml via --append-system-prompt when launched from a repo with .boj/project-memories.a2ml. Repos without the file are unaffected." }, { date = "2026-04-25", description = "Close-out documentation sweep: ROADMAP.adoc current status → 'v1.1.0 Active (Grade C)'; seed node v0.3.0 bullet updated with fly.dev hostnames + LIVE date; Phase 8/9/Grade-C bullets added. EXPLAINME.adoc testing section expanded to 11 modules (173 total, 0 failures); 'harnesses pending' note removed; seed node infrastructure table added. AI-WORK-todo.md §8 header updated from 'in progress' to 'Grade C / 4 nodes LIVE'; docs-sweep bullet added. All changes committed + pushed to main." }, { date = "2026-04-25", description = "Documentation + deploy sweep session: (1) .dockerignore fixed — ffi/zig/.zig-cache/ (347 MB) + ffi/zig/zig-out/ + cartridges/*/ffi/.zig-cache/ added; reduces build context from ~2.8 GB to ~2.2 MB for fly seed deploys. (2) Containerfile.fly.dockerignore created at repo root (Docker BuildKit per-Dockerfile override) — also excludes cartridges/*/ffi/ + abi/ + adapter/ + panels/ (cartridge source not needed in seed builds). (3) 6a2 sweep: AGENTIC (fly.io pitfall constraints + v0.2.0); ECOSYSTEM (seed-nodes updated with fly.io apps/hosts/regions + dns-status + federation protocol corrected + V-lang ref fixed + v0.2.0); NEUROSYM (report-format logtalk→json + v0.2.0); PLAYBOOK (full operational runbook: local dev, full container, fly.io per-node + deploy-seeds.sh, notes on glibc/musl ABI, .dockerignore, custom domain procedure + v0.2.0); META (languages elixir+javascript added, ADR-010 through ADR-014, design-rationale seed-node-lean-build + trust-level-design + v0.3.0). (4) All 4 fly.io seed nodes deployed: boj-seed-eu (lhr), boj-seed-de (fra), boj-seed-us (iad), boj-seed-ap (syd) — each 2/2 health checks passing, 61 MB image. gap-5 closed." }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a48b261..1ac086fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ All notable changes to Bundle of Joy Server are documented here. +## [Unreleased] + +### Added + +- **k9iser-mcp cartridge** — reference implementation of the `-iser` + regeneration-cartridge pattern (central K9 contract regeneration), mirroring + ssg-mcp: `cartridge.json`, `mod.js`, Idris2 ABI, Zig FFI, panels. +- **Unified transaction-gated adapter**: one internal/loopback listener, + protocol-routed REST + SSE + GraphQL + gRPC-compat → single dispatch → one + Zig ABI. Replaces the ssg-era 3-parallel-port anti-pattern; the trust gate + runs before every dispatch, mirroring the Idris2 `exposureSatisfied` + contract (no gatekeeperless path). Internal-only behind + `http-capability-gateway` per ADR-0004. +- **boj-rest SSE surface**: `POST /cartridge/:name/sse` on the same single + Cowboy listener and trust-gated dispatch, `text/event-stream`. + +### Changed + +- **Doc reconciliation to ADR-0004**: `elixir/README.adoc`, + `mcp-bridge/api-clients.js`, and `OPERATOR-QUICKSTART.md` corrected to the + verified runtime + ADR-0004 tiered model (they previously and wrongly + described it as "skeleton/501/pending rewrite"). + +### Fixed + +- **`dogfood-gate.yml` failed YAML validation at startup** (0 s, no jobs) on + every branch including `main`: an inline `python3 -c "` block placed Python + source at column 1 inside a `run: |` block scalar, terminating the scalar + early. Because **Dogfood Gate** is a required status check, this silently + blocked every PR in the repo. The validator now lives in + `.github/scripts/validate-eclexiaiser.py` and is invoked from the workflow. + +> Verification (k9iser-mcp): Elixir suite 177/177 (incl. 2 SSE tests); Zig +> ffi 16/16 and unified adapter 5/5 (exposure-gate truth table mirroring the +> Idris2 contract); `idris2 --check K9iserMcp/SafeK9iser.idr` passes. +> http-capability-gateway production-wiring (ADR-0004 tier-2) and the +> iseriser-scaffold rollout remain out of scope and separately tracked. + ## [0.4.0] — 2026-04-17 ### Changed diff --git a/cartridges/k9iser-mcp/README.adoc b/cartridges/k9iser-mcp/README.adoc new file mode 100644 index 00000000..902cff05 --- /dev/null +++ b/cartridges/k9iser-mcp/README.adoc @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += k9iser-mcp — K9 contract regeneration cartridge +:toc: macro +:orientation: shallow + +toc::[] + +== Purpose + +Wraps the `k9iser` generator as a BoJ cartridge so that a repository's +`generated/k9iser/*.k9` contracts are regenerated *centrally, on trigger* +instead of being run ad hoc and hand-committed (which is how they rot — +see hyperpolymath/k9iser#8 and the idaptik#77 triage). + +This is the reference implementation for the `-iser` regeneration-cartridge +pattern (standards epic): every `-iser` generator should ship an equivalent +so `generated/*` stops being hand-maintained drift estate-wide. + +== Pipeline + +[source] +---- +Empty -> ManifestLoaded -> Generated -> Validated -> Applied +---- + +Enforced safety invariants (formally in `abi/K9iserMcp/SafeK9iser.idr`, +executably in `ffi/k9iser_ffi.zig`): + +. *No apply without validate* — contracts that fail the canonical + `k9-validate` ruleset (K9! magic + pedigree) can never be committed back + to a repository. +. *No validate without generate* — stale/absent output is never validated. +. *Generate requires a loaded manifest.* + +== Tools (`cartridge.json`) + +[cols="1,3"] +|=== +| `k9_load_manifest` | Load a repo's `k9iser.toml` +| `k9_generate` | Generate K9 contracts from configs + manifest rules +| `k9_validate` | Validate against the canonical K9! + pedigree ruleset +| `k9_apply` | Commit + push regenerated contracts to the branch +| `k9_clean` | Clean the session +|=== + +== Layout + +* `cartridge.json` — manifest (tools, ADR-0006 ffi symbols) +* `mod.js` — Deno MCP bridge → backend at `K9ISER_BACKEND_URL` + (default `http://127.0.0.1:7743`) +* `abi/` — Idris2 ABI: pipeline state machine + safety GADT +* `ffi/` — Zig FFI: C-ABI exports + ADR-0006 `boj_cartridge_invoke` + dispatch (16/16 tests pass) +* `adapter/` — three-protocol (REST/gRPC-compat/GraphQL) adapter +* `panels/` — Panll dashboard manifest + +== Status + +Grade D Alpha. Cartridge package + verified state machine are complete. +`boj_cartridge_invoke` returns shaped stubs; the live path additionally +needs the backend service (runs the `k9iser` binary against a checked-out +working tree) *and* the BoJ REST runtime, which is pending the Elixir +rewrite — until that deploys, the templated `k9iser-regen` trigger is +fire-and-forget exactly like `boj-build.yml` is today. diff --git a/cartridges/k9iser-mcp/abi/K9iserMcp/SafeK9iser.idr b/cartridges/k9iser-mcp/abi/K9iserMcp/SafeK9iser.idr new file mode 100644 index 00000000..230930c9 --- /dev/null +++ b/cartridges/k9iser-mcp/abi/K9iserMcp/SafeK9iser.idr @@ -0,0 +1,211 @@ +-- SPDX-License-Identifier: PMPL-1.0-or-later +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| K9iserMcp.SafeK9iser: Formally verified K9-contract generation pipeline. +||| +||| Cartridge: k9iser-mcp +||| Matrix cell: config domain x {MCP, REST} protocols +||| +||| This module defines a regeneration pipeline state machine that prevents: +||| - Applying (committing) contracts that were never validated +||| - Validating contracts that were never generated +||| - Generating from an unloaded manifest +||| +||| State machine: Empty -> ManifestLoaded -> Generated -> Validated -> Applied +module K9iserMcp.SafeK9iser + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- K9iser Pipeline State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| k9iser regeneration lifecycle states. +||| Progresses: Empty -> ManifestLoaded -> Generated -> Validated -> Applied +public export +data K9State + = Empty + | ManifestLoaded + | Generated + | Validated + | Applied + | K9Error + +||| Equality for K9 states. +public export +Eq K9State where + Empty == Empty = True + ManifestLoaded == ManifestLoaded = True + Generated == Generated = True + Validated == Validated = True + Applied == Applied = True + K9Error == K9Error = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +||| Critically, Generated -> Applied is NOT valid (must validate first). +||| And ManifestLoaded -> Validated is NOT valid (must generate first). +public export +data ValidTransition : K9State -> K9State -> Type where + LoadManifest : ValidTransition Empty ManifestLoaded + Generate : ValidTransition ManifestLoaded Generated + Regenerate : ValidTransition Generated Generated + Validate : ValidTransition Generated Validated + Apply : ValidTransition Validated Applied + CleanApplied : ValidTransition Applied Empty + CleanReady : ValidTransition Validated Empty + ManifestErr : ValidTransition ManifestLoaded K9Error + GenerateErr : ValidTransition Generated K9Error + Recover : ValidTransition K9Error Empty + +||| Runtime transition validator (matches the Zig FFI isValidTransition). +public export +canTransition : K9State -> K9State -> Bool +canTransition Empty ManifestLoaded = True +canTransition ManifestLoaded Generated = True +canTransition Generated Generated = True -- regenerate +canTransition Generated Validated = True +canTransition Validated Applied = True +canTransition Applied Empty = True -- clean +canTransition Validated Empty = True -- clean without applying +canTransition ManifestLoaded K9Error = True -- parse error +canTransition Generated K9Error = True -- generation error +canTransition K9Error Empty = True -- recover +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Config Format Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Config formats k9iser can wrap (mirrors k9iser's ConfigFormat). +public export +data K9Format + = Toml + | Yaml + | Json + | Ini + | Custom String + +||| C-ABI encoding. +public export +formatToInt : K9Format -> Int +formatToInt Toml = 1 +formatToInt Yaml = 2 +formatToInt Json = 3 +formatToInt Ini = 4 +formatToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +public export +data McpTool + = ToolLoadManifest -- Load the k9iser.toml manifest + | ToolGenerate -- Generate K9 contracts from configs + | ToolValidate -- Validate contracts (K9! magic + pedigree) + | ToolApply -- Commit + push regenerated contracts + | ToolClean -- Clean the session + | ToolStatus -- Pipeline health check + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolLoadManifest = "k9/load_manifest" +toolName ToolGenerate = "k9/generate" +toolName ToolValidate = "k9/validate" +toolName ToolApply = "k9/apply" +toolName ToolClean = "k9/clean" +toolName ToolStatus = "k9/status" + +||| Which tools require generated contracts already present. +||| Validate and Apply both require a successful generate first. +public export +toolRequiresGenerate : McpTool -> Bool +toolRequiresGenerate ToolValidate = True +toolRequiresGenerate ToolApply = True +toolRequiresGenerate _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| K9 state to integer. +public export +k9StateToInt : K9State -> Int +k9StateToInt Empty = 0 +k9StateToInt ManifestLoaded = 1 +k9StateToInt Generated = 2 +k9StateToInt Validated = 3 +k9StateToInt Applied = 4 +k9StateToInt K9Error = 5 + +||| FFI: Validate a state transition. +export +k9_can_transition : Int -> Int -> Int +k9_can_transition from to = + let fromState = case from of + 0 => Empty + 1 => ManifestLoaded + 2 => Generated + 3 => Validated + 4 => Applied + _ => K9Error + toState = case to of + 0 => Empty + 1 => ManifestLoaded + 2 => Generated + 3 => Validated + 4 => Applied + _ => K9Error + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires generated contracts. +export +k9_tool_requires_generate : Int -> Int +k9_tool_requires_generate 2 = 1 -- ToolValidate +k9_tool_requires_generate 3 = 1 -- ToolApply +k9_tool_requires_generate _ = 0 -- All others do not + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Exposure / transaction-gating contract (BoJ interface-safety policy) +-- ═══════════════════════════════════════════════════════════════════════════ +-- A port boundary must never be a gatekeeperless gateway: every adapter +-- exposes the unified ABI ONLY behind this transaction gate. Mirrors +-- BojRest.TrustPolicy: caller exposure derived from the cartridge's +-- auth.method, loopback callers locally trusted, X-Trust-Level enforced. + +||| Caller trust the gateway/sidecar has established. +public export +data Exposure = Public | Authenticated | Internal + +||| Required exposure inferred from cartridge auth.method. +||| "none"/absent → Public; any credential-bearing method → Authenticated. +public export +requiredExposure : (authMethodIsNone : Bool) -> Exposure +requiredExposure True = Public +requiredExposure False = Authenticated + +||| The transaction gate. Loopback callers are locally trusted (mcp-bridge, +||| local curl). Otherwise the presented X-Trust-Level must meet the +||| required exposure. This is the total relation the Zig transaction layer +||| mirrors; no dispatch may occur unless it returns True. +public export +exposureSatisfied : (required : Exposure) -> (presented : Exposure) -> (isLocal : Bool) -> Bool +exposureSatisfied _ _ True = True +exposureSatisfied Public _ _ = True +exposureSatisfied Authenticated Authenticated _ = True +exposureSatisfied Authenticated Internal _ = True +exposureSatisfied Internal Internal _ = True +exposureSatisfied _ _ _ = False + +||| FFI: 1 if dispatch is permitted, 0 if the gate rejects. +||| req/pres encoding: 0=Public 1=Authenticated 2=Internal; isLocal: 1/0. +export +k9_exposure_satisfied : Int -> Int -> Int -> Int +k9_exposure_satisfied req pres isLocal = + let r = case req of { 0 => Public; 1 => Authenticated; _ => Internal } + p = case pres of { 0 => Public; 1 => Authenticated; _ => Internal } + in if exposureSatisfied r p (isLocal /= 0) then 1 else 0 diff --git a/cartridges/k9iser-mcp/abi/README.adoc b/cartridges/k9iser-mcp/abi/README.adoc new file mode 100644 index 00000000..ccbc9dfa --- /dev/null +++ b/cartridges/k9iser-mcp/abi/README.adoc @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += k9iser-mcp / abi — Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `k9iser-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +* `K9iserMcp/SafeK9iser.idr` — the regeneration pipeline state machine + (`Empty -> ManifestLoaded -> Generated -> Validated -> Applied`), the + `ValidTransition` GADT proving the safety invariants (no apply without + validate; no validate without generate), and the C-ABI exports + (`k9_can_transition`, `k9_tool_requires_generate`). +* `k9iser-mcp.ipkg` — Idris2 package descriptor. + +== Safety invariants + +. A contract set can only be *applied* (committed back to the repo) from + the `Validated` state — regenerated contracts that failed the canonical + `k9-validate` ruleset (K9! magic + pedigree) can never be pushed. +. A contract set can only be *validated* from `Generated` — there is no + path that validates stale or absent output. +. Generation requires a loaded manifest. + +The Zig FFI (`../ffi/k9iser_ffi.zig`) mirrors `canTransition` exactly; the +`state transition validation` test there is the executable cross-check. diff --git a/cartridges/k9iser-mcp/abi/k9iser-mcp.ipkg b/cartridges/k9iser-mcp/abi/k9iser-mcp.ipkg new file mode 100644 index 00000000..0eaf3bbc --- /dev/null +++ b/cartridges/k9iser-mcp/abi/k9iser-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: PMPL-1.0-or-later +package k9isermcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "PMPL-1.0-or-later" +brief = "k9iser-mcp cartridge — K9 contract regeneration pipeline state machine" + +sourcedir = "." +modules = K9iserMcp.SafeK9iser +depends = base, contrib diff --git a/cartridges/k9iser-mcp/adapter/README.adoc b/cartridges/k9iser-mcp/adapter/README.adoc new file mode 100644 index 00000000..740591d0 --- /dev/null +++ b/cartridges/k9iser-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += k9iser-mcp / adapter — protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph — depends on the sibling `ffi` target. +| `k9iser_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | V-lang predecessor(s). Not built — V-lang banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** — all state lives behind the FFI, never in the adapter. +* **Response passthrough** — whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer — protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `k9iser_adapter.zig` — dispatch function and per-protocol routers. +. `../cartridge.json` — the tool manifest the dispatcher mirrors. diff --git a/cartridges/k9iser-mcp/adapter/build.zig b/cartridges/k9iser-mcp/adapter/build.zig new file mode 100644 index 00000000..b65c8155 --- /dev/null +++ b/cartridges/k9iser-mcp/adapter/build.zig @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// k9iser-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/k9iser_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter_mod = b.createModule(.{ + .root_source_file = b.path("k9iser_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter_mod.addImport("k9iser_ffi", ffi_mod); + + const adapter = b.addExecutable(.{ + .name = "k9iser_adapter", + .root_module = adapter_mod, + }); + b.installArtifact(adapter); + + // Unified-adapter tests (classify/toolFor/dispatch → one Zig ABI). + const adapter_tests = b.addTest(.{ .root_module = adapter_mod }); + const run_tests = b.addRunArtifact(adapter_tests); + const test_step = b.step("test", "Run k9iser-mcp unified adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/k9iser-mcp/adapter/k9iser_adapter.zig b/cartridges/k9iser-mcp/adapter/k9iser_adapter.zig new file mode 100644 index 00000000..8aec158b --- /dev/null +++ b/cartridges/k9iser-mcp/adapter/k9iser_adapter.zig @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// k9iser-mcp/adapter/k9iser_adapter.zig +// +// INTERNAL-ONLY unified adapter. This is NOT a public ingress. Per +// ADR-0004 the only governed public surface is the http-capability-gateway +// (tier-2) in front of the unified Zig core; cartridge adapters bind +// loopback and sit behind it. One listener, one port, protocol-routed +// (REST + SSE + GraphQL + gRPC-compat) into a SINGLE transaction-gated +// dispatch → the one Zig ABI (ffi.boj_cartridge_invoke). Deliberately NOT +// N parallel servers and NOT a public listener. +// +// POST /invoke → REST (JSON in/out) +// POST /sse → SSE (text/event-stream) +// POST /graphql → GraphQL (op parsed from body) +// POST /grpc// → gRPC-compat (tool = method) +// +// Every request passes the transaction gate (exposureGate, mirroring the +// Idris2 K9iserMcp.SafeK9iser.exposureSatisfied contract) BEFORE dispatch. +// No request reaches the ABI ungated — this boundary is not a +// gatekeeperless gateway (estate interface-safety policy). + +const std = @import("std"); +const ffi = @import("k9iser_ffi"); + +// Loopback-only by construction: this adapter is internal, fronted by the +// http-capability-gateway (ADR-0004). Never bind a routable interface. +const BIND_IP = [4]u8{ 127, 0, 0, 1 }; +const PORT: u16 = 9390; + +// ── Transaction gate (mirrors Idris2 SafeK9iser.exposureSatisfied) ────── +// +// Encoding matches K9iserMcp.SafeK9iser: 0=Public 1=Authenticated 2=Internal. +const Exposure = enum(u8) { public = 0, authenticated = 1, internal = 2 }; + +// k9iser-mcp cartridge.json: auth.method = "none" → requiredExposure = Public. +// (requiredExposure(authMethodIsNone=true) = Public, per the Idris2 contract.) +const REQUIRED_EXPOSURE: Exposure = .public; + +/// Zig mirror of Idris2 `exposureSatisfied`. Cross-checked by the truth-table +/// test below; the Idris2 module is the source-of-truth contract. +fn exposureSatisfied(required: Exposure, presented: Exposure, is_local: bool) bool { + if (is_local) return true; // loopback callers are locally trusted + return switch (required) { + .public => true, + .authenticated => presented == .authenticated or presented == .internal, + .internal => presented == .internal, + }; +} + +/// Parse the `X-Trust-Level` request header the gateway/sidecar sets. +/// Missing/unknown → Public (conservative). Case-insensitive header name. +fn presentedExposure(req: []const u8) Exposure { + const val = headerValue(req, "x-trust-level") orelse return .public; + if (eqIgnoreCase(val, "internal")) return .internal; + if (eqIgnoreCase(val, "authenticated")) return .authenticated; + return .public; +} + +fn eqIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + return true; +} + +/// Case-insensitive single-header lookup over a raw HTTP/1.1 request. +fn headerValue(req: []const u8, name: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, req, '\n'); + _ = lines.next(); // request line + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len == 0) break; // end of headers + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + if (eqIgnoreCase(std.mem.trim(u8, line[0..colon], " "), name)) + return std.mem.trim(u8, line[colon + 1 ..], " "); + } + return null; +} + +const Dispatch = struct { status: u16, body: []const u8 }; + +/// The single point where every protocol converges onto the one Zig ABI. +fn dispatch(tool: []const u8, args_json: []const u8, out: []u8) Dispatch { + var tnbuf: [128]u8 = undefined; + if (tool.len == 0 or tool.len >= tnbuf.len) + return .{ .status = 400, .body = "{\"error\":\"bad-tool\"}" }; + @memcpy(tnbuf[0..tool.len], tool); + tnbuf[tool.len] = 0; + + var abuf: [4096]u8 = undefined; + const a = if (args_json.len == 0) "{}" else args_json; + if (a.len >= abuf.len) + return .{ .status = 413, .body = "{\"error\":\"args-too-large\"}" }; + @memcpy(abuf[0..a.len], a); + abuf[a.len] = 0; + + var len: usize = out.len; + const rc = ffi.boj_cartridge_invoke(@ptrCast(&tnbuf), @ptrCast(&abuf), @ptrCast(out.ptr), &len); + return switch (rc) { + 0 => .{ .status = 200, .body = out[0..len] }, + -1 => .{ .status = 404, .body = "{\"error\":\"unknown-tool\"}" }, + -2 => .{ .status = 400, .body = "{\"error\":\"bad-args\"}" }, + -3 => .{ .status = 500, .body = "{\"error\":\"buffer-too-small\"}" }, + else => .{ .status = 500, .body = "{\"error\":\"invoke-failed\"}" }, + }; +} + +const Protocol = enum { rest, sse, graphql, grpc, unknown }; + +fn classify(path: []const u8) Protocol { + if (std.mem.startsWith(u8, path, "/invoke")) return .rest; + if (std.mem.startsWith(u8, path, "/sse")) return .sse; + if (std.mem.startsWith(u8, path, "/graphql")) return .graphql; + if (std.mem.startsWith(u8, path, "/grpc/")) return .grpc; + return .unknown; +} + +fn toolFor(proto: Protocol, path: []const u8, body: []const u8) ?[]const u8 { + switch (proto) { + .grpc => { + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // "" + _ = it.next(); // "grpc" + _ = it.next(); // service + return it.next(); + }, + .rest, .sse => { + if (std.mem.indexOf(u8, path, "tool=")) |q| { + const rest = path[q + 5 ..]; + const end = std.mem.indexOfAny(u8, rest, "& ") orelse rest.len; + if (end > 0) return rest[0..end]; + } + return jsonStringField(body, "tool"); + }, + .graphql => { + const tools = [_][]const u8{ + "k9_load_manifest", "k9_generate", "k9_validate", "k9_apply", "k9_clean", + }; + for (tools) |t| if (std.mem.indexOf(u8, body, t) != null) return t; + return null; + }, + .unknown => return null, + } +} + +fn jsonStringField(body: []const u8, key: []const u8) ?[]const u8 { + var kbuf: [64]u8 = undefined; + if (key.len + 2 >= kbuf.len) return null; + kbuf[0] = '"'; + @memcpy(kbuf[1 .. 1 + key.len], key); + kbuf[1 + key.len] = '"'; + const needle = kbuf[0 .. key.len + 2]; + const k = std.mem.indexOf(u8, body, needle) orelse return null; + var i = k + needle.len; + while (i < body.len and (body[i] == ':' or body[i] == ' ')) : (i += 1) {} + if (i >= body.len or body[i] != '"') return null; + i += 1; + const start = i; + while (i < body.len and body[i] != '"') : (i += 1) {} + if (i > start) return body[start..i] else return null; +} + +fn writeHttp(stream: std.net.Stream, status: u16, ctype: []const u8, body: []const u8) void { + var hdr: [256]u8 = undefined; + const h = std.fmt.bufPrint(&hdr, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ status, ctype, body.len }) catch return; + _ = stream.write(h) catch {}; + _ = stream.write(body) catch {}; +} + +fn writeSse(stream: std.net.Stream, d: Dispatch) void { + const head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"; + _ = stream.write(head) catch {}; + _ = stream.write("event: open\ndata: {\"cartridge\":\"k9iser-mcp\"}\n\n") catch {}; + var fb: [4608]u8 = undefined; + const ev = if (d.status == 200) "result" else "error"; + const frame = std.fmt.bufPrint(&fb, "event: {s}\ndata: {s}\n\n", .{ ev, d.body }) catch "event: error\ndata: {}\n\n"; + _ = stream.write(frame) catch {}; + _ = stream.write("event: done\ndata: {}\n\n") catch {}; +} + +// Loopback-only listener ⇒ peers are local by construction. We still +// evaluate the gate every request (no gatekeeperless path); the +// non-local branch is exercised by exposureSatisfied's tests. +fn handleConnection(stream: std.net.Stream) void { + defer stream.close(); + var buf: [8192]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse return; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse return; + + const body_start = std.mem.indexOf(u8, req, "\r\n\r\n"); + const body = if (body_start) |bs| req[bs + 4 ..] else ""; + + const proto = classify(path); + if (proto == .unknown) { + writeHttp(stream, 404, "application/json", "{\"error\":\"route-not-found\"}"); + return; + } + + // ── TRANSACTION GATE — runs before dispatch, every request ────────── + const is_local = true; // loopback-bound (BIND_IP); see module header + if (!exposureSatisfied(REQUIRED_EXPOSURE, presentedExposure(req), is_local)) { + writeHttp(stream, 403, "application/json", "{\"error\":\"forbidden\",\"detail\":\"exposure-gate\"}"); + return; + } + + const tool = toolFor(proto, path, body) orelse { + writeHttp(stream, 400, "application/json", "{\"error\":\"missing-tool\"}"); + return; + }; + + var out: [4096]u8 = undefined; + const d = dispatch(tool, body, &out); + + switch (proto) { + .sse => writeSse(stream, d), + .graphql => { + var gb: [4352]u8 = undefined; + const g = std.fmt.bufPrint(&gb, "{{\"data\":{{\"invoke\":{s}}}}}", .{d.body}) catch d.body; + writeHttp(stream, d.status, "application/json", g); + }, + else => writeHttp(stream, d.status, "application/json", d.body), // rest, grpc + } +} + +pub fn main() !void { + _ = ffi.boj_cartridge_init(); + defer ffi.boj_cartridge_deinit(); + const addr = std.net.Address.initIp4(BIND_IP, PORT); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + std.debug.print("k9iser-mcp INTERNAL unified adapter on 127.0.0.1:{d} (behind http-capability-gateway; rest|sse|graphql|grpc; transaction-gated)\n", .{PORT}); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{conn.stream}); + t.detach(); + } +} + +// ───────────────────────── tests ───────────────────────── + +test "classify routes each protocol to one surface" { + try std.testing.expectEqual(Protocol.rest, classify("/invoke")); + try std.testing.expectEqual(Protocol.sse, classify("/sse")); + try std.testing.expectEqual(Protocol.graphql, classify("/graphql")); + try std.testing.expectEqual(Protocol.grpc, classify("/grpc/K9/k9_generate")); + try std.testing.expectEqual(Protocol.unknown, classify("/nope")); +} + +test "toolFor extracts across protocols" { + try std.testing.expectEqualStrings("k9_generate", toolFor(.grpc, "/grpc/K9/k9_generate", "").?); + try std.testing.expectEqualStrings("k9_apply", toolFor(.rest, "/invoke?tool=k9_apply", "").?); + try std.testing.expectEqualStrings("k9_validate", toolFor(.sse, "/sse", "{\"tool\":\"k9_validate\"}").?); + try std.testing.expectEqualStrings("k9_clean", toolFor(.graphql, "/graphql", "{query: invoke(tool:\"k9_clean\")}").?); + try std.testing.expect(toolFor(.rest, "/invoke", "{}") == null); +} + +test "dispatch funnels into the one Zig ABI" { + var out: [256]u8 = undefined; + const d = dispatch("k9_generate", "{}", &out); + try std.testing.expectEqual(@as(u16, 200), d.status); + try std.testing.expect(std.mem.indexOf(u8, d.body, "result") != null); + try std.testing.expectEqual(@as(u16, 404), dispatch("nope", "{}", &out).status); +} + +// Transaction-gate truth table — must match Idris2 +// K9iserMcp.SafeK9iser.exposureSatisfied exactly. +test "exposureSatisfied mirrors the Idris2 contract" { + // local caller: always permitted regardless of required/presented + try std.testing.expect(exposureSatisfied(.internal, .public, true)); + // public requirement: any presented level passes + try std.testing.expect(exposureSatisfied(.public, .public, false)); + // authenticated requirement + try std.testing.expect(!exposureSatisfied(.authenticated, .public, false)); + try std.testing.expect(exposureSatisfied(.authenticated, .authenticated, false)); + try std.testing.expect(exposureSatisfied(.authenticated, .internal, false)); + // internal requirement + try std.testing.expect(!exposureSatisfied(.internal, .authenticated, false)); + try std.testing.expect(exposureSatisfied(.internal, .internal, false)); +} + +test "presentedExposure parses X-Trust-Level (case-insensitive)" { + const req = "POST /invoke HTTP/1.1\r\nHost: x\r\nX-Trust-Level: Internal\r\n\r\n{}"; + try std.testing.expectEqual(Exposure.internal, presentedExposure(req)); + try std.testing.expectEqual(Exposure.public, presentedExposure("POST / HTTP/1.1\r\n\r\n")); +} diff --git a/cartridges/k9iser-mcp/cartridge.json b/cartridges/k9iser-mcp/cartridge.json new file mode 100644 index 00000000..e97baea6 --- /dev/null +++ b/cartridges/k9iser-mcp/cartridge.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "PMPL-1.0-or-later", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "name": "k9iser-mcp", + "version": "0.1.0", + "description": "Wrap configs into self-validating K9 contracts (k9iser generate/validate/apply)", + "domain": "config", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://k9iser-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "k9_load_manifest", + "description": "Load a k9iser.toml manifest for a repository working tree", + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "owner/name of the repository" + }, + "manifest": { + "type": "string", + "description": "Path to the k9iser.toml manifest (default: k9iser.toml)" + } + }, + "required": [ + "repo" + ] + } + }, + { + "name": "k9_generate", + "description": "Generate K9 contract files from configs + manifest rules", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "output": { + "type": "string", + "description": "Output directory (default: generated/k9iser)" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "k9_validate", + "description": "Validate generated contracts against the canonical k9-validate ruleset (K9! magic + pedigree)", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "k9_apply", + "description": "Commit + push the regenerated contracts back to the repository branch", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "branch": { + "type": "string", + "description": "Target branch" + } + }, + "required": [ + "session_id", + "branch" + ] + } + }, + { + "name": "k9_clean", + "description": "Clean the session and any working artifacts", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libk9iser_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/k9iser-mcp/ffi/README.adoc b/cartridges/k9iser-mcp/ffi/README.adoc new file mode 100644 index 00000000..d95fa526 --- /dev/null +++ b/cartridges/k9iser-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += k9iser-mcp / ffi — Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph — produces `zig-out/lib/libssg.so`. +| `k9iser_ffi.zig` | C-ABI exports (13 exports, 7 inline tests, 290 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** — every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `k9iser_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `k9iser_ffi.zig` — C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/k9iser-mcp/ffi/build.zig b/cartridges/k9iser-mcp/ffi/build.zig new file mode 100644 index 00000000..61f1554a --- /dev/null +++ b/cartridges/k9iser-mcp/ffi/build.zig @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// k9iser-mcp Cartridge — Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const k9_mod = b.addModule("k9iser_ffi", .{ + .root_source_file = b.path("k9iser_ffi.zig"), + .target = target, + .optimize = optimize, + }); + k9_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const k9_tests = b.addTest(.{ + .root_module = k9_mod, + }); + + const run_tests = b.addRunArtifact(k9_tests); + + const test_step = b.step("test", "Run k9iser-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("k9iser_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "k9iser_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/k9iser-mcp/ffi/cartridge_shim.zig b/cartridges/k9iser-mcp/ffi/cartridge_shim.zig new file mode 100644 index 00000000..2aec93cb --- /dev/null +++ b/cartridges/k9iser-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig — Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short — typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 §Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body — the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` — the caller is then expected to re-allocate +/// and retry, per ADR-0006 §Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/k9iser-mcp/ffi/k9iser_ffi.zig b/cartridges/k9iser-mcp/ffi/k9iser_ffi.zig new file mode 100644 index 00000000..e7e2ef9b --- /dev/null +++ b/cartridges/k9iser-mcp/ffi/k9iser_ffi.zig @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// k9iser-mcp Cartridge — Zig FFI bridge for the K9-contract regeneration +// pipeline. +// +// Implements the pipeline state machine from SafeK9iser.idr. Ensures no +// contract set can be applied (committed back to a repo) without first +// passing validation, and none can be validated without being generated. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match K9iserMcp.SafeK9iser encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const K9State = enum(c_int) { + empty = 0, + manifest_loaded = 1, + generated = 2, + validated = 3, + applied = 4, + k9_error = 5, +}; + +pub const K9Format = enum(c_int) { + toml = 1, + yaml = 2, + json = 3, + ini = 4, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Regeneration Pipeline State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; + +const SessionSlot = struct { + active: bool, + format: K9Format, + state: K9State, + manifest_hash: u32, // Hash of the manifest for cache invalidation +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{ + .active = false, + .format = .toml, + .state = .empty, + .manifest_hash = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: K9State, to: K9State) bool { + return switch (from) { + .empty => to == .manifest_loaded, + .manifest_loaded => to == .generated or to == .k9_error, + .generated => to == .generated or to == .validated or to == .k9_error, + .validated => to == .applied or to == .empty, + .applied => to == .empty, + .k9_error => to == .empty, + }; +} + +/// Load a manifest into a new session. Returns slot index or -1 on failure. +pub export fn k9_load_manifest(format: c_int, manifest_hash: u32) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.format = @enumFromInt(format); + slot.state = .manifest_loaded; + slot.manifest_hash = manifest_hash; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Generate K9 contracts from the loaded manifest + configs. +pub export fn k9_generate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .generated)) return -2; + + sessions[idx].state = .generated; + return 0; +} + +/// Validate generated contracts. REQUIRES state to be Generated. +pub export fn k9_validate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + // SAFETY: Must be in Generated state — cannot validate absent output + if (sessions[idx].state != .generated) return -2; + if (!isValidTransition(sessions[idx].state, .validated)) return -2; + + sessions[idx].state = .validated; + return 0; +} + +/// Apply (commit + push) the regenerated contracts. REQUIRES Validated. +/// Key safety invariant: contracts that failed validation can never be +/// pushed back to a repository. +pub export fn k9_apply(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + // SAFETY: Must be in Validated state — cannot skip validation + if (sessions[idx].state != .validated) return -2; + if (!isValidTransition(sessions[idx].state, .applied)) return -2; + + sessions[idx].state = .applied; + return 0; +} + +/// Mark a generation/parse error. +pub export fn k9_mark_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .k9_error)) return -2; + + sessions[idx].state = .k9_error; + return 0; +} + +/// Clean the session and reset to Empty. +pub export fn k9_clean(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .empty)) return -2; + + sessions[idx].active = false; + sessions[idx].state = .empty; + sessions[idx].manifest_hash = 0; + return 0; +} + +/// Get the state of a session. +pub export fn k9_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(K9State.empty); + return @intFromEnum(sessions[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn k9_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: K9State = @enumFromInt(from); + const t: K9State = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sessions (for testing). +pub export fn k9_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.active = false; + slot.state = .empty; + slot.manifest_hash = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the k9iser-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_init() c_int { + k9_reset(); + return 0; +} + +/// Deinitialise the k9iser-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_deinit() void { + k9_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "k9iser-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha — each arm +/// returns a stub JSON body shaped to the tool's intended response. +pub export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "k9_load_manifest")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_generate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_validate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_apply")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_clean")) + "{\"result\":{\"status\":\"stub\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "load manifest and clean blocked before validate" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.toml), 0xABCD); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.manifest_loaded)), k9_state(slot)); + // Cannot clean from manifest_loaded (must generate+validate or error first) + try std.testing.expectEqual(@as(c_int, -2), k9_clean(slot)); +} + +test "cannot apply without validate" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.yaml), 0x1234); + _ = k9_generate(slot); + // Attempt to apply directly from generated — MUST fail + try std.testing.expectEqual(@as(c_int, -2), k9_apply(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.generated)), k9_state(slot)); +} + +test "cannot validate without generate" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.json), 0x5678); + // Attempt to validate from manifest_loaded — MUST fail + try std.testing.expectEqual(@as(c_int, -2), k9_validate(slot)); +} + +test "full pipeline: load -> generate -> validate -> apply -> clean" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.toml), 0xCAFE); + try std.testing.expectEqual(@as(c_int, 0), k9_generate(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.generated)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_validate(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.validated)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_apply(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.applied)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_clean(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.empty)), k9_state(slot)); +} + +test "regenerate allowed" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.toml), 0xAAAA); + try std.testing.expectEqual(@as(c_int, 0), k9_generate(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_generate(slot)); // regenerate + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.generated)), k9_state(slot)); +} + +test "error then recover" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.ini), 0xBBBB); + _ = k9_generate(slot); + try std.testing.expectEqual(@as(c_int, 0), k9_mark_error(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.k9_error)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_clean(slot)); // recover + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.empty)), k9_state(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(0, 1)); // empty -> manifest_loaded + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(1, 2)); // manifest_loaded -> generated + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(2, 3)); // generated -> validated + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(3, 4)); // validated -> applied + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(4, 0)); // applied -> empty + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(2, 2)); // regenerate + // Invalid transitions — the key safety invariants + try std.testing.expectEqual(@as(c_int, 0), k9_can_transition(1, 3)); // manifest_loaded -> validated (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), k9_can_transition(2, 4)); // generated -> applied (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), k9_can_transition(0, 4)); // empty -> applied +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "k9_load_manifest", + "k9_generate", + "k9_validate", + "k9_apply", + "k9_clean", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("k9_load_manifest", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/k9iser-mcp/mod.js b/cartridges/k9iser-mcp/mod.js new file mode 100644 index 00000000..5bf5f4d8 --- /dev/null +++ b/cartridges/k9iser-mcp/mod.js @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// k9iser-mcp/mod.js — Wrap configs into self-validating K9 contracts. +// +// Delegates to backend at http://127.0.0.1:7743 (override with K9ISER_BACKEND_URL). +// The backend runs the `k9iser` binary against a checked-out repo working +// tree: load_manifest -> generate -> validate -> apply (commit+push). + +const BASE_URL = Deno.env.get("K9ISER_BACKEND_URL") ?? "http://127.0.0.1:7743"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "k9iser-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `k9iser-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "k9_load_manifest": + return post("/api/v1/k9_load_manifest", args ?? {}); + case "k9_generate": + return post("/api/v1/k9_generate", args ?? {}); + case "k9_validate": + return post("/api/v1/k9_validate", args ?? {}); + case "k9_apply": + return post("/api/v1/k9_apply", args ?? {}); + case "k9_clean": + return post("/api/v1/k9_clean", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/k9iser-mcp/panels/manifest.json b/cartridges/k9iser-mcp/panels/manifest.json new file mode 100644 index 00000000..96a2cb0b --- /dev/null +++ b/cartridges/k9iser-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "PMPL-1.0-or-later", + "cartridge": "k9iser-mcp", + "domain": "K9 Contract Regeneration", + "version": "0.1.0", + "panels": [ + { + "id": "k9-status", + "title": "k9iser Engine Status", + "description": "Contract regeneration backend readiness", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/k9iser-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "shield-check" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "k9-sessions", + "title": "Regeneration Sessions", + "description": "Repos whose K9 contracts are being regenerated and their pipeline state", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/k9iser-mcp/invoke", + "method": "POST", + "body": { "tool": "sessions_summary" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "total_sessions", "label": "Active Sessions", "icon": "git-branch" }, + { "type": "counter", "field": "validated", "label": "Validated", "icon": "check-circle" }, + { "type": "counter", "field": "failed", "label": "Validation Failed", "icon": "x-circle" } + ] + }, + { + "id": "k9-contract-metrics", + "title": "Contract Metrics", + "description": "Generated contract counts and last regeneration outcome", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/k9iser-mcp/invoke", + "method": "POST", + "body": { "tool": "contract_metrics" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "total_contracts", "label": "Total Contracts", "icon": "file-text" }, + { "type": "text", "field": "last_regen", "label": "Last Regeneration" }, + { "type": "text", "field": "last_outcome", "label": "Outcome" } + ] + } + ] +} diff --git a/docs/OPERATOR-QUICKSTART.md b/docs/OPERATOR-QUICKSTART.md index 04cd5d45..bef6bd3f 100644 --- a/docs/OPERATOR-QUICKSTART.md +++ b/docs/OPERATOR-QUICKSTART.md @@ -9,9 +9,19 @@ Total setup time: under 5 minutes. - **Podman 4.0+** (never Docker) - **IPv6 connectivity** (dual-stack preferred; IPv4-only is not supported) - **UDP port 9999** open inbound (federation gossip) -- **TCP port 7700** open inbound (REST health/status) +- **TCP port 7700** open inbound (REST/SSE health/status) - Ports 7701 (gRPC) and 7702 (GraphQL) are optional depending on your use case +> **Architecture note (ADR-0004).** 7700/7701/7702 are protocol *facets* +> of one internally-unified, transaction-gated core — not independent +> dispatchers. The only *governed public ingress* is the +> `http-capability-gateway` (tier-2: verb governance, rate-limit, +> trust-level derivation) which proxies inward to the core. Cartridge +> adapters are internal/loopback and never exposed directly. Until the +> gateway is production-wired (a tracked programme — ADR-0004 estimates +> 8–12 weeks), expose this node only behind the documented Cloudflare +> edge (tier-1) / hardened quadlet, not raw. + Verify Podman: ```bash diff --git a/elixir/README.adoc b/elixir/README.adoc index b8617306..8af50c34 100644 --- a/elixir/README.adoc +++ b/elixir/README.adoc @@ -1,31 +1,39 @@ // SPDX-License-Identifier: PMPL-1.0-or-later // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -= BoJ Server — Elixir REST skeleton += BoJ Server — Elixir REST core :orientation: shallow == Purpose -Serves the five HTTP endpoints `mcp-bridge/lib/api-clients.js` calls, on -port **7700** by default. This is a **skeleton**, not the finished REST -server: +The gated internal REST/SSE core that fronts the unified Zig ABI, on port +**7700** by default. It is the Elixir "scalability multiplier" over the +Idris2-logic / pure-Zig-interface stack (see `docs/ARCHITECTURE.md`, +v1.1.0). It is NOT a public ingress — per ADR-0004 the only governed +public surface is the `http-capability-gateway` (tier-2) in front of this +core. This listener binds the trusted/internal interface and every +invocation passes the `TrustPolicy` + `CredentialDecryptor` transaction +gate before dispatch. -* `GET /health` — returns `{"status": "ok", "mode": "skeleton", ...}` with - live cartridge-load count. -* `GET /menu` — summarises each cartridge (name / domain / tier / - description). +Endpoints: + +* `GET /health` — `{"status":"ok","version":...,"cartridges_loaded":N}`. +* `GET /menu` — per-cartridge name / domain / tier / description. * `GET /cartridges` — cartridge-name list. -* `GET /cartridge/:name` — full `cartridge.json` for that cartridge, or - 404 if unknown. -* `POST /cartridge/:name/invoke` — **returns 501** with - `{"error": "invocation-not-yet-wired"}`. Dispatch to the Zig FFI is - Phase 2. +* `GET /cartridge/:name` — full `cartridge.json`, or 404 if unknown. +* `POST /cartridge/:name/invoke` — **wired.** Trust-gated, then dispatched + via `BojRest.Invoker` (shells `boj-invoke` → `dlopen`s the cartridge + `.so` → ADR-0006 `boj_cartridge_invoke`) for `ffi` cartridges, or the + Deno `JsWorkerPool` fallback for no-`ffi` cartridges. Returns `200` with + the cartridge's JSON result. +* `POST /cartridge/:name/sse` — same gated dispatch, streamed as + `text/event-stream` (`open` → `result`|`error` → `done`). Makes the + surface four-protocol (REST + SSE here; gRPC-compat + GraphQL via the + per-cartridge internal Zig adapter). == Relationship to the stdio bridge The Node.js stdio bridge at `mcp-bridge/` is what Claude Code and Glama -talk to. When the REST server behind it was missing, `boj_health` and -`boj_research` returned misleading errors pointing at a nonexistent -listener. This skeleton closes that gap: +talk to. This core is the REST server behind it: [cols="1,3"] |=== @@ -35,7 +43,8 @@ listener. This skeleton closes that gap: | `/menu` | `boj_menu` | `/cartridges` | `boj_cartridges` | `/cartridge/:name` | `boj_cartridge_info` -| `/cartridge/:name/invoke` | `boj_cartridge_invoke` (501 until Phase 2) +| `/cartridge/:name/invoke` | `boj_cartridge_invoke` (live — Zig ABI) +| `/cartridge/:name/sse` | streaming variant of the above |=== == Build @@ -45,7 +54,14 @@ listener. This skeleton closes that gap: cd elixir mix deps.get mix compile -mix test # five endpoint tests +mix test # full suite (router, invoker, catalog, trust, …) +---- + +The Zig dispatch CLI must be built once so the FFI path is live: + +[source,bash] +---- +cd ../ffi/zig && zig build invoke # produces zig-out/bin/boj-invoke ---- == Run @@ -54,38 +70,40 @@ mix test # five endpoint tests ---- cd elixir mix run --no-halt -# listener binds http://localhost:7700 +# listener binds http://127.0.0.1:7700 (internal; front with the gateway) ---- Configure via env vars: * `BOJ_PORT` — listener port (default `7700`). -* `BOJ_CARTRIDGES_ROOT` — where to scan for `cartridge.json` (default: - `../cartridges` relative to the Elixir project). +* `BOJ_CARTRIDGES_ROOT` — where to scan for `cartridge.json` + (default: `../cartridges` relative to the Elixir project). +* `BOJ_INVOKE_CLI` — path to the `boj-invoke` Zig CLI (else auto-probed). == Design notes -* **Cowboy + Plug** — matches the http-capability-gateway decision - (`docs/decisions/0004-adopt-http-capability-gateway.md`), so this - listener is a natural attachment point for that gateway's verb-governance - and trust-level plugs in a later phase. -* **ETS-backed catalog, read-only.** The cartridge catalog is loaded once - at boot and never mutated. Re-scanning requires a restart. This matches - the current mount lifecycle (`cartridge.json` changes are already - restart-gated). +* **Cowboy + Plug, single listener.** One supervised listener, routed + internally (REST + SSE); NOT N parallel HTTP servers. The + `http-capability-gateway` (ADR-0004) attaches in front as tier-2. +* **Transaction-gated dispatch.** `TrustPolicy` derives required exposure + from the cartridge's `auth.method`; loopback callers are locally + trusted; `CredentialDecryptor` runs before any dispatch. No + gatekeeperless path to the ABI. +* **ETS-backed catalog, read-only.** Loaded once at boot; re-scan needs a + restart (matches the restart-gated `cartridge.json` mount lifecycle). * **Best-effort load.** A malformed `cartridge.json` is logged and skipped; the listener still comes up with whatever parsed cleanly. -* **Skeleton only.** Invocation routing, federation, auth, rate-limiting, - TLS — all out of scope for this file. Those land with the Elixir port - phases declared in the dogfood log. == Phase plan -. **Phase 1 — this.** REST listener serves cartridge metadata. `boj_health` - stops lying. `boj_research` still returns 501 but with a structured, - explainable error. **DONE 2026-04-18.** -. **Phase 2.** Wire `POST /cartridge/:name/invoke` into the Zig FFI via - the loader (`ffi/zig/src/loader.zig`). `boj_cartridge_invoke` becomes - live. -. **Phase 3.** Attach http-capability-gateway per ADR-0004 at tier 2. -. **Phase 4.** mTLS / SDP (see `Trustfile.a2ml` `[SDP_RULES]`). +. **Phase 1 — REST metadata.** Listener serves cartridge metadata. + **DONE 2026-04-18.** +. **Phase 2 — invocation wired.** `POST /cartridge/:name/invoke` (and + `/sse`) dispatch through the unified Zig ABI via `boj-invoke`, trust- + gated. **DONE.** +. **Phase 3 — gateway.** Attach `http-capability-gateway` at tier-2 per + ADR-0004 (verb governance, rate-limit tier-2, trust-level primary path). + *Accepted; production-wiring is a tracked programme — ADR-0004 estimates + 8–12 weeks (mTLS-as-primary, E2E, benchmarks). Until then this core + stays internal/loopback-bound.* +. **Phase 4 — mTLS / SDP.** Per `Trustfile.a2ml` `[SDP_RULES]`. diff --git a/elixir/lib/boj_rest/router.ex b/elixir/lib/boj_rest/router.ex index 4dbbb379..f744cdd2 100644 --- a/elixir/lib/boj_rest/router.ex +++ b/elixir/lib/boj_rest/router.ex @@ -122,10 +122,70 @@ defmodule BojRest.Router do end end + # Server-Sent Events surface for the same unified dispatch. The cartridge + # ABI is request/response (one boj_cartridge_invoke buffer), so the stream + # is: `open` → `result`|`error` → `done`. This makes the unified surface + # genuinely four-protocol (REST + SSE here; gRPC-compat + GraphQL in the + # per-cartridge Zig adapter). + post "/cartridge/:name/sse" do + case BojRest.Catalog.get(name) do + {:ok, cart} -> + body = conn.body_params || %{} + tool = Map.get(body, "tool") || Map.get(body, "operation") + args = Map.get(body, "arguments") || Map.get(body, "params") || %{} + is_local = loopback?(conn.remote_ip) + trust_level = conn |> get_req_header("x-trust-level") |> List.first() + node_id = conn |> get_req_header("x-node-identity") |> List.first() + + if node_id, do: Logger.info("BoJ SSE caller=#{node_id} cart=#{name} tool=#{inspect(tool)}") + + cond do + is_nil(tool) -> + json(conn, 400, %{error: "missing-tool-field", cartridge: name}) + + true -> + with :ok <- check_trust(cart, trust_level, is_local), + {:ok, creds} <- BojRest.CredentialDecryptor.extract(body, is_local) do + conn = + conn + |> Plug.Conn.put_resp_content_type("text/event-stream") + |> Plug.Conn.put_resp_header("cache-control", "no-cache") + |> Plug.Conn.send_chunked(200) + + {:ok, conn} = Plug.Conn.chunk(conn, sse_event("open", %{cartridge: name, tool: tool})) + + event = + case dispatch(cart, tool, args, creds) do + {:ok, data} -> sse_event("result", data) + {:error, info} -> sse_event("error", %{error: "invocation-failed", info: info}) + end + + {:ok, conn} = Plug.Conn.chunk(conn, event) + {:ok, conn} = Plug.Conn.chunk(conn, sse_event("done", %{})) + conn + else + {:error, :forbidden, required} -> + json(conn, 403, %{error: "forbidden", detail: "insufficient-trust", required: to_string(required)}) + + {:error, reason} -> + json(conn, 403, %{error: "credential-error", detail: reason}) + end + end + + :not_found -> + json(conn, 404, %{error: "unknown-cartridge", cartridge: name}) + end + end + match _ do json(conn, 404, %{error: "route-not-found", path: conn.request_path}) end + # One SSE frame: `event: \ndata: \n\n` (text/event-stream). + defp sse_event(event, data) do + "event: #{event}\ndata: #{Jason.encode!(data)}\n\n" + end + defp json(conn, status, body) do conn |> Plug.Conn.put_resp_content_type("application/json") diff --git a/elixir/test/router_test.exs b/elixir/test/router_test.exs index 07a93de2..0d790bc8 100644 --- a/elixir/test/router_test.exs +++ b/elixir/test/router_test.exs @@ -449,4 +449,26 @@ defmodule BojRest.RouterTest do assert conn.status in [200, 500] end + + test "POST /cartridge/:name/sse streams text/event-stream open→…→done" do + conn = + conn(:post, "/cartridge/#{@public_cart}/sse", Jason.encode!(%{tool: "boj_health_status"})) + |> put_req_header("content-type", "application/json") + |> BojRest.Router.call(@opts) + + assert conn.status == 200 + assert {"content-type", "text/event-stream; charset=utf-8"} in conn.resp_headers + assert conn.resp_body =~ "event: open" + assert conn.resp_body =~ "event: done" + end + + test "POST /cartridge/:name/sse without tool is 400 (not a stream)" do + conn = + conn(:post, "/cartridge/#{@public_cart}/sse", Jason.encode!(%{})) + |> put_req_header("content-type", "application/json") + |> BojRest.Router.call(@opts) + + assert conn.status == 400 + assert Jason.decode!(conn.resp_body)["error"] == "missing-tool-field" + end end diff --git a/mcp-bridge/lib/api-clients.js b/mcp-bridge/lib/api-clients.js index 99fd1d0a..cc452a62 100644 --- a/mcp-bridge/lib/api-clients.js +++ b/mcp-bridge/lib/api-clients.js @@ -106,9 +106,9 @@ async function invokeCartridge(name, params) { return await res.json(); } catch { return { - error: "rest-unavailable", + error: "rest-unreachable", cartridge: name, - message: "Cartridge invocation requires the BoJ REST server, which is not currently deployed (pending Elixir rewrite). The stdio MCP bridge cannot route /cartridge/" + name + "/invoke on its own.", + message: "Could not reach the BoJ REST core at " + BOJ_BASE + " to route /cartridge/" + name + "/invoke. The core is implemented (Elixir REST/SSE → unified Zig ABI; see elixir/README.adoc and docs/ARCHITECTURE.md) — this means it is not running/reachable here, or the http-capability-gateway (ADR-0004 tier-2) is not yet wired. The stdio MCP bridge cannot dispatch on its own.", base_url_probed: BOJ_BASE, }; }