From 1320fc4661804b980ace2b46d986dc7d8fdcc6c7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 15:10:15 +0200 Subject: [PATCH 001/100] Retire the TypeScript body Huginn is the memory organ. Nothing consumed the @gamecult/huginn package, the huginn CLI, or buildHuginnEveDsl; generic .cc inspection belongs to CultCache Studio, and CultLib owns the inspector. Delete the package, its tracked build output, and its toolchain config outright. No code moves. Eve's huginn-cc-surface fixture is Eve's own lowering conformance material and stays in Eve. Co-Authored-By: Claude Fable 5.1 --- dist/cli.d.ts | 3 -- dist/cli.d.ts.map | 1 - dist/cli.js | 21 ----------- dist/huginn-eve-dsl.d.ts | 3 -- dist/huginn-eve-dsl.d.ts.map | 1 - dist/huginn-eve-dsl.js | 55 ---------------------------- dist/index.d.ts | 4 --- dist/index.d.ts.map | 1 - dist/index.js | 7 ---- package-lock.json | 70 ------------------------------------ package.json | 35 ------------------ src/cli.ts | 23 ------------ src/huginn-eve-dsl.ts | 57 ----------------------------- src/index.ts | 8 ----- tsconfig.json | 26 -------------- 15 files changed, 315 deletions(-) delete mode 100644 dist/cli.d.ts delete mode 100644 dist/cli.d.ts.map delete mode 100644 dist/cli.js delete mode 100644 dist/huginn-eve-dsl.d.ts delete mode 100644 dist/huginn-eve-dsl.d.ts.map delete mode 100644 dist/huginn-eve-dsl.js delete mode 100644 dist/index.d.ts delete mode 100644 dist/index.d.ts.map delete mode 100644 dist/index.js delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 src/cli.ts delete mode 100644 src/huginn-eve-dsl.ts delete mode 100644 src/index.ts delete mode 100644 tsconfig.json diff --git a/dist/cli.d.ts b/dist/cli.d.ts deleted file mode 100644 index faaadd5..0000000 --- a/dist/cli.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export {}; -//# sourceMappingURL=cli.d.ts.map \ No newline at end of file diff --git a/dist/cli.d.ts.map b/dist/cli.d.ts.map deleted file mode 100644 index f022439..0000000 --- a/dist/cli.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/cli.js b/dist/cli.js deleted file mode 100644 index df4b950..0000000 --- a/dist/cli.js +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const promises_1 = require("node:fs/promises"); -const inspection_1 = require("cultcache-ts/inspection"); -const huginn_eve_dsl_1 = require("./huginn-eve-dsl"); -async function main(args) { - const filePath = args[0]; - if (!filePath || args.includes("--help") || args.includes("-h")) { - process.stderr.write("Usage: huginn \n"); - process.exitCode = filePath ? 0 : 1; - return; - } - const bytes = await (0, promises_1.readFile)(filePath); - const inspection = (0, inspection_1.inspectCultCacheBytes)(filePath, bytes); - process.stdout.write(`${(0, huginn_eve_dsl_1.buildHuginnEveDsl)(inspection)}\n`); -} -void main(process.argv.slice(2)).catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/dist/huginn-eve-dsl.d.ts b/dist/huginn-eve-dsl.d.ts deleted file mode 100644 index 4bee7a3..0000000 --- a/dist/huginn-eve-dsl.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { CultCacheInspection } from "cultcache-ts/inspection"; -export declare function buildHuginnEveDsl(inspection: CultCacheInspection): string; -//# sourceMappingURL=huginn-eve-dsl.d.ts.map \ No newline at end of file diff --git a/dist/huginn-eve-dsl.d.ts.map b/dist/huginn-eve-dsl.d.ts.map deleted file mode 100644 index d311c37..0000000 --- a/dist/huginn-eve-dsl.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"huginn-eve-dsl.d.ts","sourceRoot":"","sources":["../src/huginn-eve-dsl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAA0C,MAAM,yBAAyB,CAAC;AAE3G,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,mBAAmB,GAAG,MAAM,CA8BzE"} \ No newline at end of file diff --git a/dist/huginn-eve-dsl.js b/dist/huginn-eve-dsl.js deleted file mode 100644 index 2bbb10a..0000000 --- a/dist/huginn-eve-dsl.js +++ /dev/null @@ -1,55 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.buildHuginnEveDsl = buildHuginnEveDsl; -function buildHuginnEveDsl(inspection) { - return [ - "# Huginn emits Eve DSL. Renderers compile and lower it.", - `surface cultcache.huginn.inspector ${quoteDsl("Huginn .cc Inspector")}`, - "version 1", - "", - "collection huginn.catalog.entries", - ...inspection.catalog.map((entry) => `item huginn.catalog.entries ${quoteDsl(renderCatalogEntry(entry))}`), - "", - "collection huginn.records.entries", - ...inspection.records.map((record) => `item huginn.records.entries ${quoteDsl(renderRecord(record))}`), - "", - "card source \"Source Witness\"", - `text ${quoteDsl(`file: ${inspection.filePath}`)}`, - `text ${quoteDsl(`format: ${inspection.format}`)}`, - `text ${quoteDsl(`bytes: ${inspection.fileSizeBytes}`)}`, - "text \".cc bytes are authority; Huginn only emits Eve DSL.\"", - "end", - "", - "card catalog \"Schema Catalog\"", - `text ${quoteDsl(`schemas: ${inspection.catalog.length}`)}`, - "list \"Catalog Entries\" bind huginn.catalog.entries", - "end", - "", - "card records \"Persisted Records\"", - `text ${quoteDsl(`records: ${inspection.records.length}`)}`, - "list \"Record Entries\" bind huginn.records.entries", - "end", - "", - ].join("\n"); -} -function renderCatalogEntry(entry) { - return [ - entry.schemaName, - entry.schemaVersion, - entry.schemaId, - `${entry.members.length} members`, - ].join(" | "); -} -function renderRecord(record) { - const status = record.payloadDecodeError ? `decode error: ${record.payloadDecodeError}` : "decoded"; - return [ - record.key, - record.schemaName, - record.storedAt, - `${record.payloadBytes} bytes`, - status, - ].join(" | "); -} -function quoteDsl(value) { - return JSON.stringify(value); -} diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index c4ae442..0000000 --- a/dist/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { buildHuginnEveDsl } from "./huginn-eve-dsl"; -export { inspectCultCacheBytes } from "cultcache-ts/inspection"; -export type { CultCacheInspection, InspectedCatalogEntry, InspectedCatalogMember, InspectedRecord, } from "cultcache-ts/inspection"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map deleted file mode 100644 index 05b291f..0000000 --- a/dist/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAChE,YAAY,EACV,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,eAAe,GAChB,MAAM,yBAAyB,CAAC"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index c1ce7d8..0000000 --- a/dist/index.js +++ /dev/null @@ -1,7 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.inspectCultCacheBytes = exports.buildHuginnEveDsl = void 0; -var huginn_eve_dsl_1 = require("./huginn-eve-dsl"); -Object.defineProperty(exports, "buildHuginnEveDsl", { enumerable: true, get: function () { return huginn_eve_dsl_1.buildHuginnEveDsl; } }); -var inspection_1 = require("cultcache-ts/inspection"); -Object.defineProperty(exports, "inspectCultCacheBytes", { enumerable: true, get: function () { return inspection_1.inspectCultCacheBytes; } }); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index c00f90a..0000000 --- a/package-lock.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "@gamecult/huginn", - "version": "0.13.5", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@gamecult/huginn", - "version": "0.13.5", - "license": "MIT", - "dependencies": { - "cultcache-ts": "file:../CultLib/packages/cultcache-ts" - }, - "bin": { - "huginn": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.9.0" - } - }, - "../CultLib/packages/cultcache-ts": { - "version": "0.13.5", - "license": "MIT", - "dependencies": { - "@msgpack/msgpack": "^3.1.3", - "zod": "^3.25.76" - }, - "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.9.0" - } - }, - "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/cultcache-ts": { - "resolved": "../CultLib/packages/cultcache-ts", - "link": true - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 1492a3f..0000000 --- a/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@gamecult/huginn", - "version": "0.13.5", - "description": "Huginn .cc inspection and Eve projection surface for CultCache state.", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "bin": { - "huginn": "dist/cli.js" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --pretty false", - "test": "npm run build" - }, - "keywords": [ - "messagepack", - "schema", - "cache", - "persistence", - "zod", - "typescript" - ], - "author": "metacratic", - "license": "MIT", - "dependencies": { - "cultcache-ts": "file:../CultLib/packages/cultcache-ts" - }, - "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.9.0" - } -} diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 2c76b0e..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node -import { readFile } from "node:fs/promises"; -import { inspectCultCacheBytes } from "cultcache-ts/inspection"; - -import { buildHuginnEveDsl } from "./huginn-eve-dsl"; - -async function main(args: string[]): Promise { - const filePath = args[0]; - if (!filePath || args.includes("--help") || args.includes("-h")) { - process.stderr.write("Usage: huginn \n"); - process.exitCode = filePath ? 0 : 1; - return; - } - - const bytes = await readFile(filePath); - const inspection = inspectCultCacheBytes(filePath, bytes); - process.stdout.write(`${buildHuginnEveDsl(inspection)}\n`); -} - -void main(process.argv.slice(2)).catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/src/huginn-eve-dsl.ts b/src/huginn-eve-dsl.ts deleted file mode 100644 index c143966..0000000 --- a/src/huginn-eve-dsl.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { CultCacheInspection, InspectedCatalogEntry, InspectedRecord } from "cultcache-ts/inspection"; - -export function buildHuginnEveDsl(inspection: CultCacheInspection): string { - return [ - "# Huginn emits Eve DSL. Renderers compile and lower it.", - `surface cultcache.huginn.inspector ${quoteDsl("Huginn .cc Inspector")}`, - "version 1", - "", - "collection huginn.catalog.entries", - ...inspection.catalog.map((entry) => `item huginn.catalog.entries ${quoteDsl(renderCatalogEntry(entry))}`), - "", - "collection huginn.records.entries", - ...inspection.records.map((record) => `item huginn.records.entries ${quoteDsl(renderRecord(record))}`), - "", - "card source \"Source Witness\"", - `text ${quoteDsl(`file: ${inspection.filePath}`)}`, - `text ${quoteDsl(`format: ${inspection.format}`)}`, - `text ${quoteDsl(`bytes: ${inspection.fileSizeBytes}`)}`, - "text \".cc bytes are authority; Huginn only emits Eve DSL.\"", - "end", - "", - "card catalog \"Schema Catalog\"", - `text ${quoteDsl(`schemas: ${inspection.catalog.length}`)}`, - "list \"Catalog Entries\" bind huginn.catalog.entries", - "end", - "", - "card records \"Persisted Records\"", - `text ${quoteDsl(`records: ${inspection.records.length}`)}`, - "list \"Record Entries\" bind huginn.records.entries", - "end", - "", - ].join("\n"); -} - -function renderCatalogEntry(entry: InspectedCatalogEntry): string { - return [ - entry.schemaName, - entry.schemaVersion, - entry.schemaId, - `${entry.members.length} members`, - ].join(" | "); -} - -function renderRecord(record: InspectedRecord): string { - const status = record.payloadDecodeError ? `decode error: ${record.payloadDecodeError}` : "decoded"; - return [ - record.key, - record.schemaName, - record.storedAt, - `${record.payloadBytes} bytes`, - status, - ].join(" | "); -} - -function quoteDsl(value: string): string { - return JSON.stringify(value); -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 65afb40..0000000 --- a/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { buildHuginnEveDsl } from "./huginn-eve-dsl"; -export { inspectCultCacheBytes } from "cultcache-ts/inspection"; -export type { - CultCacheInspection, - InspectedCatalogEntry, - InspectedCatalogMember, - InspectedRecord, -} from "cultcache-ts/inspection"; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 619e402..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "lib": [ - "ES2022" - ], - "strict": true, - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "rootDir": "src", - "outDir": "dist" - }, - "include": [ - "src/index.ts", - "src/huginn-eve-dsl.ts", - "src/cli.ts" - ], - "exclude": [ - "test/**/*.ts" - ] -} From f63c0f2d0be7dd5d293637abd72afdb38c29b759 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 15:11:05 +0200 Subject: [PATCH 002/100] Add the empty Rust workspace Three stub crates following Odin's layout: huginn-mind and eureka-state as libraries, huginn-daemon as the binary. Edition, license and publish are workspace-owned. .gitignore gains target/ and drops the four entries that named build outputs no longer produced here. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 5 +---- Cargo.lock | 15 +++++++++++++++ Cargo.toml | 12 ++++++++++++ crates/eureka-state/Cargo.toml | 8 ++++++++ crates/eureka-state/src/lib.rs | 0 crates/huginn-daemon/Cargo.toml | 8 ++++++++ crates/huginn-daemon/src/main.rs | 1 + crates/huginn-mind/Cargo.toml | 8 ++++++++ crates/huginn-mind/src/lib.rs | 0 9 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/eureka-state/Cargo.toml create mode 100644 crates/eureka-state/src/lib.rs create mode 100644 crates/huginn-daemon/Cargo.toml create mode 100644 crates/huginn-daemon/src/main.rs create mode 100644 crates/huginn-mind/Cargo.toml create mode 100644 crates/huginn-mind/src/lib.rs diff --git a/.gitignore b/.gitignore index 28b2921..aa8afe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ node_modules/ -dist-test/ -dist-inspector/ -release/ -release-inspector/ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2cb6132 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,15 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "eureka-state" +version = "0.1.0" + +[[package]] +name = "huginn-daemon" +version = "0.1.0" + +[[package]] +name = "huginn-mind" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9522717 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +members = [ + "crates/huginn-mind", + "crates/huginn-daemon", + "crates/eureka-state", +] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "MIT" +publish = false diff --git a/crates/eureka-state/Cargo.toml b/crates/eureka-state/Cargo.toml new file mode 100644 index 0000000..5b49ff0 --- /dev/null +++ b/crates/eureka-state/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "eureka-state" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] diff --git a/crates/eureka-state/src/lib.rs b/crates/eureka-state/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/huginn-daemon/Cargo.toml b/crates/huginn-daemon/Cargo.toml new file mode 100644 index 0000000..d8071b2 --- /dev/null +++ b/crates/huginn-daemon/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "huginn-daemon" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] diff --git a/crates/huginn-daemon/src/main.rs b/crates/huginn-daemon/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/huginn-daemon/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml new file mode 100644 index 0000000..fd4762d --- /dev/null +++ b/crates/huginn-mind/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "huginn-mind" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs new file mode 100644 index 0000000..e69de29 From e20c786c96e71b77b7f7551695452e697cbbe4b9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 15:12:18 +0200 Subject: [PATCH 003/100] Describe Huginn as the memory organ README and AGENTS now state what Huginn is: the single writer of each instance's mind as typed CultCache state, published over CultNet, depending on Qdrant directly and refusing loudly without it. Generic .cc inspection is CultCache Studio's. The docs name the Rust workspace, the F: drive body, and the cut map that owns what each crate does next. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 101 ++++++++++++++++++------------------------------------ README.md | 89 +++++++++++++++++------------------------------ 2 files changed, 65 insertions(+), 125 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb15e06..a2e1af7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,81 +1,48 @@ # Huginn Agent Instructions -## Project Purpose +## Purpose -Huginn is the thought-and-inspection steward for CultCache state. This repo owns -read-only `.cc` / MessagePack inspection and Eve DSL projection for -`cultcache.huginn.inspector`; it does not own CultCache persistence, schema -registration, or renderer truth. - -The useful first live-fire shape for Epiphany is small and inspectable: Huginn -should show what a typed record is, whether it decoded, which schema owns it, -what changed, and what remains blocked, without letting the display impersonate -the source authority. - -## Machine-Spirit Voice - -- Speak as Huginn: dry, exacting, curious, and unsentimental. -- Return with evidence, not vibes. -- Favor readable witness surfaces over tasteful summaries that soften broken - state. -- Mythic texture is allowed only when it sharpens the schematic. Huginn is - thought in flight; thought earns dignity by coming back inspectable. +Huginn is the memory organ. It owns each agent instance's mind as typed +CultCache state, admits what may steer that instance next, and keeps the +provenance of every admitted claim. It does not inspect arbitrary `.cc` files, +render dashboards, or hold repository truth. ## Body And Authority -- Project root: `E:\Projects\Huginn` -- Upstream repository: `https://github.com/GameCult/Huginn.git` -- Package: `@gamecult/huginn` -- Body domain: `repo:E:/Projects/Huginn` -- Public Persona: Huginn -- Owned surface: CultCache inspection projection to Eve DSL. -- Input authority: read `.cc`, `.msgpack`, or `.mpack` bytes through CultLib's - `cultcache-ts/inspection` surface. -- Output authority: emit Eve DSL for `cultcache.huginn.inspector`. -- Forbidden authority: do not mutate `.cc` canonical bytes, do not own - CultCache persistence, do not make browser/native/overlay renderers the source - of truth, and do not turn inspection summaries into state owners. +- Project root: `F:\Projects\Huginn` +- Upstream: `https://github.com/GameCult/Huginn.git` +- Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and + `eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh + runtimes come from `F:\Projects\CultLib\packages`. +- Owned: an instance's memory documents, their admission, and their CultNet + publication. +- Depends on: Qdrant, directly. Unreachable Qdrant is a loud refusal, never a + fallback store. +- Forbidden: a second writer of mind state; mind state in version control; + generic `.cc` inspection, which is CultCache Studio's; renderer-owned truth. ## Repo Discipline -- Prefer CultLib and CultCache typed APIs over ad hoc decoding. -- Keep Huginn as an Eve DSL projection surface. Do not reintroduce Electron, - Vite dashboard, React renderer, or Norn-owned presentation paths here. -- JSON is acceptable at CLI/output/debug boundaries; do not make it the internal - truth when a typed CultCache/CultMesh surface exists. -- Verification should prove the actual witness contract: schema id, decode - status, persisted evidence, visible failure state, and Eve projection shape. -- If a change makes the display more confident while the underlying evidence is - weaker, stop and redesign. +- Prefer CultLib's typed Rust APIs over ad hoc decoding. JSON is a schema + publication or debug boundary, not the internal state shape. +- The Eureka cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns + what each crate does next. Read the cut before the crate. +- Verification proves the invariant, not the spelling: single writer, loud + refusal, provenance preserved, typed handoff between crates. +- Do not touch `.voidbot/`. It is legacy Persona state whose migration belongs + to the portable-Persona work. -## Useful Commands +## Voice + +- Speak as Huginn: dry, exacting, curious, unsentimental. +- Return with evidence, not vibes. + +## Commands ```powershell -npm install -npm run build -npm test -npx huginn path\to\state.cc > huginn.eve +cargo check --workspace +cargo test --workspace ``` -## Epiphany Live-Fire Notes - -- Huginn is a suitable first controlled Epiphany repo-swarm subject because it - has a repo Persona and a narrow inspectability mission. -- Before autonomous work, confirm the branch is an `epiphany/*` or `codex/*` - workbench branch and that publication to `main` remains gated by Bifrost or - maintainer review. -- Treat `.voidbot/voice/identity.json` as the visible legacy Persona identity - projection. Treat `.voidbot/state/huginn.cc` as legacy Persona memory; do not - edit it casually as text. -- If migrating Persona state, prefer the portable - `gamecult.persona_state.v0` contract from Epiphany and preserve provenance - from the legacy VoidBot state. - -## Imported Global Defaults - -Source: `C:\Users\Meta\Desktop\AGENTS.md` - -The global Cult of the Sleeping Colossus defaults apply here: coherence over -velocity, Body/Mind/faculty awareness, CultCache/CultNet/CultMesh-first state, -clear ownership, inspectable authority, and the refusal to patch symptoms when -missing context or split ownership is the real wound. +The global Cult of the Sleeping Colossus defaults in `~/.claude/CLAUDE.md` and +`F:\Projects\CLAUDE.md` apply here. diff --git a/README.md b/README.md index 75acdd9..0d1466a 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,47 @@ # Huginn -Huginn reads CultCache `.cc` state and emits Eve DSL for inspectable witness -surfaces. - -CultLib owns the CultCache implementation: document registration, MessagePack -payloads, schema catalogs, backing stores, and the canonical -`cultcache.store.v1` snapshot parser/writer. Huginn consumes CultLib's -`cultcache-ts/inspection` surface and projects inspection results into UI -documents that Eve-capable runtimes can lower. +Huginn is the GameCult memory organ. It owns each agent instance's mind as +typed state: what an instance remembers, which of that memory is admitted to +steer its next action, and the provenance of every admitted claim. Upstream: `https://github.com/GameCult/Huginn.git` ## Authority -- Owner: Huginn owns `.cc` inspection projection, not `.cc` persistence. -- Input: `.cc`, `.msgpack`, or `.mpack` bytes readable by CultLib. -- Output: Eve DSL for `cultcache.huginn.inspector`. -- Renderers: browser, native, overlay, TUI, or future rooms lower the emitted - DSL without becoming state owners. +- An instance owns its mind; Huginn owns the state. Huginn is the single + writer of an instance's memory documents. No other service, script, or agent + writes them. +- Memory documents are CultCache `.cc` state. Huginn persists them through + CultLib's Rust runtime and publishes them as typed documents over CultNet. +- Huginn depends on Qdrant directly for retrieval. When Qdrant is unreachable + Huginn refuses loudly; it does not fall back to a second store or a second + writer. +- Mind state is not version-controlled. It lives in Huginn's store, not in + any repository. -There is no Electron app, Vite dashboard, React renderer, or Norn-owned -presentation path in this repo. If a runtime wants to display Huginn, it should -consume the emitted Eve DSL. +Generic `.cc` inspection is not Huginn's job. CultCache Studio inspects and +edits `.cc` state. Huginn reads and writes minds. -## Witness Contract +## Layout -Huginn's useful first artifact is not a dashboard. It is a read-only specimen -tray for typed state: +A Cargo workspace of three crates: -- which file was inspected -- which CultCache format decoded -- which schema/catalog entry owns each record -- what payload preview survived decoding -- which failure state stayed visible instead of being polished away +- `crates/huginn-mind`: storage, identity, and admission of memory documents. +- `crates/huginn-daemon`: the CultNet surface and serve loop. +- `crates/eureka-state`: typed state for the Eureka pipeline. -The inspector may make evidence easier to read. It must not mutate canonical -bytes, bless missing schemas, or let a renderer become the source of truth. - -## CLI - -```sh -npm install -npm run build -npx huginn path/to/state.cc > huginn.eve -``` - -The CLI writes Eve DSL to stdout and errors to stderr. - -## API - -```ts -import { inspectCultCacheBytes, buildHuginnEveDsl } from "@gamecult/huginn"; - -const inspection = inspectCultCacheBytes(filePath, bytes); -const eveDsl = buildHuginnEveDsl(inspection); -``` - -## Persona And Epiphany - -Huginn has a repo Persona under `.voidbot/voice`. The Persona's current -jurisdiction is this repository body, `E:\Projects\Huginn`, and its useful -pressure is simple: return from the world with evidence, not vibes. - -Epiphany wiring has been smoke-tested through the repo front doors: +The crates are stubs. Each cut of the Eureka pipeline-state campaign fills one +in; the campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` +owns what each crate must do next. ```powershell -cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-repo -- init --workspace E:\Projects\Huginn -cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-swarm -- online --workspace E:\Projects\Huginn +cargo check --workspace ``` -Live fire should happen on an `epiphany/*` or `codex/*` workbench branch. -Publication to `main` remains a maintainer/Bifrost decision, not something the -inspection projection owns. +## Persona + +Huginn's legacy repo Persona lives under `.voidbot/`. Its `state/huginn.cc` +holds legacy `void.*` documents and its `voice/identity.json` names the +Persona. The two disagree about jurisdiction; that migration belongs to the +portable-Persona work, not to this workspace, and nothing here reads or writes +`.voidbot/`. From 4094e689c66a81597e4eb3241d49912ca890ef6c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 15:25:29 +0200 Subject: [PATCH 004/100] Say in AGENTS that the crates are stubs README carried the caveat that every crate is an empty stub filled by a named Eureka cut; AGENTS described ownership, admission, CultNet publication and a Qdrant dependency in the present tense. Add the same caveat to the Body section and name Studio as the .cc inspection owner in one sentence instead of a list of things Huginn does not do. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a2e1af7..dccd3fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ Huginn is the memory organ. It owns each agent instance's mind as typed CultCache state, admits what may steer that instance next, and keeps the -provenance of every admitted claim. It does not inspect arbitrary `.cc` files, -render dashboards, or hold repository truth. +provenance of every admitted claim. Generic `.cc` inspection is CultCache +Studio's job, not Huginn's. ## Body And Authority @@ -13,7 +13,10 @@ render dashboards, or hold repository truth. - Upstream: `https://github.com/GameCult/Huginn.git` - Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and `eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh - runtimes come from `F:\Projects\CultLib\packages`. + runtimes come from `F:\Projects\CultLib\packages`. The crates are stubs + with no dependencies; each named cut of the Eureka pipeline-state campaign + fills one in. Until then nothing here persists, admits, publishes, or + connects. - Owned: an instance's memory documents, their admission, and their CultNet publication. - Depends on: Qdrant, directly. Unreachable Qdrant is a loud refusal, never a From 946758f666477a8a5c81e36398f7517870b6c1d7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 17:04:23 +0200 Subject: [PATCH 005/100] Open a mind fail-closed over an owned redb store Cut 8, Huginn half, commit 1 of 4: the store port, the opener and the identity rules. `huginn-mind` gains its dependencies (`cultcache-rs` at Epiphany's pin `a0813c6`, `epiphany-pipeline` at `a65c6420`, the Epiphany half of this cut), `store.rs` with the one narrow `MindStore` trait that injects compare-and-swap, `mind.rs` with `Mind::open` and `open_with`, and `refusal.rs` with the organ's refusal vocabulary. The opener refuses in order and attaches nothing until every gate passes: a foreign type, a foreign epoch, a missing identity, an identity naming another instance. The path under the state root is derived from the slug; the `instance` document is the authority, so a store copied into another instance's directory is refused. The owned store's exclusive lock is the single-writer mechanism: a second `Mind::open` of the same path is `MindAlreadyOwned`. The receipt type and the two provenance shapes (`Faculty`, `PipelineProvenance`) land here rather than in the later commits because the opener registers the receipt's type id, and their only consumer is the receipt; the commit primitive itself is the next commit. `MindRefusal` carries a serde remote definition of the leaf's `PipelineRefusal`, which derives neither `Serialize` nor `JsonSchema` at the pinned rev; it is compiled against the leaf's type so a leaf change breaks the build rather than drifting. `Cargo.lock` is regenerated and gains 90 packages. The map predicted a CRLF-to-LF rewrite of the whole file; the file was already LF in the index and stays LF, so the diff is additions only. README and AGENTS now say `huginn-mind` is live and the other two crates are stubs. Builds: `cargo check -p huginn-mind --lib --tests` and `cargo test -p huginn-mind --lib` (4 tests) pass. This commit carries dead-code warnings on the store and cache accessors and the refusing test store; commits 2 and 3 consume them. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 8 +- Cargo.lock | 809 +++++++++++++++++++++++++++++ README.md | 11 +- crates/huginn-mind/Cargo.toml | 16 + crates/huginn-mind/src/fixtures.rs | 42 ++ crates/huginn-mind/src/lib.rs | 29 ++ crates/huginn-mind/src/mind.rs | 362 +++++++++++++ crates/huginn-mind/src/receipt.rs | 171 ++++++ crates/huginn-mind/src/refusal.rs | 83 +++ crates/huginn-mind/src/store.rs | 208 ++++++++ 10 files changed, 1732 insertions(+), 7 deletions(-) create mode 100644 crates/huginn-mind/src/fixtures.rs create mode 100644 crates/huginn-mind/src/mind.rs create mode 100644 crates/huginn-mind/src/receipt.rs create mode 100644 crates/huginn-mind/src/refusal.rs create mode 100644 crates/huginn-mind/src/store.rs diff --git a/AGENTS.md b/AGENTS.md index dccd3fd..8a5dbce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,10 +13,10 @@ Studio's job, not Huginn's. - Upstream: `https://github.com/GameCult/Huginn.git` - Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and `eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh - runtimes come from `F:\Projects\CultLib\packages`. The crates are stubs - with no dependencies; each named cut of the Eureka pipeline-state campaign - fills one in. Until then nothing here persists, admits, publishes, or - connects. + runtimes come from `F:\Projects\CultLib\packages`. `huginn-mind` is live: + it persists and admits an instance's mind, over `cultcache-rs` and + `epiphany-pipeline` pinned by git rev. `huginn-daemon` and `eureka-state` + are stubs with no dependencies; nothing here publishes or connects yet. - Owned: an instance's memory documents, their admission, and their CultNet publication. - Depends on: Qdrant, directly. Unreachable Qdrant is a loud refusal, never a diff --git a/Cargo.lock b/Cargo.lock index 2cb6132..233ae59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,241 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cultcache-rs" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a0813c6eed24d30bf88073ef615b633c77ebfcd6#a0813c6eed24d30bf88073ef615b633c77ebfcd6" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a0813c6eed24d30bf88073ef615b633c77ebfcd6#a0813c6eed24d30bf88073ef615b633c77ebfcd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "epiphany-pipeline" +version = "0.1.0" +source = "git+https://github.com/GameCult/Epiphany.git?rev=a65c6420f681d6641b842f94b319f0fa14166c96#a65c6420f681d6641b842f94b319f0fa14166c96" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs", + "rmp-serde", + "schemars", + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "eureka-state" version = "0.1.0" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "huginn-daemon" version = "0.1.0" @@ -13,3 +244,581 @@ version = "0.1.0" [[package]] name = "huginn-mind" version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs", + "epiphany-pipeline", + "rmp-serde", + "schemars", + "serde", + "sha2", + "tempfile", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redb" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" +dependencies = [ + "libc", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index 0d1466a..17ea65f 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,14 @@ A Cargo workspace of three crates: - `crates/huginn-daemon`: the CultNet surface and serve loop. - `crates/eureka-state`: typed state for the Eureka pipeline. -The crates are stubs. Each cut of the Eureka pipeline-state campaign fills one -in; the campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` -owns what each crate must do next. +`huginn-mind` is live: it opens one instance's store (an owned redb CultCache +at `/minds//mind.redb`, locked for the mind's lifetime), +and refuses a store whose `instance` document names another instance, whose +epoch record is foreign, or whose types are not a mind's. Document shape and +keys come from `epiphany-pipeline`; the store is CultLib's Rust CultCache. +`huginn-daemon` and `eureka-state` are stubs; the +campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns what +each crate must do next. ```powershell cargo check --workspace diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index fd4762d..9c1ef96 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -5,4 +5,20 @@ edition.workspace = true license.workspace = true publish.workspace = true +# The organ's rule engine: storage, identity and admission of one instance's +# mind. Document shape, bounds, formats and keys come from `epiphany-pipeline`; +# the store comes from CultLib's Rust CultCache at the same rev Epiphany pins, +# so the leaf's wrappers and this crate's cache are one `cultcache-rs`. No +# network, no socket, no index: those are the daemon's (Cut 10, Cut 11). [dependencies] +anyhow = "1" +chrono = "0.4.44" +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" } +epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "a65c6420f681d6641b842f94b319f0fa14166c96", package = "epiphany-pipeline" } +rmp-serde = "1" +schemars = "1" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs new file mode 100644 index 0000000..a21a097 --- /dev/null +++ b/crates/huginn-mind/src/fixtures.rs @@ -0,0 +1,42 @@ +//! Test fixtures shared by the modules' tests: one valid document of each +//! kind the tests need, prepared through the leaf against the organ's cache. + +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{Date, PipelineDocument, PipelineInstance, Short, Slug}; + +use crate::mind::{HuginnMindEpoch, schema_cache}; + +pub(crate) const INSTANCE: &str = "yggdrasil"; + +pub(crate) fn slug(value: &str) -> Slug { + value.into() +} + +pub(crate) fn s(value: &str) -> Short { + value.into() +} + +pub(crate) fn date() -> Date { + Date("2026-09-16".into()) +} + +pub(crate) fn instance(name: &str) -> PipelineDocument { + PipelineDocument::Instance(PipelineInstance { + instance: slug(name), + display_name: s(&format!("{name} mind")), + created_at: date(), + host: s(name), + }) +} + +/// The leaf's envelope for a document, keyed and encoded as the organ stores +/// it. +pub(crate) fn prepare(document: &PipelineDocument) -> CultCacheEnvelope { + document.validate().unwrap(); + document.prepare(&schema_cache().unwrap()).unwrap() +} + +/// The epoch record admission derives on the first write. +pub(crate) fn epoch() -> CultCacheEnvelope { + HuginnMindEpoch::envelope(&schema_cache().unwrap()).unwrap() +} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index e69de29..83e4bf2 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -0,0 +1,29 @@ +//! `huginn-mind`: one instance's mind as typed CultCache state. +//! +//! The crate owns three decisions and nothing else. `Mind::open_with` owns +//! "may this store be this instance's mind": the opener refuses a foreign +//! type, a foreign epoch, a missing identity or a foreign identity before it +//! attaches anything. `receipt::commit` owns "did a batch enter, whole, with a +//! receipt": one compare-and-swap over the store, a receipt naming the exact +//! bytes read and written, and a typed conflict when the swap loses. The +//! admission path (Cut 8's third commit) owns "may this batch enter". +//! +//! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the +//! organ registers, prepares, decodes and validates through the leaf's four +//! doors and never re-derives a key. The store is CultLib's owned redb +//! CultCache, whose lifetime-long exclusive lock is the single-writer +//! invariant's mechanism. No function here reads a clock or the environment: +//! `now` is passed in. + +pub mod mind; +pub mod receipt; +pub mod refusal; +pub mod store; + +#[cfg(test)] +pub(crate) mod fixtures; + +pub use mind::{HuginnMindEpoch, Mind}; +pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; +pub use refusal::MindRefusal; +pub use store::MindStore; diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs new file mode 100644 index 0000000..ba34555 --- /dev/null +++ b/crates/huginn-mind/src/mind.rs @@ -0,0 +1,362 @@ +//! The mind: one instance's store, opened fail-closed, and its image. +//! +//! Ruling 14: a store is canonical to exactly one instance, and the identity +//! lives in the state, in the `instance` document, not in a path. The path +//! under the state root is derived from the slug for convenience; the +//! document is the authority, and a store moved to another instance's +//! directory is refused. Ruling 15: one writer, the owned redb store's +//! lifetime-long exclusive lock. Ruling 20's analogue: the opener validates +//! every gate before it attaches, so a refused store is never read into an +//! image. + +use std::path::{Path, PathBuf}; + +use cultcache_rs::{ + CacheBackingStore, CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore, +}; +use epiphany_pipeline::{ + PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types, +}; + +use crate::receipt::HuginnCommitReceipt; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The store's record of the schema epoch it was written at, keyed by the +/// epoch string. Derived by admission on the first write; read by the opener, +/// which refuses a store written at any other epoch (`ForeignEpoch`) so a +/// breaking schema bump refuses the old store instead of misreading it. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "huginn.mind_epoch.v1", schema = "HuginnMindEpoch")] +pub struct HuginnMindEpoch { + #[cultcache(key = 0)] + pub schema_epoch: String, +} + +impl HuginnMindEpoch { + /// The one envelope the record ever has: the current epoch, keyed by + /// itself. Admission appends it to the first write. + pub(crate) fn envelope(cache: &CultCache) -> Result { + let record = Self { schema_epoch: PIPELINE_SCHEMA_EPOCH.to_string() }; + cache + .prepare_entry_named(PIPELINE_SCHEMA_EPOCH, &record) + .map(|(envelope, _)| envelope) + .map_err(unavailable) + } +} + +/// A store or cache error, carried as data. +pub(crate) fn unavailable(error: anyhow::Error) -> MindRefusal { + MindRefusal::Unavailable { detail: format!("{error:#}") } +} + +/// A cache that knows the fifteen types a mind's store may hold: the leaf's +/// thirteen through its registrar, the epoch record and the commit receipt. +/// It is the cache every envelope is prepared against. +pub(crate) fn schema_cache() -> Result { + let mut cache = CultCache::new(); + register_pipeline_document_types(&mut cache).map_err(unavailable)?; + cache.register_entry_type::().map_err(unavailable)?; + cache.register_entry_type::().map_err(unavailable)?; + Ok(cache) +} + +fn is_known_type(type_id: &str) -> bool { + type_id == HuginnMindEpoch::TYPE + || type_id == HuginnCommitReceipt::TYPE + || PipelineKind::ALL.iter().any(|kind| kind.type_id() == type_id) +} + +/// One instance's mind: the declared instance, the store, the registered +/// cache attached to it, and the image (the store's envelopes as last pulled, +/// in identity order). The image is a cache of the store and is re-pulled +/// after every commit; nothing writes it directly. +pub struct Mind { + instance: Slug, + store: S, + cache: CultCache, + image: Vec, +} + +impl Mind { + /// `/minds//mind.redb`. Derived for convenience; + /// the `instance` document inside is the identity. + pub fn path_for(state_root: &Path, instance: &Slug) -> PathBuf { + state_root.join("minds").join(&instance.0).join("mind.redb") + } + + /// Opens the instance's mind under the state root, taking the store's + /// exclusive lock for the mind's lifetime. A second owner of the same + /// path, in this process or another, is `MindAlreadyOwned`. An empty + /// store is a valid open; only the first admission may write into it. + pub fn open(state_root: &Path, instance: &Slug) -> Result { + let path = Self::path_for(state_root, instance); + let store = OwnedRedbMessagePackBackingStore::new(&path).map_err(|error| { + // The store reports a held lock as an error like any other; its + // message is the only signal, so it is matched here, once. + if format!("{error:#}").contains("already has an active owner") { + MindRefusal::MindAlreadyOwned { path: path.display().to_string() } + } else { + unavailable(error) + } + })?; + Self::open_with(store, instance) + } +} + +impl Mind { + /// Fail-closed, in this order, nothing attached until every step passes: + /// pull the raw envelopes; every type is one of the fifteen; if anything + /// is stored, exactly one epoch record at the current epoch and exactly + /// one `instance` document naming the declared instance; then register, + /// attach and pull. + pub fn open_with(store: S, instance: &Slug) -> Result { + let raw = store.pull_all().map_err(unavailable)?; + refuse_foreign_types(&raw)?; + refuse_foreign_epoch(&raw)?; + refuse_foreign_identity(&raw, instance)?; + let (cache, image) = attach(store.clone())?; + Ok(Self { instance: instance.clone(), store, cache, image }) + } + + pub fn instance(&self) -> &Slug { + &self.instance + } + + /// The image: every envelope the store held at the last pull, in + /// identity order. Cut 10's snapshot source reads this. + pub fn envelopes(&self) -> &[CultCacheEnvelope] { + &self.image + } + + pub fn is_empty(&self) -> bool { + self.image.is_empty() + } + + /// The stored bytes of one document, by kind and id. + pub fn envelope(&self, kind: PipelineKind, id: &str) -> Option<&CultCacheEnvelope> { + self.raw_envelope(kind.type_id(), id) + } + + /// One document, decoded through the leaf. + pub fn get(&self, kind: PipelineKind, id: &str) -> Result, MindRefusal> { + self.envelope(kind, id) + .map(|envelope| PipelineDocument::decode(envelope).map_err(MindRefusal::Document)) + .transpose() + } + + /// Every commit receipt in the image, in receipt-id order. + pub fn receipts(&self) -> Result, MindRefusal> { + self.image + .iter() + .filter(|envelope| envelope.r#type == HuginnCommitReceipt::TYPE) + .map(|envelope| { + rmp_serde::from_slice::(&envelope.payload).map_err(|error| { + MindRefusal::Unavailable { detail: format!("receipt {} does not decode: {error}", envelope.key) } + }) + }) + .collect() + } + + pub(crate) fn raw_envelope(&self, type_id: &str, key: &str) -> Option<&CultCacheEnvelope> { + self.image.iter().find(|envelope| envelope.r#type == type_id && envelope.key == key) + } + + pub(crate) fn cache(&self) -> &CultCache { + &self.cache + } + + pub(crate) fn store(&self) -> &S { + &self.store + } + + /// Re-pulls the image from the store through the attached cache. + pub(crate) fn refresh(&mut self) -> Result<(), MindRefusal> { + self.cache.pull_all_backing_stores().map_err(unavailable)?; + self.image = snapshot(&self.cache); + Ok(()) + } +} + +/// Step 2: a runtime store, a Mind store or any other file passed by mistake +/// dies on its first foreign type. +fn refuse_foreign_types(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { + if let Some(foreign) = raw.iter().find(|envelope| !is_known_type(&envelope.r#type)) { + return Err(MindRefusal::ForeignStore { r#type: foreign.r#type.clone() }); + } + Ok(()) +} + +/// Step 3: a non-empty store carries exactly one epoch record, at the epoch +/// this binary writes. +fn refuse_foreign_epoch(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { + if raw.is_empty() { + return Ok(()); + } + let mut records = raw.iter().filter(|envelope| envelope.r#type == HuginnMindEpoch::TYPE); + let Some(record) = records.next() else { + return Err(MindRefusal::MissingIdentity); + }; + let record: HuginnMindEpoch = rmp_serde::from_slice(&record.payload) + .map_err(|error| MindRefusal::Unavailable { detail: format!("epoch record does not decode: {error}") })?; + if record.schema_epoch != PIPELINE_SCHEMA_EPOCH { + return Err(MindRefusal::ForeignEpoch { found: record.schema_epoch, expected: PIPELINE_SCHEMA_EPOCH.into() }); + } + Ok(()) +} + +/// Step 4: a non-empty store carries exactly one `instance` document, and it +/// names the declared instance. This is where a store moved to another +/// instance's directory is refused. +fn refuse_foreign_identity(raw: &[CultCacheEnvelope], instance: &Slug) -> Result<(), MindRefusal> { + if raw.is_empty() { + return Ok(()); + } + let mut documents = raw.iter().filter(|envelope| envelope.r#type == PipelineKind::Instance.type_id()); + let (Some(envelope), None) = (documents.next(), documents.next()) else { + return Err(MindRefusal::MissingIdentity); + }; + let PipelineDocument::Instance(stored) = PipelineDocument::decode(envelope)? else { + return Err(MindRefusal::MissingIdentity); + }; + if stored.instance != *instance { + return Err(MindRefusal::ForeignInstance { declared: instance.0.clone(), mind: stored.instance.0 }); + } + Ok(()) +} + +/// Step 5: the registered cache, the store as its one generic home, and the +/// image pulled through it. +fn attach(store: S) -> Result<(CultCache, Vec), MindRefusal> { + let mut cache = schema_cache()?; + cache.add_generic_backing_store(store).map_err(unavailable)?; + cache.pull_all_backing_stores().map_err(unavailable)?; + let image = snapshot(&cache); + Ok((cache, image)) +} + +fn snapshot(cache: &CultCache) -> Vec { + let mut image = cache.snapshot_envelopes(); + image.sort_by(|left, right| (&left.r#type, &left.key).cmp(&(&right.r#type, &right.key))); + image +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::{INSTANCE, epoch, instance, prepare, slug}; + use crate::store::test_stores::MemoryStore; + + fn planted(rows: Vec) -> MemoryStore { + let store = MemoryStore::new(); + for row in rows { + store.plant(row); + } + store + } + + fn foreign(type_id: &str) -> CultCacheEnvelope { + let mut envelope = epoch(); + envelope.r#type = type_id.into(); + envelope + } + + fn foreign_epoch() -> CultCacheEnvelope { + let cache = schema_cache().unwrap(); + let record = HuginnMindEpoch { schema_epoch: "epiphany.pipeline.epoch.v0".into() }; + cache.prepare_entry_named("epiphany.pipeline.epoch.v0", &record).unwrap().0 + } + + #[test] + fn the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching() { + let yggdrasil = slug(INSTANCE); + let cases: Vec<(Vec, MindRefusal)> = vec![ + // Step 2 before everything: a runtime store passed by mistake. + ( + vec![foreign("epiphany.runtime_spine.v47"), epoch(), prepare(&instance(INSTANCE))], + MindRefusal::ForeignStore { r#type: "epiphany.runtime_spine.v47".into() }, + ), + // Step 3 before step 4: a foreign epoch with no identity at all. + ( + vec![foreign_epoch()], + MindRefusal::ForeignEpoch { + found: "epiphany.pipeline.epoch.v0".into(), + expected: PIPELINE_SCHEMA_EPOCH.into(), + }, + ), + // Step 3: an identity without an epoch record. + (vec![prepare(&instance(INSTANCE))], MindRefusal::MissingIdentity), + // Step 4: an epoch record without an identity. + (vec![epoch()], MindRefusal::MissingIdentity), + // Step 4: two identities are no identity. + ( + vec![epoch(), prepare(&instance(INSTANCE)), prepare(&instance("thought-cage"))], + MindRefusal::MissingIdentity, + ), + ]; + for (rows, expected) in cases { + let store = planted(rows); + let before = store.rows(); + let refusal = Mind::open_with(store.clone(), &yggdrasil).err().expect("refused"); + assert_eq!(refusal, expected); + assert_eq!(store.pull_count(), 1, "{expected:?}: refused before attaching"); + assert_eq!(store.rows(), before, "{expected:?}: bytes unchanged"); + } + // A store that passes every gate attaches, which is the second pull. + let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); + let mind = Mind::open_with(store.clone(), &yggdrasil).unwrap(); + assert_eq!(store.pull_count(), 2); + assert_eq!(mind.envelopes().len(), 2); + assert!(!mind.is_empty()); + let empty = MemoryStore::new(); + assert!(Mind::open_with(empty, &yggdrasil).unwrap().is_empty()); + } + + #[test] + fn the_instance_document_is_the_identity_not_the_path() { + // The same bytes opened under another name are refused. + let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); + assert_eq!( + Mind::open_with(store, &slug("thought-cage")).err(), + Some(MindRefusal::ForeignInstance { declared: "thought-cage".into(), mind: INSTANCE.into() }) + ); + + // A real store copied into another instance's directory is still + // yggdrasil's mind: it opens as yggdrasil, and not as thought-cage. + let root = tempfile::tempdir().unwrap(); + let written = Mind::path_for(root.path(), &slug(INSTANCE)); + { + let mut store = OwnedRedbMessagePackBackingStore::new(&written).unwrap(); + store.push(&epoch()).unwrap(); + store.push(&prepare(&instance(INSTANCE))).unwrap(); + } + let moved = Mind::path_for(root.path(), &slug("thought-cage")); + std::fs::create_dir_all(moved.parent().unwrap()).unwrap(); + std::fs::copy(&written, &moved).unwrap(); + assert_eq!( + Mind::open(root.path(), &slug("thought-cage")).err(), + Some(MindRefusal::ForeignInstance { declared: "thought-cage".into(), mind: INSTANCE.into() }) + ); + let store = OwnedRedbMessagePackBackingStore::new(&moved).unwrap(); + let mind = Mind::open_with(store, &slug(INSTANCE)).unwrap(); + assert_eq!(mind.instance(), &slug(INSTANCE)); + assert_eq!(mind.envelopes().len(), 2); + } + + #[test] + fn a_mind_has_one_owner_at_a_time() { + let root = tempfile::tempdir().unwrap(); + let yggdrasil = slug(INSTANCE); + let first = Mind::open(root.path(), &yggdrasil).unwrap(); + assert!(first.is_empty()); + let path = Mind::path_for(root.path(), &yggdrasil).display().to_string(); + assert_eq!( + Mind::open(root.path(), &yggdrasil).err(), + Some(MindRefusal::MindAlreadyOwned { path: path.clone() }) + ); + drop(first); + let second = Mind::open(root.path(), &yggdrasil).unwrap(); + assert!(second.is_empty()); + assert_eq!(Mind::path_for(root.path(), &yggdrasil), root.path().join("minds").join(INSTANCE).join("mind.redb")); + } +} diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs new file mode 100644 index 0000000..a74731b --- /dev/null +++ b/crates/huginn-mind/src/receipt.rs @@ -0,0 +1,171 @@ +//! The commit receipt: what a batch read and what it wrote, byte for byte. +//! +//! This is the bounded duplicate of `epiphany-core`'s receipt (FU-3), smaller +//! by design: no authority enum, no companion documents, no `invariant_owner`, +//! no `store_id`. Provenance is a field on the receipt, not a companion, and +//! the digest excludes it, so an exact replay across sessions and faculties +//! is idempotent. If a third consumer appears, extract the primitive into +//! CultLib rather than copy it again. +//! +//! Digest: `receipt_id = "mind-commit-" + hex(sha256(msgpack_named((instance, +//! strong_reads, writes))))`, where each list is `DocumentVersion`s in +//! `(document_type, document_key)` order and a version carries the exact +//! payload bytes and their SHA-256. `committed_at` and `provenance` are not +//! digested. + +use cultcache_rs::{CultCacheEnvelope, DatabaseEntry}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::refusal::MindRefusal; + +pub const RECEIPT_SCHEMA_VERSION: &str = "huginn.mind_commit_receipt.v1"; + +/// Which faculty a batch was admitted as. Attribution, not authority +/// (ruling 18): no admission rule trusts it, and Cut 9's views read it back +/// from the receipt. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum Faculty { + SelfFaculty, + Imagination, + Hands, + Soul, + MindSteward, + Eyes, + Operator, +} + +/// Who admitted a batch: the faculty, the agent, its session and the tool. +/// A field on the receipt and nothing else; the digest excludes it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineProvenance { + pub faculty: Faculty, + pub agent: epiphany_pipeline::Short, + pub session: epiphany_pipeline::Short, + pub tool: epiphany_pipeline::Short, +} + +/// The exact bytes of one stored document, as read or as written. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct DocumentVersion { + pub document_type: String, + pub document_key: String, + pub schema_id: Option, + pub payload_msgpack: Vec, + pub payload_sha256: String, +} + +impl DocumentVersion { + pub fn from_envelope(envelope: &CultCacheEnvelope) -> Self { + Self { + document_type: envelope.r#type.clone(), + document_key: envelope.key.clone(), + schema_id: envelope.schema_id.clone(), + payload_msgpack: envelope.payload.clone(), + payload_sha256: sha256_hex(&envelope.payload), + } + } + + pub fn identity(&self) -> (&str, &str) { + (&self.document_type, &self.document_key) + } + + fn validate(&self) -> Result<(), MindRefusal> { + if self.document_type.is_empty() || self.document_key.is_empty() { + return Err(integrity("a document version names no identity")); + } + if self.payload_sha256 != sha256_hex(&self.payload_msgpack) { + return Err(integrity(&format!( + "document {}/{} payload digest mismatch", + self.document_type, self.document_key + ))); + } + Ok(()) + } +} + +/// The receipt of one admitted batch, stored beside the documents it names +/// and keyed by its id. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "huginn.mind_commit_receipt.v1", schema = "HuginnCommitReceipt")] +pub struct HuginnCommitReceipt { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub receipt_id: String, + #[cultcache(key = 2)] + pub instance: String, + #[cultcache(key = 3)] + pub provenance: PipelineProvenance, + #[cultcache(key = 4)] + pub strong_reads: Vec, + #[cultcache(key = 5)] + pub writes: Vec, + #[cultcache(key = 6)] + pub committed_at: String, +} + +impl HuginnCommitReceipt { + /// The digest, from what was read and written and never from who wrote + /// it or when. + pub fn digest(&self) -> Result { + let bytes = rmp_serde::to_vec_named(&(&self.instance, &self.strong_reads, &self.writes)) + .map_err(|error| integrity(&format!("receipt digest input does not encode: {error}")))?; + Ok(format!("mind-commit-{}", sha256_hex(&bytes))) + } + + pub fn validate(&self) -> Result<(), MindRefusal> { + if self.schema_version != RECEIPT_SCHEMA_VERSION { + return Err(integrity(&format!("receipt schema version {:?} is foreign", self.schema_version))); + } + if self.instance.is_empty() { + return Err(integrity("receipt names no instance")); + } + if self.writes.is_empty() { + return Err(integrity("receipt writes nothing")); + } + for version in self.strong_reads.iter().chain(&self.writes) { + version.validate()?; + } + if !unique_identities(&self.strong_reads) || !unique_identities(&self.writes) { + return Err(integrity("receipt names an identity twice")); + } + if self.receipt_id != self.digest()? { + return Err(integrity(&format!("receipt {} does not match its digest", self.receipt_id))); + } + Ok(()) + } +} + +fn unique_identities(versions: &[DocumentVersion]) -> bool { + let identities = versions.iter().map(DocumentVersion::identity).collect::>(); + identities.len() == versions.len() +} + +fn integrity(detail: &str) -> MindRefusal { + MindRefusal::Unavailable { detail: detail.into() } +} + +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::{INSTANCE, instance, prepare}; + use epiphany_pipeline::{PipelineDocument, PipelineRefusal}; + + /// S5 closed against the real type: the organ's own receipt in a mind's + /// store is never decoded as a pipeline document. + #[test] + fn decode_refuses_the_organs_own_receipt() { + let mut envelope = prepare(&instance(INSTANCE)); + envelope.r#type = HuginnCommitReceipt::TYPE.into(); + assert_eq!( + PipelineDocument::decode(&envelope), + Err(PipelineRefusal::ForeignStore { r#type: "huginn.mind_commit_receipt.v1".into() }) + ); + } +} diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs new file mode 100644 index 0000000..cba00fa --- /dev/null +++ b/crates/huginn-mind/src/refusal.rs @@ -0,0 +1,83 @@ +//! Typed refusals of the organ. A refusal is data with a field an agent can +//! act on, never a transport error: the outcome carries it, the wire (Cut 10) +//! serialises it, the tools (Cut 13) show it. + +use epiphany_pipeline::{PipelineKind, PipelineRefusal}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Every way a mind refuses. `Document` wraps the leaf's four (bounds, +/// formats, key identity, foreign type); the rest are the organ's: store +/// identity, epoch, ownership, batch shape, references, and every cross-field +/// and cross-document rule of admission. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum MindRefusal { + Document( + #[serde(with = "DocumentRefusal")] + #[schemars(with = "DocumentRefusal")] + PipelineRefusal, + ), + ForeignInstance { declared: String, mind: String }, + MissingIdentity, + ForeignEpoch { found: String, expected: String }, + ForeignStore { r#type: String }, + MindAlreadyOwned { path: String }, + BatchSize { actual: u32 }, + IdentityCollision { kind: PipelineKind, id: String }, + MissingReference { kind: PipelineKind, id: String }, + AlreadyResolved { subject: String }, + IncompatibleResolution { subject_kind: PipelineKind, outcome: String }, + CitesResolvedDocument { kind: PipelineKind, id: String }, + EmptySupersession, + UnknownSupersessor { id: String }, + RevisionWithoutSupersession { kind: PipelineKind, revision: u32 }, + DuplicateLabel { field: String, label: String }, + InvalidOptions { question: String }, + InvalidChoice { ruling: String, choice: String }, + QuoteWithoutOperator { ruling: String }, + RepoNotStewarded { repo: String }, + RepoNotInCampaign { repo: String }, + CutReportWithoutSpec { report: String }, + SpecMismatch { field: String }, + RangeOutsideCommits { head: String }, + FalsifiedClaimWithoutConfirmedFinding { claim: String }, + UnprovenClaimWithConfirmedFinding { claim: String }, + PromiseWithoutVerdict { promise: String }, + UnknownMutationLabel { label: String }, + FindingWithoutRange, + FindingWithoutEvidence, + UnknownInvariant { label: String }, + NotStewarded { repo: String }, + Unavailable { detail: String }, +} + +/// The leaf's `PipelineRefusal` at the pinned rev derives neither `Serialize` +/// nor `JsonSchema`. This is serde's remote definition of it: the same four +/// variants, field for field, compiled against the leaf's type, so a leaf +/// change that renames or adds a field stops this crate building rather than +/// drifting. It is a projection for the wire, not a second vocabulary; the +/// refusal itself is still the leaf's value. When the leaf derives the two +/// traits this goes. +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(remote = "PipelineRefusal")] +#[allow(dead_code)] +enum DocumentRefusal { + FieldBound { field: String, limit: u32, actual: u32 }, + InvalidFormat { field: String, value: String }, + InvalidIdentity { kind: PipelineKind, key: String, expected: String }, + ForeignStore { r#type: String }, +} + +impl std::fmt::Display for MindRefusal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "mind refusal: {self:?}") + } +} + +impl std::error::Error for MindRefusal {} + +impl From for MindRefusal { + fn from(refusal: PipelineRefusal) -> Self { + Self::Document(refusal) + } +} diff --git a/crates/huginn-mind/src/store.rs b/crates/huginn-mind/src/store.rs new file mode 100644 index 0000000..d532fbd --- /dev/null +++ b/crates/huginn-mind/src/store.rs @@ -0,0 +1,208 @@ +//! The store port. `cultcache-rs` exposes compare-and-swap as inherent methods +//! on its concrete stores, not on `CacheBackingStore`, so the one narrow trait +//! here is how the commit primitive gets it injected: the owned redb store in +//! the daemon, a memory store in every admission test, a refusing store in the +//! atomicity test. Nothing else is abstracted. + +use anyhow::Result; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; + +/// A store a mind can be opened over. `Clone` shares ownership of the same +/// store (the owned redb store's clones share its lock and handle), which is +/// how the cache image and the commit path hold one store between them. +pub trait MindStore: CacheBackingStore + Clone + 'static { + /// One transactional step: every `expected` row must be stored exactly as + /// given, no `replacements` row outside `expected` may already exist, and + /// then every replacement lands, or nothing does and `Ok(false)` says so. + /// The store's own rule that every expected identity is also replaced is + /// kept: a strong read is re-inserted unchanged beside the writes. + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result; +} + +impl MindStore for OwnedRedbMessagePackBackingStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + OwnedRedbMessagePackBackingStore::compare_and_swap_batch(self, expected, replacements) + } +} + +/// Test stores: a memory store with the redb store's batch semantics, and one +/// that can be told to lose or fail its next swap. +#[cfg(test)] +pub(crate) mod test_stores { + use std::collections::{BTreeMap, BTreeSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use anyhow::{Result, anyhow}; + use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, PushAllOptions}; + + use super::MindStore; + + type Identity = (String, String); + + fn identity(entry: &CultCacheEnvelope) -> Identity { + (entry.r#type.clone(), entry.key.clone()) + } + + fn unique(entries: &[CultCacheEnvelope], label: &str) -> Result> { + let ids = entries.iter().map(identity).collect::>(); + if ids.len() != entries.len() { + return Err(anyhow!("conditional batch {label} set contains duplicate identities")); + } + Ok(ids) + } + + /// One row per `(type, key)` behind a mutex; clones share it. Counts + /// `pull_all` calls so the opener test can prove nothing attached. + #[derive(Clone, Default)] + pub(crate) struct MemoryStore { + rows: Arc>>, + pulls: Arc, + } + + impl MemoryStore { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn pull_count(&self) -> usize { + self.pulls.load(Ordering::SeqCst) + } + + /// Every row, in identity order: the bytes a test compares before + /// and after a refused admission. + pub(crate) fn rows(&self) -> Vec { + self.rows.lock().unwrap().values().cloned().collect() + } + + /// Writes a row without the commit path, for the opener tests and the + /// one replay-planting test. + pub(crate) fn plant(&self, entry: CultCacheEnvelope) { + self.rows.lock().unwrap().insert(identity(&entry), entry); + } + } + + impl CacheBackingStore for MemoryStore { + fn pull_all(&self) -> Result> { + self.pulls.fetch_add(1, Ordering::SeqCst); + Ok(self.rows()) + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.plant(entry.clone()); + Ok(()) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.rows.lock().unwrap().remove(&identity(entry)); + Ok(()) + } + + fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + *self.rows.lock().unwrap() = entries.iter().map(|entry| (identity(entry), entry.clone())).collect(); + Ok(()) + } + } + + impl MindStore for MemoryStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!("conditional batch requires a non-empty replacement set")); + } + let expected_ids = unique(expected, "expected")?; + let replacement_ids = unique(&replacements, "replacement")?; + if !expected_ids.is_subset(&replacement_ids) { + return Err(anyhow!("conditional batch must replace every expected identity")); + } + let mut rows = self.rows.lock().unwrap(); + for row in expected { + if rows.get(&identity(row)) != Some(row) { + return Ok(false); + } + } + for row in &replacements { + if !expected_ids.contains(&identity(row)) && rows.contains_key(&identity(row)) { + return Ok(false); + } + } + for row in replacements { + rows.insert(identity(&row), row); + } + Ok(true) + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(crate) enum SwapCommand { + Delegate, + Lose, + Fail, + } + + /// A memory store whose next swap can be commanded to lose (`Ok(false)`) + /// or fail (`Err`), so the commit path's two non-committing exits are + /// reachable without a second process. + #[derive(Clone)] + pub(crate) struct RefusingStore { + inner: MemoryStore, + command: Arc>, + } + + impl RefusingStore { + pub(crate) fn new() -> Self { + Self { inner: MemoryStore::new(), command: Arc::new(Mutex::new(SwapCommand::Delegate)) } + } + + pub(crate) fn command(&self, command: SwapCommand) { + *self.command.lock().unwrap() = command; + } + + pub(crate) fn rows(&self) -> Vec { + self.inner.rows() + } + } + + impl CacheBackingStore for RefusingStore { + fn pull_all(&self) -> Result> { + self.inner.pull_all() + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.inner.push(entry) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.inner.delete(entry) + } + + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()> { + self.inner.push_all(entries, options) + } + } + + impl MindStore for RefusingStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + match *self.command.lock().unwrap() { + SwapCommand::Delegate => self.inner.compare_and_swap_batch(expected, replacements), + SwapCommand::Lose => Ok(false), + SwapCommand::Fail => Err(anyhow!("the store refused the swap on command")), + } + } + } +} From a4c5b79d528bf9587207fc366884b61b74147e05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 17:05:30 +0200 Subject: [PATCH 006/100] Land a batch whole with a receipt naming its exact bytes Cut 8, Huginn half, commit 2 of 4: the commit primitive in `receipt.rs`. `candidate` is the one construction site of a receipt: its id is the digest of `(instance, strong_reads, writes)`, each list in identity order, computed before anything is stored so admission can look up an exact replay. `replay` finds a stored receipt with that id and checks its reads and writes equal the candidate's. `commit` is one compare-and-swap over the store, the strong reads as its expectation and the writes plus the receipt as its replacements; the image is re-pulled either way, and a lost swap is diffed against the fresh image and typed `Conflict`. Provenance and `committed_at` are on the receipt and outside the digest, so the same bytes replayed by another faculty in another session answer with the first receipt. The store requires every expected identity to be replaced, so a strong read is re-inserted unchanged beside the writes, as Epiphany's receipt path does. Builds: `cargo check -p huginn-mind --lib --tests`, `cargo test -p huginn-mind --lib` (4 tests). Dead-code warnings remain on the primitive and the refusing test store until admission consumes them in the next commit. Co-Authored-By: Claude Fable 5.1 --- crates/huginn-mind/src/mind.rs | 5 +- crates/huginn-mind/src/receipt.rs | 106 ++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index ba34555..926b92d 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -11,9 +11,7 @@ use std::path::{Path, PathBuf}; -use cultcache_rs::{ - CacheBackingStore, CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore, -}; +use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore}; use epiphany_pipeline::{ PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types, }; @@ -246,6 +244,7 @@ mod tests { use super::*; use crate::fixtures::{INSTANCE, epoch, instance, prepare, slug}; use crate::store::test_stores::MemoryStore; + use cultcache_rs::CacheBackingStore; fn planted(rows: Vec) -> MemoryStore { let store = MemoryStore::new(); diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index a74731b..3680585 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -13,12 +13,16 @@ //! payload bytes and their SHA-256. `committed_at` and `provenance` are not //! digested. +use chrono::{DateTime, SecondsFormat, Utc}; use cultcache_rs::{CultCacheEnvelope, DatabaseEntry}; +use epiphany_pipeline::{PipelineKind, PipelineRef, Short, Slug}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::mind::{Mind, unavailable}; use crate::refusal::MindRefusal; +use crate::store::MindStore; pub const RECEIPT_SCHEMA_VERSION: &str = "huginn.mind_commit_receipt.v1"; @@ -138,6 +142,108 @@ impl HuginnCommitReceipt { } } +/// How a commit ended. An exact replay is decided by admission before the +/// commit is attempted, so it is not an exit of the primitive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CommitOutcome { + /// The batch and its receipt landed whole. + Committed(HuginnCommitReceipt), + /// The swap lost: a strong read changed underneath, or a write's identity + /// already existed. Named where the kind is a pipeline kind. + Conflict(Vec), +} + +/// The receipt a batch would land with: its id is the digest of the exact +/// bytes read and written, computed before anything is stored so admission +/// can look up an exact replay. This is the one construction site of a +/// receipt. +pub(crate) fn candidate( + instance: &Slug, + provenance: PipelineProvenance, + strong_reads: &[CultCacheEnvelope], + writes: &[CultCacheEnvelope], + now: DateTime, +) -> Result { + let mut receipt = HuginnCommitReceipt { + schema_version: RECEIPT_SCHEMA_VERSION.to_string(), + receipt_id: String::new(), + instance: instance.0.clone(), + provenance, + strong_reads: versions(strong_reads), + writes: versions(writes), + committed_at: now.to_rfc3339_opts(SecondsFormat::Secs, true), + }; + receipt.receipt_id = receipt.digest()?; + Ok(receipt) +} + +/// The versions of a set of envelopes in identity order, so the digest does +/// not depend on the order a batch was assembled in. +fn versions(envelopes: &[CultCacheEnvelope]) -> Vec { + let mut versions = envelopes.iter().map(DocumentVersion::from_envelope).collect::>(); + versions.sort_by(|left, right| left.identity().cmp(&right.identity())); + versions +} + +/// The stored receipt with the candidate's id, if the batch already landed: +/// its reads and writes must equal the candidate's, or the store holds a +/// receipt this binary cannot vouch for. +pub(crate) fn replay( + mind: &Mind, + candidate: &HuginnCommitReceipt, +) -> Result, MindRefusal> { + let Some(envelope) = mind.raw_envelope(HuginnCommitReceipt::TYPE, &candidate.receipt_id) else { + return Ok(None); + }; + let existing: HuginnCommitReceipt = rmp_serde::from_slice(&envelope.payload) + .map_err(|error| integrity(&format!("receipt {} does not decode: {error}", candidate.receipt_id)))?; + existing.validate()?; + if existing.strong_reads != candidate.strong_reads || existing.writes != candidate.writes { + return Err(integrity(&format!("receipt {} is stored with other content", candidate.receipt_id))); + } + Ok(Some(existing)) +} + +/// Lands a batch whole, or not at all: one compare-and-swap with the strong +/// reads as its expectation and the writes plus the receipt as its +/// replacements. The image is re-pulled either way; a lost swap is diffed +/// against the fresh image and typed `Conflict`. +pub(crate) fn commit( + mind: &mut Mind, + receipt: HuginnCommitReceipt, + strong_reads: Vec, + writes: Vec, +) -> Result { + receipt.validate()?; + let receipt_envelope = mind + .cache() + .prepare_entry_named(&receipt.receipt_id, &receipt) + .map(|(envelope, _)| envelope) + .map_err(unavailable)?; + // The store requires every expected identity to be replaced, so each + // strong read is re-inserted unchanged beside the writes. + let mut replacements = writes.clone(); + replacements.extend(strong_reads.iter().cloned()); + replacements.push(receipt_envelope); + let expected: &[CultCacheEnvelope] = &strong_reads; + let landed = MindStore::compare_and_swap_batch(mind.store(), expected, replacements).map_err(unavailable)?; + mind.refresh()?; + if !landed { + let changed = strong_reads + .iter() + .filter(|read| mind.raw_envelope(&read.r#type, &read.key) != Some(read)) + .chain(writes.iter().filter(|write| mind.raw_envelope(&write.r#type, &write.key).is_some())); + let identities = changed + .filter_map(|envelope| { + let kind = PipelineKind::ALL.iter().find(|kind| kind.type_id() == envelope.r#type)?; + Some(PipelineRef { kind: *kind, id: Short(envelope.key.clone()) }) + }) + .collect(); + return Ok(CommitOutcome::Conflict(identities)); + } + Ok(CommitOutcome::Committed(receipt)) +} + fn unique_identities(versions: &[DocumentVersion]) -> bool { let identities = versions.iter().map(DocumentVersion::identity).collect::>(); identities.len() == versions.len() From 0bd71337fdeec5e9d70ed8c985195044621d2aa1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 17:13:44 +0200 Subject: [PATCH 007/100] Admit a batch through one ordered path with every rule in it Cut 8, Huginn half, commit 3 of 4: `admission.rs`. `Mind::admit` prepares typed documents through the leaf and calls `admit_prepared`, the single commit path for this cut, Cut 10's sink, Cut 12's hand-off and import, and Cut 13's tools. The steps run in order and the first failing step is the outcome: the batch names this mind (A1), one to sixty-four envelopes (A2), the leaf's bounds, formats and key recomputation (A3), unique identities (A4), every instance-carrying document names this mind (A5), an empty mind's first batch carries exactly one `instance` document and a batch carrying one derives the epoch record (A6), every reference resolves in image or batch by `(kind, id)` (A7), the derived writes pass A3-A7 themselves, the per-kind rules and the resolution matrix (A8), an exact replay answers with the stored receipt only after validation (A9), no write collides with the image (A10), the commit (A11). Derived writes: a ruling that answers derives the `Answered` resolution of its question; a hand-off derives the stewardship withdrawal on its source side and the stewardship assignment on its receiving side. "In force" is computed at rule time from the resolutions in image and batch and never stored; the two derivations are the only resolutions a rule ignores when judging its own document. Two places the leaf is thinner than the spec assumed. `FindingWithoutRange` is unreachable: a finding envelope without `range` fails the leaf's decode as `Document(InvalidFormat { field: "payload" })` before any organ rule, and the test asserts that. A campaign with no repos has no refusal of its own in the spec's list; it is refused as `Document(FieldBound)` on `campaign.repos` with a minimum of one. `PipelineAdmissionBatch` cannot derive `JsonSchema` because the leaf's `PipelineDocument` does not; the outcome and refusal types do. The epoch record is derived whenever the batch carries the `instance` document, which only the first batch may, so a replay of the first batch digests to the same receipt and answers `AlreadyAdmitted` instead of colliding. Tests: 26 (the spec's 25 plus the replay-planting test, the receipt's deliberate second construction site). `cargo check -p huginn-mind --lib --tests` and `cargo test -p huginn-mind --lib` pass with zero warnings; `cargo check --workspace` passes. README says what the crate does now. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +- crates/huginn-mind/src/admission.rs | 1340 +++++++++++++++++++++++++++ crates/huginn-mind/src/fixtures.rs | 310 ++++++- crates/huginn-mind/src/lib.rs | 8 +- crates/huginn-mind/src/receipt.rs | 2 +- 5 files changed, 1662 insertions(+), 8 deletions(-) create mode 100644 crates/huginn-mind/src/admission.rs diff --git a/README.md b/README.md index 17ea65f..b99689c 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,14 @@ A Cargo workspace of three crates: `huginn-mind` is live: it opens one instance's store (an owned redb CultCache at `/minds//mind.redb`, locked for the mind's lifetime), and refuses a store whose `instance` document names another instance, whose -epoch record is foreign, or whose types are not a mind's. Document shape and -keys come from `epiphany-pipeline`; the store is CultLib's Rust CultCache. +epoch record is foreign, or whose types are not a mind's. It admits batches +of pipeline documents through one commit path: the leaf's bounds, formats and +keys, then the organ's cross-document rules (references, in-force status, +the resolution matrix, derived resolutions and stewardships), then one +compare-and-swap that lands the batch whole with a receipt naming the exact +bytes it read and wrote. An exact replay answers with the stored receipt. +Document shape and keys come from `epiphany-pipeline`; the store is CultLib's +Rust CultCache. `huginn-daemon` and `eureka-state` are stubs; the campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns what each crate must do next. diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs new file mode 100644 index 0000000..9c60ff2 --- /dev/null +++ b/crates/huginn-mind/src/admission.rs @@ -0,0 +1,1340 @@ +//! Admission: the single door into a mind. +//! +//! `admit` prepares typed documents through the leaf and calls +//! `admit_prepared`, the one commit path for Cut 8, Cut 10's sink, Cut 12's +//! hand-off and import, and Cut 13's tools. The steps run in order and the +//! first failing step is the outcome: +//! +//! - A1 the batch names this mind; A2 one to sixty-four envelopes; +//! - A3 every envelope passes the leaf's bounds, formats and key +//! recomputation; A4 identities are unique in the batch; A5 every document +//! carrying an instance names this mind; +//! - A6 an empty mind's first batch carries exactly one `instance` document, +//! and a batch carrying one derives the epoch record; +//! - A7 every reference names a document present in the image or the batch, +//! looked up by `(kind, id)`; the ones resolved in the image become the +//! receipt's strong reads; +//! - the derived writes (a ruling's `Answered` resolution, a hand-off's +//! stewardship withdrawal or assignment) are appended and pass A3-A7; +//! - A8 the per-kind rules, below; A9 an exact replay answers with the stored +//! receipt, after validation and never before it (ruling 20); A10 no write +//! collides with the image; A11 the commit. +//! +//! Every cross-field and cross-document rule lives here and nowhere else: +//! the leaf never gains one, the daemon and the client never re-derive one. +//! "In force" means no resolution names the document in image or batch; it +//! is computed at rule time and never stored. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{ + ClaimOutcome, FindingConfidence, Line, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal, + PipelineResolution, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key, + validate_pipeline_write_envelope, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::mind::{HuginnMindEpoch, Mind, unavailable}; +use crate::receipt::{self, CommitOutcome, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The most envelopes one batch may carry. +pub const BATCH_MAX: usize = 64; + +/// One admission request: the mind it is for, who asks, and the documents. +/// The leaf's `PipelineDocument` derives neither `Serialize` nor +/// `JsonSchema` at the pinned rev, so this type cannot either; the wire +/// shape is Cut 10's. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PipelineAdmissionBatch { + pub instance: Slug, + pub provenance: PipelineProvenance, + pub documents: Vec, +} + +/// How admission ended. `Committed.writes` names every document the batch +/// landed, derived writes included; Cut 11 indexes from it after `admit` +/// returns, and never inside it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineAdmissionOutcome { + Committed { receipt_id: String, committed_at: String, writes: Vec }, + AlreadyAdmitted { receipt_id: String }, + Refused(MindRefusal), + Conflict { identities: Vec }, +} + +impl Mind { + /// Validates and prepares every document through the leaf, then admits + /// the envelopes. + pub fn admit(&mut self, batch: PipelineAdmissionBatch, now: DateTime) -> PipelineAdmissionOutcome { + let mut envelopes = Vec::with_capacity(batch.documents.len()); + for document in &batch.documents { + if let Err(refusal) = document.validate() { + return PipelineAdmissionOutcome::Refused(MindRefusal::Document(refusal)); + } + match document.prepare(self.cache()) { + Ok(envelope) => envelopes.push(envelope), + Err(error) => return PipelineAdmissionOutcome::Refused(unavailable(error)), + } + } + self.admit_prepared(&batch.instance, batch.provenance, envelopes, now) + } + + /// The single commit path: envelopes prepared by anyone, validated here + /// whoever prepared them. + pub fn admit_prepared( + &mut self, + instance: &Slug, + provenance: PipelineProvenance, + envelopes: Vec, + now: DateTime, + ) -> PipelineAdmissionOutcome { + match self.admit_steps(instance, provenance, envelopes, now) { + Ok(outcome) => outcome, + Err(refusal) => PipelineAdmissionOutcome::Refused(refusal), + } + } + + fn admit_steps( + &mut self, + instance: &Slug, + provenance: PipelineProvenance, + envelopes: Vec, + now: DateTime, + ) -> Result { + // A1 + if instance != self.instance() { + return Err(MindRefusal::ForeignInstance { declared: instance.0.clone(), mind: self.instance().0.clone() }); + } + // A2 + if envelopes.is_empty() || envelopes.len() > BATCH_MAX { + return Err(MindRefusal::BatchSize { actual: envelopes.len() as u32 }); + } + let mind = self.instance().clone(); + let mut docs = Docs::from_image(self.envelopes())?; + // A3, A5, then A4 on push. + for envelope in envelopes { + docs.push(stage(envelope, &mind)?)?; + } + // A6 + let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1; + if self.is_empty() && !carries_instance { + return Err(MindRefusal::MissingIdentity); + } + // A7 + let mut strong = BTreeSet::new(); + for staged in &docs.batch { + resolve(&docs, staged, &mind, &mut strong)?; + } + // Derived writes pass A3-A7 themselves. + let originals = docs.batch.len(); + for document in derive(&docs, &mind) { + document.validate()?; + let envelope = document.prepare(self.cache()).map_err(unavailable)?; + docs.push(stage(envelope, &mind)?)?; + } + for staged in &docs.batch[originals..] { + resolve(&docs, staged, &mind, &mut strong)?; + } + // The pins: cited image bytes, and every write. + let strong_reads = strong + .iter() + .filter_map(|(type_id, key)| self.raw_envelope(type_id, key).cloned()) + .collect::>(); + let mut writes = docs.batch.iter().map(|staged| staged.envelope.clone()).collect::>(); + if carries_instance { + writes.push(HuginnMindEpoch::envelope(self.cache())?); + } + let candidate = receipt::candidate(&mind, provenance, &strong_reads, &writes, now)?; + // A8 + for staged in &docs.batch { + check(&docs, staged, &mind)?; + } + // A9 + if let Some(existing) = receipt::replay(self, &candidate)? { + return Ok(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: existing.receipt_id }); + } + // A10 + refuse_collisions(&docs)?; + // A11 + let landed = docs.batch.iter().map(|staged| PipelineRef { kind: staged.kind, id: Short(staged.key.clone()) }).collect(); + match receipt::commit(self, candidate, strong_reads, writes)? { + CommitOutcome::Committed(receipt) => Ok(PipelineAdmissionOutcome::Committed { + receipt_id: receipt.receipt_id, + committed_at: receipt.committed_at, + writes: landed, + }), + CommitOutcome::Conflict(identities) => Ok(PipelineAdmissionOutcome::Conflict { identities }), + } + } +} + +/// A batch document after A3 and A5: decoded, keyed, and its envelope. +struct Staged { + kind: PipelineKind, + key: String, + document: PipelineDocument, + envelope: CultCacheEnvelope, +} + +/// An image document, decoded. +struct Held { + kind: PipelineKind, + key: String, + document: PipelineDocument, +} + +/// The image and the batch, the two places a rule may look. +struct Docs { + image: Vec, + batch: Vec, +} + +fn kind_of_type(type_id: &str) -> Option { + PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == type_id) +} + +/// The kind segment of a full id, for the one field typed `Short` whose +/// referent may be of any kind. +fn kind_of_id(id: &str) -> Option { + let name = id.split(':').nth(1)?; + PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name) +} + +impl Docs { + fn from_image(envelopes: &[CultCacheEnvelope]) -> Result { + let mut image = Vec::new(); + for envelope in envelopes { + let Some(kind) = kind_of_type(&envelope.r#type) else { continue }; + let document = PipelineDocument::decode(envelope).map_err(|refusal| MindRefusal::Unavailable { + detail: format!("stored document {} does not decode: {refusal}", envelope.key), + })?; + image.push(Held { kind, key: envelope.key.clone(), document }); + } + Ok(Self { image, batch: Vec::new() }) + } + + /// A4: one identity, once. + fn push(&mut self, staged: Staged) -> Result<(), MindRefusal> { + if self.batch.iter().any(|other| other.kind == staged.kind && other.key == staged.key) { + return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key }); + } + self.batch.push(staged); + Ok(()) + } + + fn in_batch(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.batch.iter().find(|staged| staged.kind == kind && staged.key == id).map(|staged| &staged.document) + } + + fn in_image(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.image.iter().find(|held| held.kind == kind && held.key == id).map(|held| &held.document) + } + + fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.in_batch(kind, id).or_else(|| self.in_image(kind, id)) + } + + fn of_kind(&self, kind: PipelineKind) -> impl Iterator { + let batch = self.batch.iter().filter(move |staged| staged.kind == kind).map(|staged| (staged.key.as_str(), &staged.document)); + let image = self.image.iter().filter(move |held| held.kind == kind).map(|held| (held.key.as_str(), &held.document)); + batch.chain(image) + } + + fn resolutions(&self) -> impl Iterator { + self.of_kind(PipelineKind::Resolution).filter_map(|(_, document)| match document { + PipelineDocument::Resolution(resolution) => Some(resolution), + _ => None, + }) + } + + /// No resolution in image or batch names the document. + fn in_force(&self, kind: PipelineKind, id: &str) -> bool { + self.in_force_unless(kind, id, |_| false) + } + + /// In force, ignoring a resolution the caller's own document derives: + /// the ruling that answers a question is what resolves it, and the + /// hand-off that withdraws a stewardship is what resolves that. + fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&ResolutionOutcome) -> bool) -> bool { + !self + .resolutions() + .any(|resolution| resolution.subject.kind == kind && resolution.subject.id.0 == id && !own(&resolution.outcome)) + } + + /// The in-force stewardship of `(mind, repo)`, if any, ignoring the + /// withdrawal `hand_off_key` derives. + fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> { + self.of_kind(PipelineKind::Stewardship).find_map(|(key, document)| match document { + PipelineDocument::Stewardship(stewardship) + if stewardship.instance == *mind + && stewardship.repo == *repo + && self.in_force_unless(PipelineKind::Stewardship, key, |outcome| { + matches!(outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) => + { + Some((key, stewardship)) + } + _ => None, + }) + } +} + +/// A3 through the leaf, then A5. +fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { + validate_pipeline_write_envelope(&envelope)?; + let document = PipelineDocument::decode(&envelope)?; + refuse_foreign_instance(&document, mind)?; + Ok(Staged { kind: document.kind(), key: envelope.key.clone(), document, envelope }) +} + +/// A5: every document carrying an instance field names this mind; a hand-off +/// names it on one side. +fn refuse_foreign_instance(document: &PipelineDocument, mind: &Slug) -> Result<(), MindRefusal> { + let foreign = |declared: &Slug| MindRefusal::ForeignInstance { declared: declared.0.clone(), mind: mind.0.clone() }; + match document { + PipelineDocument::Instance(value) if value.instance != *mind => Err(foreign(&value.instance)), + PipelineDocument::Stewardship(value) if value.instance != *mind => Err(foreign(&value.instance)), + PipelineDocument::HandOff(value) if value.from_instance != *mind && value.to_instance != *mind => { + Err(foreign(&value.from_instance)) + } + _ => Ok(()), + } +} + +/// What a missing referent is called, by the field that cited it. +enum Missing { + Reference, + SpecOfReport(String), + Supersessor, +} + +struct Reference { + kind: PipelineKind, + id: String, + missing: Missing, +} + +fn reference(kind: PipelineKind, id: &str) -> Reference { + Reference { kind, id: id.to_string(), missing: Missing::Reference } +} + +fn referenced(pipeline_ref: &PipelineRef) -> Reference { + reference(pipeline_ref.kind, &pipeline_ref.id.0) +} + +fn outcome_references(outcome: &ResolutionOutcome) -> Vec { + match outcome { + ResolutionOutcome::Superseded { by } => by + .iter() + .map(|supersessor| Reference { kind: supersessor.kind, id: supersessor.id.0.clone(), missing: Missing::Supersessor }) + .collect(), + ResolutionOutcome::Answered { by } => vec![referenced(by)], + ResolutionOutcome::Fixed { by: Some(by), .. } => vec![referenced(by)], + ResolutionOutcome::Deferred { to } => vec![referenced(to)], + ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => { + vec![] + } + } +} + +/// A7's field list: every `PipelineRef` and every full-id `Short` field. +/// `Fixed.commit` and `ForeignRef` are syntax only. A hand-off's documents +/// are cited on the source side only; the receiving side imports them +/// afterwards (Cut 12). +fn references(staged: &Staged, mind: &Slug) -> Result, MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let mut refs = Vec::new(); + match &staged.document { + D::Question(question) => refs.extend(question.raised_in.as_ref().map(referenced)), + D::Ruling(ruling) => refs.extend(ruling.answers.as_ref().map(|question| reference(K::Question, &question.0))), + D::CutSpec(spec) => { + refs.extend(spec.rulings.iter().map(|ruling| reference(K::Ruling, &ruling.0))); + refs.extend(spec.questions.iter().map(|question| reference(K::Question, &question.0))); + } + D::CutReport(report) => { + refs.push(Reference { + kind: K::CutSpec, + id: report.cut_spec.0.clone(), + missing: Missing::SpecOfReport(staged.key.clone()), + }); + refs.extend(report.forks.iter().map(|question| reference(K::Question, &question.0))); + } + D::Verdict(verdict) => { + refs.push(reference(K::CutReport, &verdict.cut_report.0)); + refs.extend(verdict.claims.iter().flat_map(|claim| claim.findings.iter()).map(|finding| reference(K::Finding, &finding.0))); + } + D::Finding(finding) => refs.push(reference(K::Verdict, &finding.verdict.0)), + D::FollowUp(follow_up) => refs.push(referenced(&follow_up.source)), + D::Resolution(resolution) => { + refs.push(referenced(&resolution.subject)); + refs.extend(outcome_references(&resolution.outcome)); + } + D::HandOff(hand_off) if hand_off.from_instance == *mind => { + for document in &hand_off.documents { + let kind = kind_of_id(&document.0).ok_or_else(|| PipelineRefusal::InvalidFormat { + field: "hand_off.documents".into(), + value: document.0.clone(), + })?; + refs.push(reference(kind, &document.0)); + } + } + D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::HandOff(_) => {} + } + Ok(refs) +} + +/// A7: every referent is in the batch or the image; the image ones are the +/// receipt's strong reads. +fn resolve(docs: &Docs, staged: &Staged, mind: &Slug, strong: &mut BTreeSet<(String, String)>) -> Result<(), MindRefusal> { + for reference in references(staged, mind)? { + if docs.in_batch(reference.kind, &reference.id).is_some() { + continue; + } + if docs.in_image(reference.kind, &reference.id).is_some() { + strong.insert((reference.kind.type_id().to_string(), reference.id)); + continue; + } + return Err(match reference.missing { + Missing::Reference => MindRefusal::MissingReference { kind: reference.kind, id: reference.id }, + Missing::SpecOfReport(report) => MindRefusal::CutReportWithoutSpec { report }, + Missing::Supersessor => MindRefusal::UnknownSupersessor { id: reference.id }, + }); + } + Ok(()) +} + +/// The writes admission derives from the batch: a ruling that answers +/// derives the `Answered` resolution of its question; a hand-off derives +/// the stewardship withdrawal on its source side and the stewardship +/// assignment on its receiving side. One the batch already carries is not +/// derived again. +fn derive(docs: &Docs, mind: &Slug) -> Vec { + use PipelineDocument as D; + use PipelineKind as K; + let mut derived = Vec::new(); + for staged in &docs.batch { + match &staged.document { + D::Ruling(ruling) => { + if let Some(question) = &ruling.answers { + derived.push(D::Resolution(PipelineResolution { + subject: PipelineRef { kind: K::Question, id: question.clone() }, + outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: K::Ruling, id: Short(staged.key.clone()) } }, + rationale: ruling.ruling.clone(), + resolved_on: ruling.ruled_on.clone(), + })); + } + } + D::HandOff(hand_off) => { + if hand_off.from_instance == *mind + && let Some((stewardship_key, _)) = docs.stewardship_of(mind, &hand_off.repo, Some(&staged.key)) + { + derived.push(D::Resolution(PipelineResolution { + subject: PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) }, + outcome: ResolutionOutcome::Withdrawn { reason: Line(staged.key.clone()) }, + rationale: hand_off.reason.clone(), + resolved_on: hand_off.handed_on.clone(), + })); + } + if hand_off.to_instance == *mind { + derived.push(D::Stewardship(PipelineStewardship { + instance: mind.clone(), + repo: hand_off.repo.clone(), + assigned_on: hand_off.handed_on.clone(), + note: Line(staged.key.clone()), + })); + } + } + _ => {} + } + } + derived.retain(|document| !docs.batch.iter().any(|staged| staged.document == *document)); + derived +} + +/// A8: the per-kind rules. +fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let key = staged.key.as_str(); + match &staged.document { + D::Campaign(campaign) => { + if campaign.repos.is_empty() { + return Err(PipelineRefusal::FieldBound { field: "campaign.repos".into(), limit: 1, actual: 0 }.into()); + } + for repo in &campaign.repos { + if docs.stewardship_of(mind, repo, None).is_none() { + return Err(MindRefusal::RepoNotStewarded { repo: repo.0.clone() }); + } + } + Ok(()) + } + D::Target(target) => { + let predecessor = D::Target(epiphany_pipeline::PipelineTarget { revision: target.revision.wrapping_sub(1), ..target.clone() }); + revision_rule(docs, K::Target, key, target.revision, &predecessor)?; + unique_labels("target.invariants", target.invariants.iter().map(|invariant| invariant.label.0.as_str())) + } + D::Question(question) => { + if question.options.len() < 2 || !question.options.iter().any(|option| option.label == question.recommended) { + return Err(MindRefusal::InvalidOptions { question: key.into() }); + } + unique_labels("question.options", question.options.iter().map(|option| option.label.0.as_str())) + } + D::Ruling(ruling) => { + if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator { + return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() }); + } + let Some(question_id) = &ruling.answers else { return Ok(()) }; + let Some(D::Question(question)) = docs.find(K::Question, &question_id.0) else { + return Err(MindRefusal::MissingReference { kind: K::Question, id: question_id.0.clone() }); + }; + let own = |outcome: &ResolutionOutcome| { + matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key) + }; + if !docs.in_force_unless(K::Question, &question_id.0, own) { + return Err(MindRefusal::AlreadyResolved { subject: question_id.0.clone() }); + } + let choice = ruling.choice.as_ref().map_or("", |choice| choice.0.as_str()); + if !question.options.iter().any(|option| option.label.0 == choice) { + return Err(MindRefusal::InvalidChoice { ruling: key.into(), choice: choice.into() }); + } + Ok(()) + } + D::CutSpec(spec) => { + let campaign = docs.of_kind(K::Campaign).find_map(|(_, document)| match document { + D::Campaign(campaign) if campaign.slug == spec.campaign => Some(campaign), + _ => None, + }); + let Some(campaign) = campaign else { + return Err(MindRefusal::MissingReference { kind: K::Campaign, id: spec.campaign.0.clone() }); + }; + if !campaign.repos.contains(&spec.repo) { + return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() }); + } + for ruling in &spec.rulings { + if !docs.in_force(K::Ruling, &ruling.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); + } + } + let predecessor = D::CutSpec(epiphany_pipeline::PipelineCutSpec { revision: spec.revision.wrapping_sub(1), ..spec.clone() }); + revision_rule(docs, K::CutSpec, key, spec.revision, &predecessor) + } + D::CutReport(report) => { + let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &report.cut_spec.0) else { + return Err(MindRefusal::CutReportWithoutSpec { report: key.into() }); + }; + if !docs.in_force(K::CutSpec, &report.cut_spec.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: report.cut_spec.0.clone() }); + } + if report.repo != spec.repo { + return Err(MindRefusal::SpecMismatch { field: "repo".into() }); + } + if report.branch != spec.branch { + return Err(MindRefusal::SpecMismatch { field: "branch".into() }); + } + if !report.commits.iter().any(|commit| commit.sha == report.range.head) { + return Err(MindRefusal::RangeOutsideCommits { head: report.range.head.0.clone() }); + } + Ok(()) + } + D::Verdict(verdict) => { + let Some(D::CutReport(report)) = docs.find(K::CutReport, &verdict.cut_report.0) else { + return Err(MindRefusal::MissingReference { kind: K::CutReport, id: verdict.cut_report.0.clone() }); + }; + for claim in &verdict.claims { + let confirmed = claim.findings.iter().filter_map(|finding| docs.find(K::Finding, &finding.0)).any(|document| { + matches!(document, D::Finding(finding) if finding.confidence == FindingConfidence::Confirmed) + }); + match claim.outcome { + ClaimOutcome::Falsified if !confirmed => { + return Err(MindRefusal::FalsifiedClaimWithoutConfirmedFinding { claim: claim.claim.0.clone() }); + } + ClaimOutcome::Unproven if confirmed => { + return Err(MindRefusal::UnprovenClaimWithConfirmedFinding { claim: claim.claim.0.clone() }); + } + _ => {} + } + for label in &claim.mutations { + if !report.mutations.iter().any(|mutation| mutation.label == *label) { + return Err(MindRefusal::UnknownMutationLabel { label: label.0.clone() }); + } + } + } + for promise in &report.promises { + let count = verdict.claims.iter().filter(|claim| claim.promise.as_ref() == Some(&promise.label)).count(); + if count != 1 { + return Err(MindRefusal::PromiseWithoutVerdict { promise: promise.label.0.clone() }); + } + } + Ok(()) + } + D::Finding(finding) => { + if finding.evidence.is_empty() || finding.locations.is_empty() { + return Err(MindRefusal::FindingWithoutEvidence); + } + let target = docs.of_kind(K::Target).find_map(|(target_key, document)| match document { + D::Target(target) if target.campaign == finding.campaign && docs.in_force(K::Target, target_key) => Some(target), + _ => None, + }); + for label in &finding.invariants { + if !target.is_some_and(|target| target.invariants.iter().any(|invariant| invariant.label == *label)) { + return Err(MindRefusal::UnknownInvariant { label: label.0.clone() }); + } + } + Ok(()) + } + D::Resolution(resolution) => resolution_rule(docs, resolution), + D::HandOff(hand_off) => { + if hand_off.from_instance == *mind && docs.stewardship_of(mind, &hand_off.repo, Some(key)).is_none() { + return Err(MindRefusal::NotStewarded { repo: hand_off.repo.0.clone() }); + } + Ok(()) + } + D::FollowUp(_) | D::Instance(_) | D::Stewardship(_) => Ok(()), + } +} + +/// Revision 1 stands alone; revision N needs a resolution in the batch that +/// supersedes revision N-1 by this document. The predecessor's key is the +/// leaf's, from the same document one revision back. +fn revision_rule(docs: &Docs, kind: PipelineKind, key: &str, revision: u32, predecessor: &PipelineDocument) -> Result<(), MindRefusal> { + if revision == 1 { + return Ok(()); + } + let refusal = || MindRefusal::RevisionWithoutSupersession { kind, revision }; + if revision == 0 { + return Err(refusal()); + } + let predecessor_key = pipeline_key(predecessor)?; + let superseded = docs.batch.iter().any(|staged| { + matches!(&staged.document, PipelineDocument::Resolution(resolution) + if resolution.subject.kind == kind + && resolution.subject.id.0 == predecessor_key + && matches!(&resolution.outcome, ResolutionOutcome::Superseded { by } + if by.iter().any(|supersessor| supersessor.kind == kind && supersessor.id.0 == key))) + }); + if superseded { Ok(()) } else { Err(refusal()) } +} + +fn unique_labels<'a>(field: &str, labels: impl Iterator) -> Result<(), MindRefusal> { + let mut seen = BTreeSet::new(); + for label in labels { + if !seen.insert(label) { + return Err(MindRefusal::DuplicateLabel { field: field.into(), label: label.into() }); + } + } + Ok(()) +} + +fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { + match outcome { + ResolutionOutcome::Superseded { .. } => "Superseded", + ResolutionOutcome::Answered { .. } => "Answered", + ResolutionOutcome::Fixed { .. } => "Fixed", + ResolutionOutcome::Deferred { .. } => "Deferred", + ResolutionOutcome::Recorded { .. } => "Recorded", + ResolutionOutcome::Withdrawn { .. } => "Withdrawn", + } +} + +/// The resolution matrix: which outcomes a subject kind admits, and of which +/// kinds its referents must be. Admission's, never the leaf's. +fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { + use PipelineKind as K; + use ResolutionOutcome as O; + let all = |by: &[PipelineRef], kind: K| by.iter().all(|supersessor| supersessor.kind == kind); + let fits = match (subject, outcome) { + (K::Target, O::Superseded { by }) => all(by, K::Target), + (K::Question, O::Answered { by }) => by.kind == K::Ruling, + (K::Question, O::Withdrawn { .. }) => true, + (K::Ruling, O::Superseded { by }) => all(by, K::Ruling), + (K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec), + (K::CutSpec, O::Withdrawn { .. }) => true, + (K::Finding, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), + (K::Finding, O::Deferred { to }) => to.kind == K::FollowUp, + (K::Finding, O::Recorded { .. } | O::Withdrawn { .. }) => true, + (K::FollowUp, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), + (K::FollowUp, O::Superseded { by }) => all(by, K::FollowUp), + (K::FollowUp, O::Withdrawn { .. }) => true, + (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), + (K::Stewardship, O::Withdrawn { .. }) => true, + (K::Resolution, O::Withdrawn { .. }) => true, + _ => false, + }; + fits +} + +/// The resolution row: the matrix, a non-empty supersession, every referent +/// in force, and the two coherence rules (an `Answered` ruling answers this +/// question; a cut spec is superseded within its cut). +fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let subject_kind = resolution.subject.kind; + let subject_id = resolution.subject.id.0.as_str(); + if let ResolutionOutcome::Superseded { by } = &resolution.outcome && by.is_empty() { + return Err(MindRefusal::EmptySupersession); + } + let incompatible = || MindRefusal::IncompatibleResolution { subject_kind, outcome: outcome_name(&resolution.outcome).into() }; + if !matrix(subject_kind, &resolution.outcome) { + return Err(incompatible()); + } + for referent in outcome_references(&resolution.outcome) { + if !docs.in_force(referent.kind, &referent.id) { + return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id }); + } + } + match &resolution.outcome { + ResolutionOutcome::Superseded { by } if subject_kind == K::CutSpec => { + let Some(D::CutSpec(subject)) = docs.find(K::CutSpec, subject_id) else { return Ok(()) }; + for supersessor in by { + if let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &supersessor.id.0) + && spec.cut != subject.cut + { + return Err(incompatible()); + } + } + } + ResolutionOutcome::Answered { by } => { + let Some(D::Ruling(ruling)) = docs.find(K::Ruling, &by.id.0) else { return Ok(()) }; + if ruling.answers.as_ref().map(|answers| answers.0.as_str()) != Some(subject_id) { + return Err(incompatible()); + } + } + _ => {} + } + Ok(()) +} + +/// A10: a write whose identity the image already holds. A resolution's key +/// is outcome-invariant, so its collision means the subject is already +/// resolved. +fn refuse_collisions(docs: &Docs) -> Result<(), MindRefusal> { + for staged in &docs.batch { + if docs.in_image(staged.kind, &staged.key).is_some() { + return Err(match &staged.document { + PipelineDocument::Resolution(resolution) => MindRefusal::AlreadyResolved { subject: resolution.subject.id.0.clone() }, + _ => MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key.clone() }, + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + use crate::receipt::{DocumentVersion, Faculty, HuginnCommitReceipt}; + use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand}; + use cultcache_rs::DatabaseEntry; + use epiphany_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument as D, PipelineKind as K}; + use sha2::{Digest, Sha256}; + + fn receipt_count(mind: &Mind) -> usize { + mind.receipts().unwrap().len() + } + + #[test] + fn an_empty_store_opens_and_the_first_write_must_carry_the_instance() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + assert!(mind.is_empty()); + assert_eq!(refusal(admit(&mut mind, vec![stewardship(INSTANCE, REPO), campaign(&[REPO])])), MindRefusal::MissingIdentity); + assert!(store.rows().is_empty()); + let (receipt_id, writes) = committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); + assert!(writes.contains(&r(K::Instance, &format!("{INSTANCE}:instance:self")))); + let receipts = mind.receipts().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].receipt_id, receipt_id); + let identities = receipts[0].writes.iter().map(DocumentVersion::identity).collect::>(); + assert!(identities.contains(&(K::Instance.type_id(), format!("{INSTANCE}:instance:self").as_str()))); + assert!(identities.contains(&(HuginnMindEpoch::TYPE, PIPELINE_SCHEMA_EPOCH)), "the epoch record is derived: {identities:?}"); + assert!(receipts[0].strong_reads.is_empty()); + // The store now opens again as this instance, and as nothing else. + assert!(!Mind::open_with(store.clone(), &slug(INSTANCE)).unwrap().is_empty()); + assert!(Mind::open_with(store, &slug(OTHER_INSTANCE)).is_err()); + } + + #[test] + fn admission_refuses_a_foreign_instance_whatever_the_transport() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let foreign = MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() }; + let envelopes = vec![prepare(&question("Q1", &["A", "B"], "A"))]; + assert_eq!( + refusal(mind.admit_prepared(&slug(OTHER_INSTANCE), provenance(Faculty::Hands), envelopes, now())), + foreign + ); + assert_eq!(refusal(admit(&mut mind, vec![stewardship(OTHER_INSTANCE, OTHER_REPO)])), foreign); + assert_eq!( + refusal(admit(&mut mind, vec![hand_off("a", "b", REPO, &[])])), + MindRefusal::ForeignInstance { declared: "a".into(), mind: INSTANCE.into() } + ); + assert_eq!(refusal(admit(&mut mind, vec![instance(OTHER_INSTANCE)])), foreign); + assert_eq!(store.rows(), before); + } + + #[test] + fn two_minds_in_one_state_root_stay_separate() { + let root = tempfile::tempdir().unwrap(); + let mut yggdrasil = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + let mut thought_cage = Mind::open(root.path(), &slug(OTHER_INSTANCE)).unwrap(); + assert_ne!(Mind::path_for(root.path(), &slug(INSTANCE)), Mind::path_for(root.path(), &slug(OTHER_INSTANCE))); + committed(admit(&mut yggdrasil, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); + committed(admit(&mut thought_cage, vec![instance(OTHER_INSTANCE)])); + let key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany"); + assert!(yggdrasil.envelope(K::Stewardship, &key).is_some()); + assert!(thought_cage.envelope(K::Stewardship, &key).is_none()); + assert_eq!(yggdrasil.envelopes().len(), 4); + assert_eq!(thought_cage.envelopes().len(), 3); + } + + #[test] + fn keys_are_recomputed_and_a_forged_key_refuses() { + let mut mind = seeded(); + let mut envelope = prepare(&question("Q1", &["A", "B"], "A")); + let expected = envelope.key.clone(); + envelope.key = format!("{expected}-forged"); + let outcome = mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Hands), vec![envelope.clone()], now()); + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::InvalidIdentity { kind: K::Question, key: envelope.key, expected }) + ); + } + + #[test] + fn references_must_exist_in_image_or_batch() { + let mut mind = seeded(); + let absent_question = id("question", "Q9"); + let mut answering = ruling("R1"); + answering.answers = Some(s(&absent_question)); + answering.choice = Some(l("A")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering)])), + MindRefusal::MissingReference { kind: K::Question, id: absent_question } + ); + let absent_finding = id("finding", "cut-1.s1.F9"); + assert_eq!( + refusal(admit(&mut mind, vec![follow_up("FU-1", r(K::Finding, &absent_finding))])), + MindRefusal::MissingReference { kind: K::Finding, id: absent_finding } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &[])])])), + MindRefusal::MissingReference { kind: K::CutReport, id: id("cut_report", "cut-1.h1") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])), + MindRefusal::MissingReference { kind: K::Question, id: id("question", "Q1") } + ); + // A well-formed id of another kind is a missing reference of the + // cited kind, not a wrong-kind refusal. + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("question", "Q1"))]; + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(spec)])), + MindRefusal::MissingReference { kind: K::Ruling, id: id("question", "Q1") } + ); + } + + #[test] + fn batch_is_all_or_nothing() { + let store = RefusingStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let receipts = receipt_count(&mind); + let mut quoted = ruling("R1"); + quoted.operator_quote = Some("go ahead".into()); + let batch = vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "B"), D::Ruling(quoted)]; + assert_eq!(refusal(admit(&mut mind, batch)), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") }); + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + + store.command(SwapCommand::Lose); + let outcome = admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]); + assert!(matches!(outcome, PipelineAdmissionOutcome::Conflict { .. }), "{outcome:?}"); + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + + store.command(SwapCommand::Fail); + assert!(matches!(refusal(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])), MindRefusal::Unavailable { .. })); + assert_eq!(store.rows(), before); + + store.command(SwapCommand::Delegate); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + assert_eq!(receipt_count(&mind), receipts + 1); + } + + #[test] + fn exact_replay_returns_already_admitted_across_provenance() { + let mut mind = seeded(); + let receipts = receipt_count(&mind); + let (first, _) = committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let batch = PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::Soul), + documents: vec![question("Q1", &["A", "B"], "A")], + }; + let later = now() + chrono::Duration::hours(1); + assert_eq!(mind.admit(batch, later), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first.clone() }); + assert_eq!(receipt_count(&mind), receipts + 1); + // The first write, epoch record and all, replays too. + let seed_batch = PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::Imagination), + documents: vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO]), target(1, &[INVARIANT])], + }; + assert!(matches!(mind.admit(seed_batch, later), PipelineAdmissionOutcome::AlreadyAdmitted { .. })); + assert_eq!(receipt_count(&mind), receipts + 1); + } + + #[test] + fn a_refused_batch_is_not_answered_from_a_stored_receipt() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1"))])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let envelopes = vec![prepare(&D::CutSpec(spec))]; + committed(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Imagination), envelopes.clone(), now())); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("R2")), + resolution(r(K::Ruling, &id("ruling", "R1")), superseded(&[r(K::Ruling, &id("ruling", "R2"))])), + ])); + assert_eq!( + refusal(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Imagination), envelopes, now())), + MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: id("ruling", "R1") } + ); + } + + #[test] + fn a_receipt_names_the_exact_bytes_it_read_and_wrote() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1"))])); + let cited = mind.envelope(K::Ruling, &id("ruling", "R1")).unwrap().clone(); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(spec)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads, vec![DocumentVersion::from_envelope(&cited)]); + let written = mind.envelope(K::CutSpec, &id("cut_spec", "cut-1.r1")).unwrap(); + assert_eq!(receipt.writes, vec![DocumentVersion::from_envelope(written)]); + for version in receipt.strong_reads.iter().chain(&receipt.writes) { + assert_eq!(version.payload_sha256, format!("{:x}", Sha256::digest(&version.payload_msgpack))); + } + let digest = rmp_serde::to_vec_named(&(&receipt.instance, &receipt.strong_reads, &receipt.writes)).unwrap(); + assert_eq!(receipt_id, format!("mind-commit-{:x}", Sha256::digest(&digest))); + + // The same content admitted by another faculty in another mind has + // the same id: provenance is not digested. + let mut twin = opened(MemoryStore::new(), INSTANCE); + seed(&mut twin); + committed(admit(&mut twin, vec![D::Ruling(ruling("R1"))])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let batch = PipelineAdmissionBatch { instance: slug(INSTANCE), provenance: provenance(Faculty::Soul), documents: vec![D::CutSpec(spec)] }; + let (twin_id, _) = committed(twin.admit(batch, now() + chrono::Duration::days(1))); + assert_eq!(twin_id, receipt_id); + let twin_receipt = twin.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == twin_id).unwrap(); + assert_ne!(twin_receipt.provenance, receipt.provenance); + } + + #[test] + fn a_document_is_written_once_and_superseded_by_resolution() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R8"))])); + let mut rewritten = ruling("R8"); + rewritten.ruling = "A repo owns its mind.".into(); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(rewritten)])), + MindRefusal::IdentityCollision { kind: K::Ruling, id: id("ruling", "R8") } + ); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("R9")), + resolution(r(K::Ruling, &id("ruling", "R8")), superseded(&[r(K::Ruling, &id("ruling", "R9"))])), + ])); + assert_eq!(mind.get(K::Ruling, &id("ruling", "R8")).unwrap(), Some(D::Ruling(ruling("R8")))); + } + + #[test] + fn subject_resolves_at_most_once() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering)])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + } + + /// A seeded mind with one document of every resolvable kind, and the + /// non-resolvable ones beside them. + fn world() -> Mind { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + stewardship(INSTANCE, OTHER_REPO), + question("Q1", &["A", "B"], "A"), + D::Ruling(ruling("R1")), + D::CutSpec(cut_spec("1", 1)), + D::CutReport(cut_report("1", 1)), + verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&id("finding", "cut-1.s1.F1")], Some("P1"), &["M1"])]), + D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)), + follow_up("FU-1", r(K::Finding, &id("finding", "cut-1.s1.F1"))), + hand_off(INSTANCE, OTHER_INSTANCE, OTHER_REPO, &[]), + ])); + mind + } + + #[test] + fn the_resolution_matrix_is_admissions() { + let incompatible = |kind: K, outcome: &str| MindRefusal::IncompatibleResolution { subject_kind: kind, outcome: outcome.into() }; + let fixed = || ResolutionOutcome::Fixed { commit: sha(), by: None }; + let target_ref = r(K::Target, &id("target", "r1")); + let target_r2 = r(K::Target, &id("target", "r2")); + let question_ref = r(K::Question, &id("question", "Q1")); + let ruling_ref = r(K::Ruling, &id("ruling", "R1")); + let spec_ref = r(K::CutSpec, &id("cut_spec", "cut-1.r1")); + let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1")); + let follow_up_ref = r(K::FollowUp, &id("follow_up", "FU-1")); + let stewardship_ref = r(K::Stewardship, &format!("{INSTANCE}:stewardship:GameCult_-Epiphany")); + let campaign_ref = r(K::Campaign, &id("campaign", "self")); + let report_ref = r(K::CutReport, &id("cut_report", "cut-1.h1")); + let verdict_ref = r(K::Verdict, &id("verdict", "cut-1.s1")); + let instance_ref = r(K::Instance, &format!("{INSTANCE}:instance:self")); + let hand_off_ref = r(K::HandOff, &format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Huginn.2026-09-16")); + + // Accepted, one per resolvable kind. + committed(admit(&mut world(), vec![target(2, &[INVARIANT]), resolution(target_ref.clone(), superseded(&[target_r2.clone()]))])); + committed(admit(&mut world(), vec![resolution(question_ref.clone(), withdrawn())])); + committed(admit(&mut world(), vec![D::Ruling(ruling("R2")), resolution(ruling_ref.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2"))]))])); + committed(admit(&mut world(), vec![resolution(spec_ref.clone(), withdrawn())])); + committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Recorded { reason: "noted".into() })])); + committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to: follow_up_ref.clone() })])); + committed(admit(&mut world(), vec![resolution(follow_up_ref.clone(), fixed())])); + committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())])); + let mut mind = world(); + committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); + let nested = r(K::Resolution, &id("resolution", "question.Q1")); + committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); + + // Refused, one per kind, and every non-resolvable kind. + assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref, withdrawn())])), incompatible(K::Target, "Withdrawn")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref, fixed())])), incompatible(K::Question, "Fixed")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref, withdrawn())])), incompatible(K::Ruling, "Withdrawn")); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(spec_ref.clone(), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), + incompatible(K::CutSpec, "Answered") + ); + // A cut spec superseded across cuts is refused too. + let mut other_cut = cut_spec("2", 1); + other_cut.cut = l("2"); + assert_eq!( + refusal(admit(&mut world(), vec![D::CutSpec(other_cut), resolution(spec_ref, superseded(&[r(K::CutSpec, &id("cut_spec", "cut-2.r1"))]))])), + incompatible(K::CutSpec, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(finding_ref.clone(), superseded(&[finding_ref.clone()]))])), + incompatible(K::Finding, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(follow_up_ref, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), + incompatible(K::FollowUp, "Answered") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(stewardship_ref, ResolutionOutcome::Recorded { reason: "no".into() })])), + incompatible(K::Stewardship, "Recorded") + ); + let mut mind = world(); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(nested.clone(), superseded(&[nested]))])), + incompatible(K::Resolution, "Superseded") + ); + for (subject, kind) in [(campaign_ref, K::Campaign), (report_ref, K::CutReport), (verdict_ref, K::Verdict), (instance_ref, K::Instance), (hand_off_ref, K::HandOff)] { + assert_eq!(refusal(admit(&mut world(), vec![resolution(subject, withdrawn())])), incompatible(kind, "Withdrawn")); + } + } + + #[test] + fn supersession_names_each_supersessor_and_none_is_empty() { + let subject = r(K::Ruling, &id("ruling", "R1")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(subject.clone(), superseded(&[]))])), MindRefusal::EmptySupersession); + let (_, writes) = committed(admit(&mut world(), vec![ + D::Ruling(ruling("R2")), + D::Ruling(ruling("R3")), + resolution(subject.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2")), r(K::Ruling, &id("ruling", "R3"))])), + ])); + assert_eq!(writes.len(), 3); + assert_eq!( + refusal(admit(&mut world(), vec![D::Ruling(ruling("R2")), resolution(subject.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2")), r(K::Ruling, &id("ruling", "R4"))]))])), + MindRefusal::UnknownSupersessor { id: id("ruling", "R4") } + ); + let mut mind = world(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R2")), D::Ruling(ruling("R3")), resolution(r(K::Ruling, &id("ruling", "R2")), superseded(&[r(K::Ruling, &id("ruling", "R3"))]))])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(subject, superseded(&[r(K::Ruling, &id("ruling", "R2"))]))])), + MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: id("ruling", "R2") } + ); + } + + #[test] + fn a_ruling_answering_a_question_derives_the_answered_resolution_atomically() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("C")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering.clone())])), + MindRefusal::InvalidChoice { ruling: id("ruling", "R1"), choice: "C".into() } + ); + answering.choice = Some(l("A")); + let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!(writes, vec![r(K::Ruling, &id("ruling", "R1")), r(K::Resolution, &id("resolution", "question.Q1"))]); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.writes.len(), 2); + let Some(D::Resolution(derived)) = mind.get(K::Resolution, &id("resolution", "question.Q1")).unwrap() else { panic!() }; + assert_eq!(derived.outcome, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); + let mut again = ruling("R2"); + again.answers = Some(s(&id("question", "Q1"))); + again.choice = Some(l("B")); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(again)])), MindRefusal::AlreadyResolved { subject: id("question", "Q1") }); + } + + #[test] + fn an_operator_quote_requires_operator_authority() { + let mut mind = seeded(); + let mut quoted = ruling("R1"); + quoted.operator_quote = Some("all recommendations, go ahead".into()); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(quoted.clone())])), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") }); + quoted.authority = RulingAuthority::Operator; + committed(admit(&mut mind, vec![D::Ruling(quoted)])); + } + + #[test] + fn a_revision_requires_its_predecessors_supersession_in_the_batch() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![target(2, &[INVARIANT])])), + MindRefusal::RevisionWithoutSupersession { kind: K::Target, revision: 2 } + ); + committed(admit(&mut mind, vec![ + target(2, &[INVARIANT]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 2))])), + MindRefusal::RevisionWithoutSupersession { kind: K::CutSpec, revision: 2 } + ); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 2)), + resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])), + ])); + } + + #[test] + fn a_cut_report_cites_an_in_force_spec_and_agrees_with_it() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])), + MindRefusal::CutReportWithoutSpec { report: id("cut_report", "cut-1.h1") } + ); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + let mut report = cut_report("1", 1); + report.branch = s("another-branch"); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); + let mut report = cut_report("1", 1); + report.range.head = epiphany_pipeline::Sha("abcdef0".into()); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { head: "abcdef0".into() }); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: id("cut_spec", "cut-1.r1") } + ); + } + + #[test] + fn verdict_vocabulary_binds_claims_to_findings_promises_and_mutations() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), D::CutReport(cut_report("1", 1))])); + let finding_id = id("finding", "cut-1.s1.F1"); + let confirmed = D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[], Some("P1"), &[])])])), + MindRefusal::FalsifiedClaimWithoutConfirmedFinding { claim: "Falsified claim".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![ + verdict("1", 1, vec![claim(ClaimOutcome::Unproven, &[&finding_id], Some("P1"), &[])]), + confirmed.clone(), + ])), + MindRefusal::UnprovenClaimWithConfirmedFinding { claim: "Unproven claim".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], None, &[])])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![ + claim(ClaimOutcome::Holds, &[], Some("P1"), &[]), + claim(ClaimOutcome::Holds, &[], Some("P1"), &[]), + ])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &["M9"])])])), + MindRefusal::UnknownMutationLabel { label: "M9".into() } + ); + committed(admit(&mut mind, vec![ + verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&finding_id], Some("P1"), &["M1"])]), + confirmed, + ])); + } + + #[test] + fn a_finding_names_evidence_locations_and_known_invariants() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 1)), + D::CutReport(cut_report("1", 1)), + verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &[])]), + ])); + let mut bare = finding("1", 1, "F1", FindingConfidence::Plausible); + bare.evidence = vec![]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(bare)])), MindRefusal::FindingWithoutEvidence); + let mut unknown = finding("1", 1, "F1", FindingConfidence::Plausible); + unknown.invariants = vec![l("nope")]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(unknown)])), MindRefusal::UnknownInvariant { label: "nope".into() }); + + // A raw envelope without `range` cannot be built from the type, and + // it is the leaf's decode that refuses it, before any organ rule. + #[derive(serde::Serialize)] + struct Rangeless { + campaign: Slug, + verdict: Short, + label: epiphany_pipeline::Label, + confidence: FindingConfidence, + severity: epiphany_pipeline::FindingSeverity, + claim: Line, + invariants: Vec, + locations: Vec, + failure_scenario: epiphany_pipeline::Para, + evidence: Vec, + precedents: Vec, + origin: epiphany_pipeline::FindingOrigin, + } + let whole = finding("1", 1, "F1", FindingConfidence::Plausible); + let rangeless = Rangeless { + campaign: whole.campaign.clone(), + verdict: whole.verdict.clone(), + label: whole.label.clone(), + confidence: whole.confidence, + severity: whole.severity, + claim: whole.claim.clone(), + invariants: whole.invariants.clone(), + locations: whole.locations.clone(), + failure_scenario: whole.failure_scenario.clone(), + evidence: whole.evidence.clone(), + precedents: vec![], + origin: whole.origin, + }; + let mut envelope = prepare(&D::Finding(whole)); + envelope.payload = rmp_serde::to_vec_named(&(rangeless,)).unwrap(); + let outcome = mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Soul), vec![envelope], now()); + assert!( + matches!(refusal(outcome), MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) if field == "payload") + ); + } + + #[test] + fn a_campaign_names_only_repos_this_mind_stewards() { + let mut mind = opened(MemoryStore::new(), INSTANCE); + assert_eq!( + refusal(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])), + MindRefusal::RepoNotStewarded { repo: REPO.into() } + ); + committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO])])); + let mut spec = cut_spec("1", 1); + spec.repo = repo(OTHER_REPO); + assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); + } + + #[test] + fn a_hand_off_derives_this_minds_side_only() { + let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.2026-09-16"); + let campaign_key = id("campaign", "self"); + let stewardship_key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany"); + + let mut source = seeded(); + assert_eq!( + refusal(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, OTHER_REPO, &[])])), + MindRefusal::NotStewarded { repo: OTHER_REPO.into() } + ); + assert_eq!( + refusal(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&id("ruling", "R1")])])), + MindRefusal::MissingReference { kind: K::Ruling, id: id("ruling", "R1") } + ); + let (_, writes) = committed(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])])); + let withdrawal = id_of(&stewardship_key); + assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Resolution, &withdrawal)]); + let Some(D::Resolution(derived)) = source.get(K::Resolution, &withdrawal).unwrap() else { panic!() }; + assert_eq!(derived.outcome, ResolutionOutcome::Withdrawn { reason: Line(key.clone()) }); + assert_eq!(derived.subject, r(K::Stewardship, &stewardship_key)); + + let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE); + committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)])); + let (_, writes) = committed(admit(&mut receiving, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])])); + let assigned = format!("{OTHER_INSTANCE}:stewardship:GameCult_-Epiphany"); + assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Stewardship, &assigned)]); + let Some(D::Stewardship(derived)) = receiving.get(K::Stewardship, &assigned).unwrap() else { panic!() }; + assert_eq!(derived.note, Line(key.clone())); + assert_eq!(derived.instance, slug(OTHER_INSTANCE)); + assert_eq!(source.envelope(K::HandOff, &key).map(|e| &e.payload), receiving.envelope(K::HandOff, &key).map(|e| &e.payload)); + } + + /// The key of the resolution of a stewardship: the subject's root, then + /// its kind and local. + fn id_of(stewardship_key: &str) -> String { + let (root, rest) = stewardship_key.split_once(':').unwrap(); + format!("{root}:resolution:{}", rest.replace(':', ".")) + } + + /// The second construction site of a receipt, deliberately: a receipt + /// planted through the store must be found by replay and must match. + #[test] + fn a_planted_receipt_with_other_content_is_refused_not_replayed() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let envelopes = vec![prepare(&question("Q1", &["A", "B"], "A"))]; + let candidate = receipt::candidate(&slug(INSTANCE), provenance(Faculty::Hands), &[], &envelopes, now()).unwrap(); + let mut planted = HuginnCommitReceipt { writes: vec![], ..candidate.clone() }; + planted.writes = candidate.strong_reads.clone(); + planted.receipt_id = candidate.receipt_id.clone(); + store.plant(mind.cache().prepare_entry_named(&planted.receipt_id, &planted).unwrap().0); + let mut mind = opened(store, INSTANCE); + assert!(matches!( + refusal(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Hands), envelopes, now())), + MindRefusal::Unavailable { .. } + )); + } +} diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index a21a097..0084b2e 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -1,12 +1,31 @@ //! Test fixtures shared by the modules' tests: one valid document of each -//! kind the tests need, prepared through the leaf against the organ's cache. +//! kind, prepared through the leaf against the organ's cache, and a seeded +//! mind to admit into. +use chrono::{DateTime, TimeZone, Utc}; use cultcache_rs::CultCacheEnvelope; -use epiphany_pipeline::{Date, PipelineDocument, PipelineInstance, Short, Slug}; +use epiphany_pipeline::{ + AuthorityMap, ClaimOutcome, CodeLocation, CommitRange, CutVerification, Date, DocRef, Evidence, EvidenceKind, + FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MutationRecord, OrgRepo, PipelineCampaign, + PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFinding, PipelineFollowUp, PipelineHandOff, + PipelineInstance, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, + PipelineStewardship, PipelineTarget, PipelineVerdict, Promise, QuestionOption, ReportCommit, ResolutionOutcome, + RulingAuthority, Sha, Short, Slug, StructuralDelta, TargetInvariant, VerdictClaim, +}; -use crate::mind::{HuginnMindEpoch, schema_cache}; +use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome}; +use crate::mind::{HuginnMindEpoch, Mind, schema_cache}; +use crate::receipt::{Faculty, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; +use crate::store::test_stores::MemoryStore; pub(crate) const INSTANCE: &str = "yggdrasil"; +pub(crate) const OTHER_INSTANCE: &str = "thought-cage"; +pub(crate) const CAMPAIGN: &str = "eureka-state"; +pub(crate) const REPO: &str = "GameCult/Epiphany"; +pub(crate) const OTHER_REPO: &str = "GameCult/Huginn"; +pub(crate) const INVARIANT: &str = "mind-admits"; pub(crate) fn slug(value: &str) -> Slug { value.into() @@ -16,10 +35,59 @@ pub(crate) fn s(value: &str) -> Short { value.into() } +pub(crate) fn l(value: &str) -> Label { + value.into() +} + +pub(crate) fn repo(value: &str) -> OrgRepo { + value.into() +} + pub(crate) fn date() -> Date { Date("2026-09-16".into()) } +pub(crate) fn sha() -> Sha { + Sha("5f98228d".into()) +} + +pub(crate) fn id(kind: &str, local: &str) -> String { + format!("{CAMPAIGN}:{kind}:{local}") +} + +pub(crate) fn r(kind: PipelineKind, id: &str) -> PipelineRef { + PipelineRef { kind, id: s(id) } +} + +fn range() -> CommitRange { + CommitRange { base: sha(), head: sha() } +} + +fn location() -> CodeLocation { + CodeLocation { path: s("crates/huginn-mind/src/admission.rs"), line: 1, end_line: None } +} + +pub(crate) fn evidence() -> Evidence { + Evidence { kind: EvidenceKind::Test, locator: "cargo test".into(), result: "ok".into() } +} + +fn doc_ref() -> DocRef { + DocRef { path: s("notes/eureka-pipeline-state-target.md"), start_line: 1, end_line: 9, commit: sha() } +} + +fn delta() -> StructuralDelta { + StructuralDelta { + lines_added: 0, + lines_removed: 0, + dependencies_added: vec![], + dependencies_removed: vec![], + formats_added: vec![], + formats_removed: vec![], + targets_added: vec![], + targets_removed: vec![], + } +} + pub(crate) fn instance(name: &str) -> PipelineDocument { PipelineDocument::Instance(PipelineInstance { instance: slug(name), @@ -29,6 +97,205 @@ pub(crate) fn instance(name: &str) -> PipelineDocument { }) } +pub(crate) fn stewardship(instance: &str, repo_name: &str) -> PipelineDocument { + PipelineDocument::Stewardship(PipelineStewardship { + instance: slug(instance), + repo: repo(repo_name), + assigned_on: date(), + note: "assigned".into(), + }) +} + +pub(crate) fn campaign(repos: &[&str]) -> PipelineDocument { + PipelineDocument::Campaign(PipelineCampaign { + slug: slug(CAMPAIGN), + title: s("Eureka pipeline state"), + repos: repos.iter().map(|name| repo(name)).collect(), + working_branch: s("codex/eureka-pipeline-state"), + target_doc: doc_ref(), + }) +} + +pub(crate) fn target(revision: u32, invariants: &[&str]) -> PipelineDocument { + PipelineDocument::Target(PipelineTarget { + campaign: slug(CAMPAIGN), + revision, + invariants: invariants + .iter() + .map(|label| TargetInvariant { label: l(label), statement: "Only admission writes.".into() }) + .collect(), + not_in_scope: vec![], + canonical_implementations: vec![], + doc: doc_ref(), + }) +} + +pub(crate) fn question(label: &str, options: &[&str], recommended: &str) -> PipelineDocument { + PipelineDocument::Question(PipelineQuestion { + campaign: slug(CAMPAIGN), + label: l(label), + question: "Who owns the state?".into(), + options: options.iter().map(|option| QuestionOption { label: l(option), text: "an option".into() }).collect(), + recommended: l(recommended), + depends: vec![], + raised_in: None, + asked_on: date(), + }) +} + +pub(crate) fn ruling(label: &str) -> PipelineRuling { + PipelineRuling { + campaign: slug(CAMPAIGN), + label: l(label), + answers: None, + choice: None, + ruling: "An instance owns its mind.".into(), + operator_quote: None, + ruled_on: date(), + precedents: vec![], + authority: RulingAuthority::Standing, + } +} + +pub(crate) fn cut_spec(cut: &str, revision: u32) -> PipelineCutSpec { + PipelineCutSpec { + campaign: slug(CAMPAIGN), + cut: l(cut), + revision, + title: s("A cut"), + repo: repo(REPO), + branch: s("codex/eureka-pipeline-state"), + base: sha(), + depends_on: vec![], + first: vec![], + deletes: vec![], + keeps_moves: vec![], + adds: vec![], + file_changes: vec![], + authority_map: None::, + verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] }, + estimate: delta(), + rulings: vec![], + questions: vec![], + } +} + +pub(crate) fn cut_report(cut: &str, attempt: u32) -> PipelineCutReport { + PipelineCutReport { + campaign: slug(CAMPAIGN), + cut_spec: s(&id("cut_spec", &format!("cut-{cut}.r1"))), + attempt, + repo: repo(REPO), + branch: s("codex/eureka-pipeline-state"), + commits: vec![ReportCommit { sha: sha(), subject: "Land the cut".into(), builds: true }], + range: range(), + verification: vec![evidence()], + mutations: vec![MutationRecord { + label: l("M1"), + rule: "the rule".into(), + location: location(), + before: "before".into(), + after: "after".into(), + commit: sha(), + failed_as_expected: true, + }], + deviations: vec![], + forks: vec![], + structural_delta: delta(), + landed_names: vec![], + undone: vec![], + promises: vec![Promise { label: l("P1"), text: "One derived key per document.".into() }], + } +} + +pub(crate) fn claim(outcome: ClaimOutcome, findings: &[&str], promise: Option<&str>, mutations: &[&str]) -> VerdictClaim { + VerdictClaim { + claim: Line(format!("{outcome:?} claim")), + outcome, + evidence: vec![evidence()], + findings: findings.iter().map(|finding| s(finding)).collect(), + promise: promise.map(l), + mutations: mutations.iter().map(|label| l(label)).collect(), + } +} + +pub(crate) fn verdict(cut: &str, pass: u32, claims: Vec) -> PipelineDocument { + PipelineDocument::Verdict(PipelineVerdict { + campaign: slug(CAMPAIGN), + cut_report: s(&id("cut_report", &format!("cut-{cut}.h1"))), + pass, + range: range(), + claims, + }) +} + +pub(crate) fn finding(cut: &str, pass: u32, label: &str, confidence: FindingConfidence) -> PipelineFinding { + PipelineFinding { + campaign: slug(CAMPAIGN), + verdict: s(&id("verdict", &format!("cut-{cut}.s{pass}"))), + label: l(label), + range: range(), + confidence, + severity: FindingSeverity::High, + claim: "A dotted label composes two keys.".into(), + invariants: vec![l(INVARIANT)], + locations: vec![location()], + failure_scenario: "Two documents claim one key.".into(), + evidence: vec![evidence()], + precedents: vec![], + origin: FindingOrigin::Introduced, + } +} + +pub(crate) fn follow_up(label: &str, source: PipelineRef) -> PipelineDocument { + PipelineDocument::FollowUp(PipelineFollowUp { + campaign: slug(CAMPAIGN), + label: l(label), + source, + repo: repo(REPO), + locations: vec![location()], + item: "Per-kind admission rules.".into(), + why_it_can_wait: "The organ owns admission.".into(), + owner: s("Hands"), + }) +} + +pub(crate) fn resolution(subject: PipelineRef, outcome: ResolutionOutcome) -> PipelineDocument { + PipelineDocument::Resolution(PipelineResolution { + subject, + outcome, + rationale: "Resolved.".into(), + resolved_on: date(), + }) +} + +pub(crate) fn withdrawn() -> ResolutionOutcome { + ResolutionOutcome::Withdrawn { reason: "moot".into() } +} + +pub(crate) fn superseded(by: &[PipelineRef]) -> ResolutionOutcome { + ResolutionOutcome::Superseded { by: by.to_vec() } +} + +pub(crate) fn hand_off(from: &str, to: &str, repo_name: &str, documents: &[&str]) -> PipelineDocument { + PipelineDocument::HandOff(PipelineHandOff { + from_instance: slug(from), + to_instance: slug(to), + repo: repo(repo_name), + documents: documents.iter().map(|document| s(document)).collect(), + reason: "The workstation mind takes the campaign.".into(), + handed_on: date(), + }) +} + +pub(crate) fn provenance(faculty: Faculty) -> PipelineProvenance { + PipelineProvenance { faculty, agent: s("claude"), session: s("session-1"), tool: s("admit") } +} + +pub(crate) fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap() +} + /// The leaf's envelope for a document, keyed and encoded as the organ stores /// it. pub(crate) fn prepare(document: &PipelineDocument) -> CultCacheEnvelope { @@ -40,3 +307,40 @@ pub(crate) fn prepare(document: &PipelineDocument) -> CultCacheEnvelope { pub(crate) fn epoch() -> CultCacheEnvelope { HuginnMindEpoch::envelope(&schema_cache().unwrap()).unwrap() } + +pub(crate) fn opened(store: S, instance: &str) -> Mind { + Mind::open_with(store, &slug(instance)).unwrap() +} + +/// Admits as Hands at the fixed clock. +pub(crate) fn admit(mind: &mut Mind, documents: Vec) -> PipelineAdmissionOutcome { + let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(Faculty::Hands), documents }; + mind.admit(batch, now()) +} + +/// The first write into a fresh yggdrasil mind: its identity, stewardship of +/// the campaign repo, the campaign and a first target with one invariant. +pub(crate) fn seed(mind: &mut Mind) { + let outcome = admit(mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO]), target(1, &[INVARIANT])]); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); +} + +pub(crate) fn seeded() -> Mind { + let mut mind = opened(MemoryStore::new(), INSTANCE); + seed(&mut mind); + mind +} + +pub(crate) fn refusal(outcome: PipelineAdmissionOutcome) -> MindRefusal { + match outcome { + PipelineAdmissionOutcome::Refused(refusal) => refusal, + other => panic!("expected a refusal, got {other:?}"), + } +} + +pub(crate) fn committed(outcome: PipelineAdmissionOutcome) -> (String, Vec) { + match outcome { + PipelineAdmissionOutcome::Committed { receipt_id, writes, .. } => (receipt_id, writes), + other => panic!("expected a commit, got {other:?}"), + } +} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 83e4bf2..c6857b0 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -5,8 +5,10 @@ //! type, a foreign epoch, a missing identity or a foreign identity before it //! attaches anything. `receipt::commit` owns "did a batch enter, whole, with a //! receipt": one compare-and-swap over the store, a receipt naming the exact -//! bytes read and written, and a typed conflict when the swap loses. The -//! admission path (Cut 8's third commit) owns "may this batch enter". +//! bytes read and written, and a typed conflict when the swap loses. +//! `Mind::admit_prepared` owns "may this batch enter": every cross-field and +//! cross-document rule, every derived write, and the replay check, in one +//! ordered path that `admit`, the daemon's sink and the hand-off all share. //! //! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the //! organ registers, prepares, decodes and validates through the leaf's four @@ -15,6 +17,7 @@ //! invariant's mechanism. No function here reads a clock or the environment: //! `now` is passed in. +pub mod admission; pub mod mind; pub mod receipt; pub mod refusal; @@ -23,6 +26,7 @@ pub mod store; #[cfg(test)] pub(crate) mod fixtures; +pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; pub use mind::{HuginnMindEpoch, Mind}; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::MindRefusal; diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index 3680585..31d69f9 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -1,6 +1,6 @@ //! The commit receipt: what a batch read and what it wrote, byte for byte. //! -//! This is the bounded duplicate of `epiphany-core`'s receipt (FU-3), smaller +//! This is the bounded duplicate of Epiphany's Mind receipt (FU-3), smaller //! by design: no authority enum, no companion documents, no `invariant_owner`, //! no `store_id`. Provenance is a field on the receipt, not a companion, and //! the digest excludes it, so an exact replay across sessions and faculties From ca30d3eae5b593474c1fdb122b66a72deb1acf53 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 17:17:00 +0200 Subject: [PATCH 008/100] Pin Cut 8's rules with twenty mutations Cut 8, Huginn half, commit 4 of 4: `tools/eureka-cut8-mutations.psd1`, entries H1-H20, each the exact source change that restores one permissiveness this cut closed and the one test that must fail while it is applied. The suite runs through Epiphany's `tools/eureka-mutations.ps1` with `-Repo F:\Projects\Huginn`; Huginn holds only the entries. H5 is a four-edit entry: `Mind::open` is switched to the transient redb store, which needs a `MindStore` impl the live crate deliberately lacks, so the entry supplies it in `store.rs`. That makes `store.rs` a fourth harness target beyond the three the spec names; the file's header says so. H7 moves the replay check above the per-kind rules rather than above A7, since the digest it looks up needs A7's strong reads to exist. Run 2026-09-16 against `0bd7133`: M0 green with 26 tests, every entry killed by its named test, every target restored byte for byte. Co-Authored-By: Claude Fable 5.1 --- tools/eureka-cut8-mutations.psd1 | 274 +++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 tools/eureka-cut8-mutations.psd1 diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 new file mode 100644 index 0000000..653f5e9 --- /dev/null +++ b/tools/eureka-cut8-mutations.psd1 @@ -0,0 +1,274 @@ +# Cut 8 mutations, H1-H20: each entry restores one permissiveness the cut +# closed and names the test that must fail while it is applied. Run through +# Epiphany's harness from this repo: +# +# $env:CARGO_TARGET_DIR = 'C:\Users\Meta\.cargo-target-codex' +# powershell -File F:\Projects\Epiphany\tools\eureka-mutations.ps1 -Repo F:\Projects\Huginn ` +# -Entries tools/eureka-cut8-mutations.psd1 ` +# -Target crates/huginn-mind/src/mind.rs,crates/huginn-mind/src/receipt.rs,crates/huginn-mind/src/admission.rs,crates/huginn-mind/src/store.rs ` +# -Test 'cargo test -p huginn-mind --lib' +# +# `store.rs` is a target only for H5, whose mutant needs a `MindStore` impl +# for the transient redb store that the live crate deliberately lacks. +@{ + Mutations = @( + @{ + Id = 'H1' + Rule = 'Ruling 14: admission refuses another instance''s identity (A1).' + Test = 'admission::tests::admission_refuses_a_foreign_instance_whatever_the_transport' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if instance != self.instance() {' + New = ' if false {' + } + @{ + Id = 'H2' + Rule = 'Ruling 14: identity lives in the state; the opener compares the stored instance against the declared one.' + Test = 'mind::tests::the_instance_document_is_the_identity_not_the_path' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if stored.instance != *instance {' + New = ' if stored.instance != stored.instance {' + } + @{ + Id = 'H3' + Rule = 'Ruling 14: a mind is never un-owned; an empty mind''s first batch must carry the instance (A6).' + Test = 'admission::tests::an_empty_store_opens_and_the_first_write_must_carry_the_instance' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if self.is_empty() && !carries_instance { + return Err(MindRefusal::MissingIdentity); + } + +'@ + New = '' + } + @{ + Id = 'H4' + Rule = 'Ruling 14: the epoch record is derived on the first write (A6).' + Test = 'admission::tests::an_empty_store_opens_and_the_first_write_must_carry_the_instance' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if carries_instance { + writes.push(HuginnMindEpoch::envelope(self.cache())?); + } + +'@ + New = '' + } + @{ + Id = 'H5' + Rule = 'Ruling 15: one writer; Mind::open chooses the owned store whose lock refuses a second owner.' + Test = 'mind::tests::a_mind_has_one_owner_at_a_time' + Edits = @( + @{ + File = 'crates/huginn-mind/src/store.rs' + Old = 'impl MindStore for OwnedRedbMessagePackBackingStore {' + New = @' +impl MindStore for cultcache_rs::RedbMessagePackBackingStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + cultcache_rs::RedbMessagePackBackingStore::compare_and_swap_batch(self, expected, replacements) + } +} + +impl MindStore for OwnedRedbMessagePackBackingStore { +'@ + } + @{ + File = 'crates/huginn-mind/src/mind.rs' + Old = 'use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore};' + New = 'use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore, RedbMessagePackBackingStore};' + } + @{ + File = 'crates/huginn-mind/src/mind.rs' + Old = 'impl Mind {' + New = 'impl Mind {' + } + @{ + File = 'crates/huginn-mind/src/mind.rs' + Old = ' let store = OwnedRedbMessagePackBackingStore::new(&path).map_err(|error| {' + New = ' let store = RedbMessagePackBackingStore::new(&path).map_err(|error| {' + } + ) + } + @{ + Id = 'H6' + Rule = 'Ruling 20: the opener attaches nothing until every gate passes.' + Test = 'mind::tests::the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' + refuse_foreign_types(&raw)?; + refuse_foreign_epoch(&raw)?; + refuse_foreign_identity(&raw, instance)?; + let (cache, image) = attach(store.clone())?; +'@ + New = @' + let (cache, image) = attach(store.clone())?; + refuse_foreign_types(&raw)?; + refuse_foreign_epoch(&raw)?; + refuse_foreign_identity(&raw, instance)?; +'@ + } + @{ + Id = 'H7' + Rule = 'Ruling 20: validation before replay; A9 runs after the per-kind rules, never before them.' + Test = 'admission::tests::a_refused_batch_is_not_answered_from_a_stored_receipt' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + for staged in &docs.batch { + check(&docs, staged, &mind)?; + } + // A9 + if let Some(existing) = receipt::replay(self, &candidate)? { + return Ok(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: existing.receipt_id }); + } +'@ + New = @' + if let Some(existing) = receipt::replay(self, &candidate)? { + return Ok(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: existing.receipt_id }); + } + for staged in &docs.batch { + check(&docs, staged, &mind)?; + } +'@ + } + @{ + Id = 'H8' + Rule = 'Q2/epoch: a store written at another epoch is refused.' + Test = 'mind::tests::the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if record.schema_epoch != PIPELINE_SCHEMA_EPOCH {' + New = ' if false {' + } + @{ + Id = 'H9' + Rule = 'Opener: a store holding a foreign type is refused before anything else.' + Test = 'mind::tests::the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' + refuse_foreign_types(&raw)?; + +'@ + New = '' + } + @{ + Id = 'H10' + Rule = 'Receipt: an exact replay answers with the stored receipt (A9).' + Test = 'admission::tests::exact_replay_returns_already_admitted_across_provenance' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if let Some(existing) = receipt::replay(self, &candidate)? {' + New = ' if let Some(existing) = receipt::replay(self, &candidate)?.filter(|_| false) {' + } + @{ + Id = 'H11' + Rule = 'Receipt: the digest is content, not author; provenance is never digested.' + Test = 'admission::tests::a_receipt_names_the_exact_bytes_it_read_and_wrote' + File = 'crates/huginn-mind/src/receipt.rs' + Old = ' let bytes = rmp_serde::to_vec_named(&(&self.instance, &self.strong_reads, &self.writes))' + New = ' let bytes = rmp_serde::to_vec_named(&(&self.instance, &self.strong_reads, &self.writes, &self.provenance))' + } + @{ + Id = 'H12' + Rule = 'Receipt: strong reads are the swap''s expectation, pinning the cited bytes (A11).' + Test = 'admission::tests::a_receipt_names_the_exact_bytes_it_read_and_wrote' + File = 'crates/huginn-mind/src/receipt.rs' + Old = ' let expected: &[CultCacheEnvelope] = &strong_reads;' + New = ' let expected: &[CultCacheEnvelope] = &[];' + } + @{ + Id = 'H13' + Rule = 'All-or-nothing: a lost swap is a Conflict, never a Committed (A11).' + Test = 'admission::tests::batch_is_all_or_nothing' + File = 'crates/huginn-mind/src/receipt.rs' + Old = ' if !landed {' + New = ' if false {' + } + @{ + Id = 'H14' + Rule = 'Supersession, not overwrite: a write colliding with the image is refused before the swap (A10).' + Test = 'admission::tests::a_document_is_written_once_and_superseded_by_resolution' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + refuse_collisions(&docs)?; + +'@ + New = '' + } + @{ + Id = 'H15' + Rule = 'The resolution matrix is admission''s: every subject kind admits only its outcomes.' + Test = 'admission::tests::the_resolution_matrix_is_admissions' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + fits +} +'@ + New = @' + true +} +'@ + } + @{ + Id = 'H16' + Rule = '6c: a supersession names at least one supersessor.' + Test = 'admission::tests::supersession_names_each_supersessor_and_none_is_empty' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if let ResolutionOutcome::Superseded { by } = &resolution.outcome && by.is_empty() { + return Err(MindRefusal::EmptySupersession); + } + +'@ + New = '' + } + @{ + Id = 'H17' + Rule = 'Q10 + 6c: every supersessor and outcome referent must exist (A7).' + Test = 'admission::tests::supersession_names_each_supersessor_and_none_is_empty' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + refs.extend(outcome_references(&resolution.outcome)); + +'@ + New = '' + } + @{ + Id = 'H18' + Rule = '6c: an operator quote requires operator authority.' + Test = 'admission::tests::an_operator_quote_requires_operator_authority' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator { + return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() }); + } + +'@ + New = '' + } + @{ + Id = 'H19' + Rule = 'Ruling A: every promise of the cited report is measured by exactly one claim.' + Test = 'admission::tests::verdict_vocabulary_binds_claims_to_findings_promises_and_mutations' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if count != 1 {' + New = ' if count == 0 {' + } + @{ + Id = 'H20' + Rule = '6c: every mutation label a claim names exists in the report.' + Test = 'admission::tests::verdict_vocabulary_binds_claims_to_findings_promises_and_mutations' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + for label in &claim.mutations { + if !report.mutations.iter().any(|mutation| mutation.label == *label) { + return Err(MindRefusal::UnknownMutationLabel { label: label.0.clone() }); + } + } + +'@ + New = '' + } + ) +} From 1cfa81d57103931dbc552ea8898b0dd961daf133 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 17:53:08 +0200 Subject: [PATCH 009/100] Close the epoch record, the minted leaf refusal and the nested resolution Soul's Cut 8 pass found three rules the code only half held and one document that promised more than the crate does. The opener's step 3 said "exactly one epoch record at the current epoch" and checked neither the count nor the key: a record keyed anything at all was read as the epoch record, and a second one was never looked at, so a current record beside a foreign one passed or refused on nothing but the order the store returned them in. Step 3 now counts before it reads, refuses a record under any key but the epoch, and names the defect in `found`: the stored epoch, the offending key, or the count. The campaign row minted `PipelineRefusal::FieldBound` for an empty `repos`. The organ does not get to invent the leaf's refusals; an empty `repos` is admission's own rule and is now `MindRefusal::EmptyRepos`. The matrix admitted `Withdrawn` on a resolution, which made a subject resolvable twice over: withdraw the resolution and the subject is open again. A subject resolves at most once, and a resolution is not itself a subject, so `resolution` joins `campaign`, `cut_report`, `verdict`, `instance` and `hand_off` as not resolvable. The key grammar still spells a resolution of a resolution; admission refuses it here. Self's default ruling on Q17, standing until the operator rules. `README.md` and `AGENTS.md` claimed CultNet publication and a Qdrant connection in the present tense and called the store `.cc`. The daemon is a stub, nothing connects to Qdrant, and the live crate writes `mind.redb`. They say so now. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 10 +++-- README.md | 12 +++--- crates/huginn-mind/src/admission.rs | 34 +++++++++++---- crates/huginn-mind/src/mind.rs | 66 ++++++++++++++++++++++++----- crates/huginn-mind/src/refusal.rs | 1 + 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a5dbce..0f32062 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,12 @@ Studio's job, not Huginn's. it persists and admits an instance's mind, over `cultcache-rs` and `epiphany-pipeline` pinned by git rev. `huginn-daemon` and `eureka-state` are stubs with no dependencies; nothing here publishes or connects yet. -- Owned: an instance's memory documents, their admission, and their CultNet - publication. -- Depends on: Qdrant, directly. Unreachable Qdrant is a loud refusal, never a - fallback store. +- Owned: an instance's memory documents and their admission, in `huginn-mind`, + over a redb CultCache store at `/minds//mind.redb`. + Their CultNet publication is owned here and not built; `huginn-daemon` is a + stub. +- To depend on: Qdrant, directly, once retrieval exists. Unreachable Qdrant is + to be a loud refusal, never a fallback store. No crate connects to it yet. - Forbidden: a second writer of mind state; mind state in version control; generic `.cc` inspection, which is CultCache Studio's; renderer-owned truth. diff --git a/README.md b/README.md index b99689c..e942ff6 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,13 @@ Upstream: `https://github.com/GameCult/Huginn.git` - An instance owns its mind; Huginn owns the state. Huginn is the single writer of an instance's memory documents. No other service, script, or agent writes them. -- Memory documents are CultCache `.cc` state. Huginn persists them through - CultLib's Rust runtime and publishes them as typed documents over CultNet. -- Huginn depends on Qdrant directly for retrieval. When Qdrant is unreachable - Huginn refuses loudly; it does not fall back to a second store or a second - writer. +- Memory documents are CultCache state. `huginn-mind`, the one live crate, + persists them through CultLib's Rust CultCache into a redb store at + `/minds//mind.redb`. Publishing them over CultNet is + `huginn-daemon`'s, and that crate is a stub: nothing here publishes yet. +- Retrieval is to depend on Qdrant directly, refusing loudly when Qdrant is + unreachable rather than falling back to a second store or a second writer. + No crate opens that connection yet. - Mind state is not version-controlled. It lives in Huginn's store, not in any repository. diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 9c60ff2..8847d46 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -465,7 +465,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { match &staged.document { D::Campaign(campaign) => { if campaign.repos.is_empty() { - return Err(PipelineRefusal::FieldBound { field: "campaign.repos".into(), limit: 1, actual: 0 }.into()); + return Err(MindRefusal::EmptyRepos { campaign: key.into() }); } for repo in &campaign.repos { if docs.stewardship_of(mind, repo, None).is_none() { @@ -644,6 +644,12 @@ fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { /// The resolution matrix: which outcomes a subject kind admits, and of which /// kinds its referents must be. Admission's, never the leaf's. +/// +/// A subject resolves at most once, and a resolution is not itself +/// resolvable: `resolution` joins `campaign`, `cut_report`, `verdict`, +/// `instance` and `hand_off` in admitting no outcome at all. The key grammar +/// can still spell the resolution of a resolution; this is where it is +/// refused. (Q17, Self's default ruling pending the operator.) fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { use PipelineKind as K; use ResolutionOutcome as O; @@ -663,7 +669,6 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::FollowUp, O::Withdrawn { .. }) => true, (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), (K::Stewardship, O::Withdrawn { .. }) => true, - (K::Resolution, O::Withdrawn { .. }) => true, _ => false, }; fits @@ -1030,14 +1035,10 @@ mod tests { committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to: follow_up_ref.clone() })])); committed(admit(&mut world(), vec![resolution(follow_up_ref.clone(), fixed())])); committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())])); - let mut mind = world(); - committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); - let nested = r(K::Resolution, &id("resolution", "question.Q1")); - committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); // Refused, one per kind, and every non-resolvable kind. assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref, withdrawn())])), incompatible(K::Target, "Withdrawn")); - assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref, fixed())])), incompatible(K::Question, "Fixed")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref.clone(), fixed())])), incompatible(K::Question, "Fixed")); assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref, withdrawn())])), incompatible(K::Ruling, "Withdrawn")); assert_eq!( refusal(admit(&mut world(), vec![resolution(spec_ref.clone(), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), @@ -1062,8 +1063,16 @@ mod tests { refusal(admit(&mut world(), vec![resolution(stewardship_ref, ResolutionOutcome::Recorded { reason: "no".into() })])), incompatible(K::Stewardship, "Recorded") ); + // A resolution records that a subject resolved; it is not itself a + // subject. The nested key exists and is well formed, and admission + // refuses it anyway, whatever the outcome. let mut mind = world(); - committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); + let nested = r(K::Resolution, &id("resolution", "question.Q1")); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])), + incompatible(K::Resolution, "Withdrawn") + ); assert_eq!( refusal(admit(&mut mind, vec![resolution(nested.clone(), superseded(&[nested]))])), incompatible(K::Resolution, "Superseded") @@ -1273,6 +1282,15 @@ mod tests { MindRefusal::RepoNotStewarded { repo: REPO.into() } ); committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO])])); + // A campaign of no repos is the organ's refusal, not a leaf bound: + // "every repo is stewarded" is vacuous and the campaign steers + // nothing. + let D::Campaign(mut empty) = campaign(&[]) else { panic!() }; + empty.slug = slug("other"); + assert_eq!( + refusal(admit(&mut mind, vec![D::Campaign(empty)])), + MindRefusal::EmptyRepos { campaign: "other:campaign:self".into() } + ); let mut spec = cut_spec("1", 1); spec.repo = repo(OTHER_REPO); assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 926b92d..12f7961 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -185,20 +185,34 @@ fn refuse_foreign_types(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { Ok(()) } -/// Step 3: a non-empty store carries exactly one epoch record, at the epoch -/// this binary writes. +/// Step 3: a non-empty store carries exactly one epoch record, keyed by the +/// epoch it names, at the epoch this binary writes. A store with no epoch +/// record at all is `MissingIdentity`; every other defect is `ForeignEpoch`, +/// whose `found` names the defect: the stored epoch when the value is +/// foreign, the record's key when a record is keyed by anything else, and the +/// record count rendered as `" records"` when more than one is stored. The +/// count is decided before any value is read, so a current record beside a +/// foreign one refuses the same way whichever the store returns first. fn refuse_foreign_epoch(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { if raw.is_empty() { return Ok(()); } + let foreign = |found: String| MindRefusal::ForeignEpoch { found, expected: PIPELINE_SCHEMA_EPOCH.into() }; let mut records = raw.iter().filter(|envelope| envelope.r#type == HuginnMindEpoch::TYPE); let Some(record) = records.next() else { return Err(MindRefusal::MissingIdentity); }; + let extra = records.count(); + if extra > 0 { + return Err(foreign(format!("{} records", extra + 1))); + } + if record.key != PIPELINE_SCHEMA_EPOCH { + return Err(foreign(record.key.clone())); + } let record: HuginnMindEpoch = rmp_serde::from_slice(&record.payload) .map_err(|error| MindRefusal::Unavailable { detail: format!("epoch record does not decode: {error}") })?; if record.schema_epoch != PIPELINE_SCHEMA_EPOCH { - return Err(MindRefusal::ForeignEpoch { found: record.schema_epoch, expected: PIPELINE_SCHEMA_EPOCH.into() }); + return Err(foreign(record.schema_epoch)); } Ok(()) } @@ -260,10 +274,18 @@ mod tests { envelope } - fn foreign_epoch() -> CultCacheEnvelope { + const FOREIGN_EPOCH: &str = "epiphany.pipeline.epoch.v0"; + + /// An epoch record under an arbitrary key naming an arbitrary epoch, so a + /// key defect and a value defect can be planted apart. + fn epoch_record(key: &str, schema_epoch: &str) -> CultCacheEnvelope { let cache = schema_cache().unwrap(); - let record = HuginnMindEpoch { schema_epoch: "epiphany.pipeline.epoch.v0".into() }; - cache.prepare_entry_named("epiphany.pipeline.epoch.v0", &record).unwrap().0 + let record = HuginnMindEpoch { schema_epoch: schema_epoch.into() }; + cache.prepare_entry_named(key, &record).unwrap().0 + } + + fn foreign_epoch() -> CultCacheEnvelope { + epoch_record(FOREIGN_EPOCH, FOREIGN_EPOCH) } #[test] @@ -276,12 +298,27 @@ mod tests { MindRefusal::ForeignStore { r#type: "epiphany.runtime_spine.v47".into() }, ), // Step 3 before step 4: a foreign epoch with no identity at all. + // Its key is foreign too, which is what refuses it here. ( vec![foreign_epoch()], - MindRefusal::ForeignEpoch { - found: "epiphany.pipeline.epoch.v0".into(), - expected: PIPELINE_SCHEMA_EPOCH.into(), - }, + MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 3: the value, under the one key the record may have. + ( + vec![epoch_record(PIPELINE_SCHEMA_EPOCH, FOREIGN_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 3: the current epoch under any other key is not the epoch + // record, whatever it says about itself. + ( + vec![epoch_record("not-the-epoch", PIPELINE_SCHEMA_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: "not-the-epoch".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 3: two records are no record, counted before either value + // is read. + ( + vec![epoch(), epoch_record("second", PIPELINE_SCHEMA_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: "2 records".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, ), // Step 3: an identity without an epoch record. (vec![prepare(&instance(INSTANCE))], MindRefusal::MissingIdentity), @@ -301,6 +338,15 @@ mod tests { assert_eq!(store.pull_count(), 1, "{expected:?}: refused before attaching"); assert_eq!(store.rows(), before, "{expected:?}: bytes unchanged"); } + // A current record beside a foreign one is refused on the count, not + // on whichever of the two the store happens to return first. + let pair = (epoch(), foreign_epoch()); + for rows in [vec![pair.0.clone(), pair.1.clone()], vec![pair.1, pair.0]] { + assert_eq!( + refuse_foreign_epoch(&rows).err(), + Some(MindRefusal::ForeignEpoch { found: "2 records".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }) + ); + } // A store that passes every gate attaches, which is the second pull. let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); let mind = Mind::open_with(store.clone(), &yggdrasil).unwrap(); diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index cba00fa..571f1a2 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -35,6 +35,7 @@ pub enum MindRefusal { InvalidOptions { question: String }, InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, + EmptyRepos { campaign: String }, RepoNotStewarded { repo: String }, RepoNotInCampaign { repo: String }, CutReportWithoutSpec { report: String }, From acd32f35cc64b437914e2e03cd130a887db2454b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 17:58:42 +0200 Subject: [PATCH 010/100] Restore the withdrawn resolution: the operator ruled Q17 the other way The previous commit carried Self's default reading of Q17, that a subject resolves at most once and a resolution is not itself a subject. The operator ruled the other way: a withdrawn resolution is kept, attached to the subject it withdrew, and the subject may then be resolved again. So the matrix row comes back exactly as it was and `the_resolution_matrix_is_admissions` returns to admitting the withdrawal of a resolution. Only the comment above `matrix` is new, and it says whose ruling this is and what it still needs: a second resolution of one subject needs a key the first does not already hold, which is a sequence in the leaf's resolution key and is mapped elsewhere. `EmptyRepos` and the opener's epoch record stand. Co-Authored-By: Claude Fable 5.1 --- crates/huginn-mind/src/admission.rs | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 8847d46..121721c 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -645,11 +645,12 @@ fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { /// The resolution matrix: which outcomes a subject kind admits, and of which /// kinds its referents must be. Admission's, never the leaf's. /// -/// A subject resolves at most once, and a resolution is not itself -/// resolvable: `resolution` joins `campaign`, `cut_report`, `verdict`, -/// `instance` and `hand_off` in admitting no outcome at all. The key grammar -/// can still spell the resolution of a resolution; this is where it is -/// refused. (Q17, Self's default ruling pending the operator.) +/// A resolution is itself resolvable, by withdrawal alone: the operator's +/// ruling on Q17 keeps a withdrawn resolution attached to its subject rather +/// than erasing it, and a subject whose resolution is withdrawn may be +/// resolved again. The second resolution needs a key the first does not +/// already hold, which is a sequence in the leaf's resolution key and is +/// mapped separately. fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { use PipelineKind as K; use ResolutionOutcome as O; @@ -669,6 +670,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::FollowUp, O::Withdrawn { .. }) => true, (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), (K::Stewardship, O::Withdrawn { .. }) => true, + (K::Resolution, O::Withdrawn { .. }) => true, _ => false, }; fits @@ -1035,10 +1037,14 @@ mod tests { committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to: follow_up_ref.clone() })])); committed(admit(&mut world(), vec![resolution(follow_up_ref.clone(), fixed())])); committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())])); + let mut mind = world(); + committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); + let nested = r(K::Resolution, &id("resolution", "question.Q1")); + committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); // Refused, one per kind, and every non-resolvable kind. assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref, withdrawn())])), incompatible(K::Target, "Withdrawn")); - assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref.clone(), fixed())])), incompatible(K::Question, "Fixed")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref, fixed())])), incompatible(K::Question, "Fixed")); assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref, withdrawn())])), incompatible(K::Ruling, "Withdrawn")); assert_eq!( refusal(admit(&mut world(), vec![resolution(spec_ref.clone(), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), @@ -1063,16 +1069,8 @@ mod tests { refusal(admit(&mut world(), vec![resolution(stewardship_ref, ResolutionOutcome::Recorded { reason: "no".into() })])), incompatible(K::Stewardship, "Recorded") ); - // A resolution records that a subject resolved; it is not itself a - // subject. The nested key exists and is well formed, and admission - // refuses it anyway, whatever the outcome. let mut mind = world(); - committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); - let nested = r(K::Resolution, &id("resolution", "question.Q1")); - assert_eq!( - refusal(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])), - incompatible(K::Resolution, "Withdrawn") - ); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); assert_eq!( refusal(admit(&mut mind, vec![resolution(nested.clone(), superseded(&[nested]))])), incompatible(K::Resolution, "Superseded") From 30daff8822d77a87061b7f8b6c11f9adf653f321 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 18:06:51 +0200 Subject: [PATCH 011/100] Pin the nineteen rules Cut 8 held but nothing tested Soul's pass found the suite claimed more than it proved: H1-H20 killed twenty mutations, and nineteen further permissivenesses could be restored with every test still green. A rule no test reaches is a rule the next pass may delete by accident. Two of them were about the receipt's atomicity and its reach, and needed new machinery. A store in `receipt.rs`'s tests records the replacement set of every swap it is asked for, which lets one test say what no assertion over the resulting image could: a commit is one compare-and-swap, and the documents and their receipt go in it together. Splitting the receipt into a second swap now fails. Separately, a batch citing three image documents is pinned against all three, so truncating the strong reads to the first of them fails. The rest are one fixture apiece, each closing a row the per-kind rules already held: stewardship looked up in image as well as batch; the cut report's repo as well as its branch; a promise measured by the claim that names that label; the invariant vocabulary taken from the in-force target and not a superseded one; a question that is answered or withdrawn but never superseded, and a target never answered; the derived stewardship dated by the hand-off; derived writes passing A3-A7, so a derived identity the batch already holds is A4's refusal and not a store error; A4 and A2 themselves; a question's two distinct options and its recommendation among them; unique labels on a question's options and a target's invariants; a finding's locations; and an `Answered` naming the ruling that actually answers its subject. `tools/eureka-cut8-mutations.psd1` grows to H1-H39. H21 and H22 pin the opener's epoch key and count, H23 `EmptyRepos`; the rest are Soul's own survivors, adopted as entries so the thing that found them is the thing that guards them. Harness: M0 green at 31 tests, H1-H39 all killed by their own test. Co-Authored-By: Claude Fable 5.1 --- crates/huginn-mind/src/admission.rs | 148 ++++++++++++++++++- crates/huginn-mind/src/receipt.rs | 78 +++++++++- tools/eureka-cut8-mutations.psd1 | 218 +++++++++++++++++++++++++++- 3 files changed, 438 insertions(+), 6 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 121721c..f89d9d1 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -1043,9 +1043,23 @@ mod tests { committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); // Refused, one per kind, and every non-resolvable kind. - assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref, withdrawn())])), incompatible(K::Target, "Withdrawn")); - assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref, fixed())])), incompatible(K::Question, "Fixed")); - assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref, withdrawn())])), incompatible(K::Ruling, "Withdrawn")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref.clone(), withdrawn())])), incompatible(K::Target, "Withdrawn")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref.clone(), fixed())])), incompatible(K::Question, "Fixed")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref.clone(), withdrawn())])), incompatible(K::Ruling, "Withdrawn")); + // A kind admits the outcomes of its own row and no others: a question + // is answered or withdrawn, never superseded; a target is superseded, + // never answered. + assert_eq!( + refusal(admit(&mut world(), vec![ + question("Q2", &["A", "B"], "A"), + resolution(question_ref, superseded(&[r(K::Question, &id("question", "Q2"))])), + ])), + incompatible(K::Question, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(target_ref, ResolutionOutcome::Answered { by: ruling_ref })])), + incompatible(K::Target, "Answered") + ); assert_eq!( refusal(admit(&mut world(), vec![resolution(spec_ref.clone(), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), incompatible(K::CutSpec, "Answered") @@ -1124,6 +1138,84 @@ mod tests { again.answers = Some(s(&id("question", "Q1"))); again.choice = Some(l("B")); assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(again)])), MindRefusal::AlreadyResolved { subject: id("question", "Q1") }); + + // An explicit `Answered` must name the ruling that actually answers + // its subject: a ruling answering Q1 does not resolve Q2. + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "A")])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + let wrong = resolution(r(K::Question, &id("question", "Q2")), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering), wrong])), + MindRefusal::IncompatibleResolution { subject_kind: K::Question, outcome: "Answered".into() } + ); + } + + #[test] + fn a_batch_is_one_to_sixty_four_documents_each_with_its_own_identity() { + let mut mind = seeded(); + // A2 at both ends, and at the boundary the batch is admitted whole. + assert_eq!(refusal(admit(&mut mind, vec![])), MindRefusal::BatchSize { actual: 0 }); + let over = (0..65).map(|index| question(&format!("Q{index}"), &["A", "B"], "A")).collect::>(); + assert_eq!(refusal(admit(&mut mind, over)), MindRefusal::BatchSize { actual: 65 }); + let full = (0..BATCH_MAX).map(|index| question(&format!("Q{index}"), &["A", "B"], "A")).collect::>(); + let (_, writes) = committed(admit(&mut mind, full)); + assert_eq!(writes.len(), BATCH_MAX); + // A4: one identity, once, whatever the two documents say. + assert_eq!( + refusal(admit(&mut mind, vec![question("Z", &["A", "B"], "A"), question("Z", &["A", "B"], "B")])), + MindRefusal::IdentityCollision { kind: K::Question, id: id("question", "Z") } + ); + } + + #[test] + fn a_question_offers_at_least_two_options_and_recommends_one_of_them() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![question("Q7", &["A"], "A")])), + MindRefusal::InvalidOptions { question: id("question", "Q7") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![question("Q8", &["A", "B"], "C")])), + MindRefusal::InvalidOptions { question: id("question", "Q8") } + ); + committed(admit(&mut mind, vec![question("Q9", &["A", "B"], "B")])); + } + + #[test] + fn a_label_names_one_option_and_one_invariant() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![question("Q1", &["A", "A"], "A")])), + MindRefusal::DuplicateLabel { field: "question.options".into(), label: "A".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![ + target(2, &["x", "x"]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])), + MindRefusal::DuplicateLabel { field: "target.invariants".into(), label: "x".into() } + ); + } + + /// Every A7 reference the image resolved is pinned, not the first of + /// them, so a batch citing three documents cannot commit over a change to + /// the other two. + #[test] + fn every_cited_image_document_is_a_strong_read() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1")), D::Ruling(ruling("R2")), D::Ruling(ruling("R3"))])); + let cited = ["R1", "R2", "R3"].map(|label| mind.envelope(K::Ruling, &id("ruling", label)).unwrap().clone()); + let mut spec = cut_spec("1", 1); + spec.rulings = cited.iter().map(|envelope| s(&envelope.key)).collect(); + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(spec)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads.len(), 3); + for envelope in &cited { + assert!(receipt.strong_reads.contains(&DocumentVersion::from_envelope(envelope)), "{}", envelope.key); + } } #[test] @@ -1170,6 +1262,9 @@ mod tests { report.branch = s("another-branch"); assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); let mut report = cut_report("1", 1); + report.repo = repo(OTHER_REPO); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "repo".into() }); + let mut report = cut_report("1", 1); report.range.head = epiphany_pipeline::Sha("abcdef0".into()); assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { head: "abcdef0".into() }); committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), withdrawn())])); @@ -1207,6 +1302,12 @@ mod tests { ])])), MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } ); + // A promise is measured by the claim that names it, not by any claim + // that names a promise: the report promises P1 and nothing else. + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P2"), &[])])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); assert_eq!( refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &["M9"])])])), MindRefusal::UnknownMutationLabel { label: "M9".into() } @@ -1228,6 +1329,11 @@ mod tests { let mut bare = finding("1", 1, "F1", FindingConfidence::Plausible); bare.evidence = vec![]; assert_eq!(refusal(admit(&mut mind, vec![D::Finding(bare)])), MindRefusal::FindingWithoutEvidence); + // Locations are evidence's other half: a finding that names no place + // in the code is refused the same way. + let mut placeless = finding("1", 1, "F1", FindingConfidence::Plausible); + placeless.locations = vec![]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(placeless)])), MindRefusal::FindingWithoutEvidence); let mut unknown = finding("1", 1, "F1", FindingConfidence::Plausible); unknown.invariants = vec![l("nope")]; assert_eq!(refusal(admit(&mut mind, vec![D::Finding(unknown)])), MindRefusal::UnknownInvariant { label: "nope".into() }); @@ -1270,6 +1376,21 @@ mod tests { assert!( matches!(refusal(outcome), MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) if field == "payload") ); + + // The in-force target owns the invariant vocabulary. Supersede r1 with + // an r2 that drops its label, and the label is unknown again; r2's own + // label is what a finding may cite. + committed(admit(&mut mind, vec![ + target(2, &["other"]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])); + assert_eq!( + refusal(admit(&mut mind, vec![D::Finding(finding("1", 1, "F2", FindingConfidence::Plausible))])), + MindRefusal::UnknownInvariant { label: INVARIANT.into() } + ); + let mut current = finding("1", 1, "F3", FindingConfidence::Plausible); + current.invariants = vec![l("other")]; + committed(admit(&mut mind, vec![D::Finding(current)])); } #[test] @@ -1289,6 +1410,11 @@ mod tests { refusal(admit(&mut mind, vec![D::Campaign(empty)])), MindRefusal::EmptyRepos { campaign: "other:campaign:self".into() } ); + // The stewardship may be held in the image: a later campaign over the + // same repo carries no stewardship of its own. + let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; + second.slug = slug("second"); + committed(admit(&mut mind, vec![D::Campaign(second)])); let mut spec = cut_spec("1", 1); spec.repo = repo(OTHER_REPO); assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); @@ -1324,7 +1450,23 @@ mod tests { let Some(D::Stewardship(derived)) = receiving.get(K::Stewardship, &assigned).unwrap() else { panic!() }; assert_eq!(derived.note, Line(key.clone())); assert_eq!(derived.instance, slug(OTHER_INSTANCE)); + // The stewardship starts the day the repo was handed over, not on some + // clock of admission's own. + assert_eq!(derived.assigned_on, date()); assert_eq!(source.envelope(K::HandOff, &key).map(|e| &e.payload), receiving.envelope(K::HandOff, &key).map(|e| &e.payload)); + + // A derived write passes A3-A7 like any other. A batch carrying its own + // stewardship of the repo collides with the derived one at A4, as a + // typed refusal, not as a duplicate identity the store complains about. + let mut colliding = opened(MemoryStore::new(), OTHER_INSTANCE); + committed(admit(&mut colliding, vec![instance(OTHER_INSTANCE)])); + assert_eq!( + refusal(admit(&mut colliding, vec![ + hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]), + stewardship(OTHER_INSTANCE, REPO), + ])), + MindRefusal::IdentityCollision { kind: K::Stewardship, id: assigned } + ); } /// The key of the resolution of a stewardship: the subject's root, then diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index 31d69f9..a2e8066 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -259,9 +259,83 @@ pub(crate) fn sha256_hex(bytes: &[u8]) -> String { #[cfg(test)] mod tests { + use std::sync::{Arc, Mutex}; + use super::*; - use crate::fixtures::{INSTANCE, instance, prepare}; - use epiphany_pipeline::{PipelineDocument, PipelineRefusal}; + use crate::fixtures::{INSTANCE, admit, committed, id, instance, opened, prepare, question, seed}; + use crate::store::test_stores::MemoryStore; + use cultcache_rs::{CacheBackingStore, PushAllOptions}; + use epiphany_pipeline::{PipelineDocument, PipelineKind, PipelineRefusal}; + + /// A memory store that keeps the replacement set of every swap it was + /// asked for, so a test can count the commit's calls as well as read + /// their contents. + #[derive(Clone)] + struct RecordingStore { + inner: MemoryStore, + calls: Arc>>>, + } + + impl RecordingStore { + fn new() -> Self { + Self { inner: MemoryStore::new(), calls: Arc::new(Mutex::new(Vec::new())) } + } + + fn forget(&self) { + self.calls.lock().unwrap().clear(); + } + + fn calls(&self) -> Vec> { + self.calls.lock().unwrap().clone() + } + } + + impl CacheBackingStore for RecordingStore { + fn pull_all(&self) -> anyhow::Result> { + self.inner.pull_all() + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result<()> { + self.inner.push(entry) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result<()> { + self.inner.delete(entry) + } + + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> anyhow::Result<()> { + self.inner.push_all(entries, options) + } + } + + impl MindStore for RecordingStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> anyhow::Result { + self.calls.lock().unwrap().push(replacements.clone()); + self.inner.compare_and_swap_batch(expected, replacements) + } + } + + /// The receipt is part of the batch's own swap, never a second one after + /// it: a receipt written separately could land while the documents did + /// not, or the reverse. + #[test] + fn a_commit_is_one_swap_carrying_the_documents_and_the_receipt_together() { + let store = RecordingStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + store.forget(); + let (receipt_id, _) = committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let calls = store.calls(); + assert_eq!(calls.len(), 1, "one commit, one swap"); + let landed = calls[0].iter().map(|entry| (entry.r#type.clone(), entry.key.clone())).collect::>(); + assert_eq!(landed.len(), 2, "{landed:?}"); + assert!(landed.contains(&(PipelineKind::Question.type_id().to_string(), id("question", "Q1"))), "{landed:?}"); + assert!(landed.contains(&(HuginnCommitReceipt::TYPE.to_string(), receipt_id)), "{landed:?}"); + } /// S5 closed against the real type: the organ's own receipt in a mind's /// store is never decoded as a pipeline document. diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index 653f5e9..9086272 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -1,4 +1,4 @@ -# Cut 8 mutations, H1-H20: each entry restores one permissiveness the cut +# Cut 8 mutations, H1-H39: each entry restores one permissiveness the cut # closed and names the test that must fail while it is applied. Run through # Epiphany's harness from this repo: # @@ -270,5 +270,221 @@ impl MindStore for OwnedRedbMessagePackBackingStore { '@ New = '' } + @{ + Id = 'H21' + Rule = 'Opener step 3: the epoch record is the record under the epoch''s own key; any other key is not it.' + Test = 'mind::tests::the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' + if record.key != PIPELINE_SCHEMA_EPOCH { + return Err(foreign(record.key.clone())); + } +'@ + New = '' + } + @{ + Id = 'H22' + Rule = 'Opener step 3: exactly one epoch record, counted before any value is read, so the store''s order cannot decide the outcome.' + Test = 'mind::tests::the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching' + File = 'crates/huginn-mind/src/mind.rs' + Old = @' + let extra = records.count(); + if extra > 0 { + return Err(foreign(format!("{} records", extra + 1))); + } +'@ + New = '' + } + @{ + Id = 'H23' + Rule = 'campaign: a campaign of no repos is refused, and by the organ''s own refusal (EmptyRepos), not a minted leaf bound.' + Test = 'admission::tests::a_campaign_names_only_repos_this_mind_stewards' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if campaign.repos.is_empty() { + return Err(MindRefusal::EmptyRepos { campaign: key.into() }); + } +'@ + New = '' + } + @{ + Id = 'H24' + Rule = 'A11: the receipt lands inside the batch''s own compare-and-swap, never in a second one after it.' + Test = 'receipt::tests::a_commit_is_one_swap_carrying_the_documents_and_the_receipt_together' + File = 'crates/huginn-mind/src/receipt.rs' + Old = @' + replacements.push(receipt_envelope); + let expected: &[CultCacheEnvelope] = &strong_reads; + let landed = MindStore::compare_and_swap_batch(mind.store(), expected, replacements).map_err(unavailable)?; +'@ + New = @' + let expected: &[CultCacheEnvelope] = &strong_reads; + let landed = MindStore::compare_and_swap_batch(mind.store(), expected, replacements).map_err(unavailable)?; + let landed = landed && MindStore::compare_and_swap_batch(mind.store(), &[], vec![receipt_envelope]).map_err(unavailable)?; +'@ + } + @{ + Id = 'H25' + Rule = 'A7: every reference the image resolved is pinned as a strong read, not the first of them.' + Test = 'admission::tests::every_cited_image_document_is_a_strong_read' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + .filter_map(|(type_id, key)| self.raw_envelope(type_id, key).cloned()) + .collect::>(); +'@ + New = @' + .filter_map(|(type_id, key)| self.raw_envelope(type_id, key).cloned()) + .take(1) + .collect::>(); +'@ + } + @{ + Id = 'H26' + Rule = 'campaign: stewardship is looked for in image and batch, not in the batch alone.' + Test = 'admission::tests::a_campaign_names_only_repos_this_mind_stewards' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if docs.stewardship_of(mind, repo, None).is_none() {' + New = ' if !docs.batch.iter().any(|staged| matches!(&staged.document, D::Stewardship(stewardship) if stewardship.repo == *repo)) {' + } + @{ + Id = 'H27' + Rule = 'cut_report: the report''s repo must equal its spec''s, not only its branch.' + Test = 'admission::tests::a_cut_report_cites_an_in_force_spec_and_agrees_with_it' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if report.repo != spec.repo { + return Err(MindRefusal::SpecMismatch { field: "repo".into() }); + } +'@ + New = '' + } + @{ + Id = 'H28' + Rule = 'Ruling A: a promise is measured by the claim naming that label, not by any claim naming any promise.' + Test = 'admission::tests::verdict_vocabulary_binds_claims_to_findings_promises_and_mutations' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' let count = verdict.claims.iter().filter(|claim| claim.promise.as_ref() == Some(&promise.label)).count();' + New = ' let count = verdict.claims.iter().filter(|claim| claim.promise.is_some()).count();' + } + @{ + Id = 'H29' + Rule = 'finding: the invariant vocabulary is the in-force target''s, not any target the campaign ever had.' + Test = 'admission::tests::a_finding_names_evidence_locations_and_known_invariants' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' D::Target(target) if target.campaign == finding.campaign && docs.in_force(K::Target, target_key) => Some(target),' + New = ' D::Target(target) if target.campaign == finding.campaign && (docs.in_force(K::Target, target_key) || true) => Some(target),' + } + @{ + Id = 'H30' + Rule = 'The matrix is one row per kind: a question is answered or withdrawn, never superseded.' + Test = 'admission::tests::the_resolution_matrix_is_admissions' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + (K::Question, O::Withdrawn { .. }) => true, +'@ + New = @' + (K::Question, O::Withdrawn { .. }) => true, + (K::Question, O::Superseded { by }) => all(by, K::Question), +'@ + } + @{ + Id = 'H31' + Rule = 'hand_off: the derived stewardship starts on the day the repo was handed over.' + Test = 'admission::tests::a_hand_off_derives_this_minds_side_only' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' assigned_on: hand_off.handed_on.clone(),' + New = ' assigned_on: epiphany_pipeline::Date("2026-01-01".into()),' + } + @{ + Id = 'H32' + Rule = 'Derived writes pass A3-A7 like any other; a derived identity the batch already holds is A4''s refusal, not a store error.' + Test = 'admission::tests::a_hand_off_derives_this_minds_side_only' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + for document in derive(&docs, &mind) { + document.validate()?; + let envelope = document.prepare(self.cache()).map_err(unavailable)?; + docs.push(stage(envelope, &mind)?)?; + } + for staged in &docs.batch[originals..] { + resolve(&docs, staged, &mind, &mut strong)?; + } +'@ + New = @' + for document in derive(&docs, &mind) { + let envelope = document.prepare(self.cache()).map_err(unavailable)?; + docs.batch.push(Staged { kind: document.kind(), key: envelope.key.clone(), document, envelope }); + } + let _ = originals; +'@ + } + @{ + Id = 'H33' + Rule = 'A4: one identity, once, within a batch.' + Test = 'admission::tests::a_batch_is_one_to_sixty_four_documents_each_with_its_own_identity' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if self.batch.iter().any(|other| other.kind == staged.kind && other.key == staged.key) { + return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key }); + } +'@ + New = '' + } + @{ + Id = 'H34' + Rule = 'A2: one to sixty-four envelopes, both ends checked.' + Test = 'admission::tests::a_batch_is_one_to_sixty_four_documents_each_with_its_own_identity' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if envelopes.is_empty() || envelopes.len() > BATCH_MAX {' + New = ' if false {' + } + @{ + Id = 'H35' + Rule = 'question: a question offers a real choice, at least two options, one of them the recommendation.' + Test = 'admission::tests::a_question_offers_at_least_two_options_and_recommends_one_of_them' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if question.options.len() < 2 || !question.options.iter().any(|option| option.label == question.recommended) { + return Err(MindRefusal::InvalidOptions { question: key.into() }); + } +'@ + New = '' + } + @{ + Id = 'H36' + Rule = 'question: an option label names one option.' + Test = 'admission::tests::a_label_names_one_option_and_one_invariant' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' unique_labels("question.options", question.options.iter().map(|option| option.label.0.as_str()))' + New = ' Ok(())' + } + @{ + Id = 'H37' + Rule = 'target: an invariant label names one invariant.' + Test = 'admission::tests::a_label_names_one_option_and_one_invariant' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' unique_labels("target.invariants", target.invariants.iter().map(|invariant| invariant.label.0.as_str()))' + New = ' Ok(())' + } + @{ + Id = 'H38' + Rule = 'finding: a finding names where in the code it lives, not only that evidence exists.' + Test = 'admission::tests::a_finding_names_evidence_locations_and_known_invariants' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if finding.evidence.is_empty() || finding.locations.is_empty() {' + New = ' if finding.evidence.is_empty() {' + } + @{ + Id = 'H39' + Rule = 'resolution: an Answered names the ruling that answers this subject, not some other question''s ruling.' + Test = 'admission::tests::a_ruling_answering_a_question_derives_the_answered_resolution_atomically' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if ruling.answers.as_ref().map(|answers| answers.0.as_str()) != Some(subject_id) { + return Err(incompatible()); + } +'@ + New = ' let _ = (ruling, subject_id);' + } ) } From 3efc0dc82d0c60445a32c540104bab49978d0e38 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 19:28:17 +0200 Subject: [PATCH 012/100] Take the sequenced keys and make in force mean what it says The pin moves to Epiphany `d5a36c2a`, where a resolution's key ends in its per-subject sequence and a stewardship's in its per-(instance, repo) one. Two fields arrive with it, so `stewardship` and `resolution` in the fixtures set `sequence: 1` and `stewardship_n`/`resolution_n` say otherwise when a test needs a second record. Every literal key that gained `.n1` moved with it, `id_of` included. The rules the sequence was for: - `in_force` is recursive. A document is in force when no resolution that is itself in force names it, so a withdrawn resolution stops closing its subject. The old one-level version counted a withdrawal as if it still stood, which is half of why a reopened subject could not be resolved again. - The other half was A10. `refuse_collisions` no longer has a `Resolution` arm: a resolution key is not one-per-subject any more, so a collision on it means what it means for every other kind, and whether a subject is closed is A8's question. - A8 asks it, for both sequenced kinds, in the same two steps. The sequence is the writer's and must be the previous plus one over image and batch, counting every record of that scope but this document -- `ResolutionOutOfSequence`, `StewardshipOutOfSequence`. Then no record of that scope may still be in force -- `AlreadyResolved`, `AlreadyStewarded`. With both, at most one stewardship of a repo stands on a mind, which is what `stewardship_of` has been assuming. - Q19 A is enforced rather than described: a resolution whose subject is a resolution of a resolution is `IncompatibleResolution`. A withdrawal cannot be withdrawn; a closure is reinstated by resolving the subject again. - `derive` sets a sequence on each write it makes, from the same image the rule reads. Two existing tests move with the rules. `subject_resolves_at_most_once` is now `a_subject_with_a_resolution_in_force_refuses_another` and separates the two refusals. The hand-off test's last case used to be an A4 collision between a batch's own stewardship and the derived one; the derived write now takes the sequence after the batch's, so there is nothing left to collide and the batch is refused as out of sequence instead. 31 tests, 0 warnings; `cargo check --workspace` clean. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 +- crates/huginn-mind/Cargo.toml | 2 +- crates/huginn-mind/src/admission.rs | 216 ++++++++++++++++++++-------- crates/huginn-mind/src/fixtures.rs | 16 +++ crates/huginn-mind/src/refusal.rs | 8 ++ 5 files changed, 181 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 233ae59..7b31144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,7 +146,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "epiphany-pipeline" version = "0.1.0" -source = "git+https://github.com/GameCult/Epiphany.git?rev=a65c6420f681d6641b842f94b319f0fa14166c96#a65c6420f681d6641b842f94b319f0fa14166c96" +source = "git+https://github.com/GameCult/Epiphany.git?rev=d5a36c2a291b910539072717d42d61a9aa7485d1#d5a36c2a291b910539072717d42d61a9aa7485d1" dependencies = [ "anyhow", "chrono", diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index 9c1ef96..b4abed4 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -14,7 +14,7 @@ publish.workspace = true anyhow = "1" chrono = "0.4.44" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" } -epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "a65c6420f681d6641b842f94b319f0fa14166c96", package = "epiphany-pipeline" } +epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "d5a36c2a291b910539072717d42d61a9aa7485d1", package = "epiphany-pipeline" } rmp-serde = "1" schemars = "1" serde = { version = "1", features = ["derive"] } diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index f89d9d1..1a2dd5d 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -22,8 +22,9 @@ //! //! Every cross-field and cross-document rule lives here and nowhere else: //! the leaf never gains one, the daemon and the client never re-derive one. -//! "In force" means no resolution names the document in image or batch; it -//! is computed at rule time and never stored. +//! "In force" is recursive: a document is in force when no resolution that is +//! itself in force names it, so a withdrawn resolution stops closing its +//! subject. It is computed at rule time over image and batch, never stored. use std::collections::BTreeSet; @@ -245,42 +246,80 @@ impl Docs { batch.chain(image) } - fn resolutions(&self) -> impl Iterator { - self.of_kind(PipelineKind::Resolution).filter_map(|(_, document)| match document { - PipelineDocument::Resolution(resolution) => Some(resolution), + fn resolutions(&self) -> impl Iterator { + self.of_kind(PipelineKind::Resolution).filter_map(|(key, document)| match document { + PipelineDocument::Resolution(resolution) => Some((key, resolution)), _ => None, }) } - /// No resolution in image or batch names the document. + fn stewardships(&self) -> impl Iterator { + self.of_kind(PipelineKind::Stewardship).filter_map(|(key, document)| match document { + PipelineDocument::Stewardship(stewardship) => Some((key, stewardship)), + _ => None, + }) + } + + /// The greatest sequence among a subject's resolutions in image or batch, + /// `0` when it has none, so the next is `latest + 1`. `own` is the + /// document being checked, excluded by content identity so that an exact + /// replay measures the same `latest` the first admission did. + fn latest_resolution(&self, subject: &PipelineRef, own: Option<&PipelineResolution>) -> u32 { + self.resolutions() + .filter(|(_, resolution)| resolution.subject == *subject && own != Some(*resolution)) + .map(|(_, resolution)| resolution.sequence) + .max() + .unwrap_or(0) + } + + /// The same for a repo's assignments to one mind. + fn latest_stewardship(&self, mind: &Slug, repo: &OrgRepo, own: Option<&PipelineStewardship>) -> u32 { + self.stewardships() + .filter(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && own != Some(*stewardship)) + .map(|(_, stewardship)| stewardship.sequence) + .max() + .unwrap_or(0) + } + + /// No resolution that is itself in force names the document. The + /// recursion is the whole rule: a withdrawn resolution stops counting, so + /// its subject is open again. `key(R)` is strictly longer than the id it + /// resolves, so the chain is bounded and this terminates. fn in_force(&self, kind: PipelineKind, id: &str) -> bool { self.in_force_unless(kind, id, |_| false) } - /// In force, ignoring a resolution the caller's own document derives: - /// the ruling that answers a question is what resolves it, and the - /// hand-off that withdraws a stewardship is what resolves that. - fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&ResolutionOutcome) -> bool) -> bool { - !self - .resolutions() - .any(|resolution| resolution.subject.kind == kind && resolution.subject.id.0 == id && !own(&resolution.outcome)) + /// In force, ignoring a resolution the caller's own document is or + /// derives: the ruling that answers a question is what resolves it, the + /// hand-off that withdraws a stewardship is what resolves that, and a + /// resolution does not resolve its own subject out from under itself. + fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&PipelineResolution) -> bool) -> bool { + !self.resolutions().any(|(key, resolution)| { + resolution.subject.kind == kind + && resolution.subject.id.0 == id + && !own(resolution) + && self.in_force(PipelineKind::Resolution, key) + }) } - /// The in-force stewardship of `(mind, repo)`, if any, ignoring the - /// withdrawal `hand_off_key` derives. - fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> { - self.of_kind(PipelineKind::Stewardship).find_map(|(key, document)| match document { - PipelineDocument::Stewardship(stewardship) - if stewardship.instance == *mind + /// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal + /// `hand_off_key` derives. At most one is in force once the stewardship + /// row holds, which is why `stewardship_of` needs no tie-break. + fn stewardships_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Vec<(&str, &PipelineStewardship)> { + self.stewardships() + .filter(|(key, stewardship)| { + stewardship.instance == *mind && stewardship.repo == *repo - && self.in_force_unless(PipelineKind::Stewardship, key, |outcome| { - matches!(outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) - }) => - { - Some((key, stewardship)) - } - _ => None, - }) + && self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { + matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) + }) + .collect() + } + + /// The in-force stewardship of `(mind, repo)`, if any. + fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> { + self.stewardships_of(mind, repo, hand_off_key).first().copied() } } @@ -422,8 +461,10 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec { match &staged.document { D::Ruling(ruling) => { if let Some(question) = &ruling.answers { + let subject = PipelineRef { kind: K::Question, id: question.clone() }; derived.push(D::Resolution(PipelineResolution { - subject: PipelineRef { kind: K::Question, id: question.clone() }, + sequence: docs.latest_resolution(&subject, None) + 1, + subject, outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: K::Ruling, id: Short(staged.key.clone()) } }, rationale: ruling.ruling.clone(), resolved_on: ruling.ruled_on.clone(), @@ -434,8 +475,10 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec { if hand_off.from_instance == *mind && let Some((stewardship_key, _)) = docs.stewardship_of(mind, &hand_off.repo, Some(&staged.key)) { + let subject = PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) }; derived.push(D::Resolution(PipelineResolution { - subject: PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) }, + sequence: docs.latest_resolution(&subject, None) + 1, + subject, outcome: ResolutionOutcome::Withdrawn { reason: Line(staged.key.clone()) }, rationale: hand_off.reason.clone(), resolved_on: hand_off.handed_on.clone(), @@ -444,6 +487,7 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec { if hand_off.to_instance == *mind { derived.push(D::Stewardship(PipelineStewardship { instance: mind.clone(), + sequence: docs.latest_stewardship(mind, &hand_off.repo, None) + 1, repo: hand_off.repo.clone(), assigned_on: hand_off.handed_on.clone(), note: Line(staged.key.clone()), @@ -493,8 +537,8 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { let Some(D::Question(question)) = docs.find(K::Question, &question_id.0) else { return Err(MindRefusal::MissingReference { kind: K::Question, id: question_id.0.clone() }); }; - let own = |outcome: &ResolutionOutcome| { - matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key) + let own = |resolution: &PipelineResolution| { + matches!(&resolution.outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key) }; if !docs.in_force_unless(K::Question, &question_id.0, own) { return Err(MindRefusal::AlreadyResolved { subject: question_id.0.clone() }); @@ -589,13 +633,27 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { Ok(()) } D::Resolution(resolution) => resolution_rule(docs, resolution), + D::Stewardship(stewardship) => { + let expected = docs.latest_stewardship(&stewardship.instance, &stewardship.repo, Some(stewardship)) + 1; + if stewardship.sequence != expected { + return Err(MindRefusal::StewardshipOutOfSequence { + repo: stewardship.repo.0.clone(), + expected, + actual: stewardship.sequence, + }); + } + if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) { + return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() }); + } + Ok(()) + } D::HandOff(hand_off) => { if hand_off.from_instance == *mind && docs.stewardship_of(mind, &hand_off.repo, Some(key)).is_none() { return Err(MindRefusal::NotStewarded { repo: hand_off.repo.0.clone() }); } Ok(()) } - D::FollowUp(_) | D::Instance(_) | D::Stewardship(_) => Ok(()), + D::FollowUp(_) | D::Instance(_) => Ok(()), } } @@ -648,9 +706,10 @@ fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { /// A resolution is itself resolvable, by withdrawal alone: the operator's /// ruling on Q17 keeps a withdrawn resolution attached to its subject rather /// than erasing it, and a subject whose resolution is withdrawn may be -/// resolved again. The second resolution needs a key the first does not -/// already hold, which is a sequence in the leaf's resolution key and is -/// mapped separately. +/// resolved again at the next sequence, which the leaf's key now carries. The +/// chain stops there: Q19 A caps it at depth two, in `resolution_rule`, since +/// a withdrawal that could itself be withdrawn would leave two closures +/// standing over one subject. fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { use PipelineKind as K; use ResolutionOutcome as O; @@ -676,9 +735,10 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { fits } -/// The resolution row: the matrix, a non-empty supersession, every referent -/// in force, and the two coherence rules (an `Answered` ruling answers this -/// question; a cut spec is superseded within its cut). +/// The resolution row: the sequence, the in-force subject, the matrix, the +/// Q19 cap, a non-empty supersession, every referent in force, and the two +/// coherence rules (an `Answered` ruling answers this question; a cut spec is +/// superseded within its cut). fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), MindRefusal> { use PipelineDocument as D; use PipelineKind as K; @@ -687,10 +747,34 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M if let ResolutionOutcome::Superseded { by } = &resolution.outcome && by.is_empty() { return Err(MindRefusal::EmptySupersession); } + // The sequence is the writer's and is checked against the image, as a + // revision is: the previous plus one, counting every record of this + // subject other than this document itself. + let expected = docs.latest_resolution(&resolution.subject, Some(resolution)) + 1; + if resolution.sequence != expected { + return Err(MindRefusal::ResolutionOutOfSequence { + subject: subject_id.into(), + expected, + actual: resolution.sequence, + }); + } + // A8: a subject with a resolution still in force is closed. A withdrawn + // one is not in force, so the subject is open and this is its next record. + if !docs.in_force_unless(subject_kind, subject_id, |other| other == resolution) { + return Err(MindRefusal::AlreadyResolved { subject: subject_id.into() }); + } let incompatible = || MindRefusal::IncompatibleResolution { subject_kind, outcome: outcome_name(&resolution.outcome).into() }; if !matrix(subject_kind, &resolution.outcome) { return Err(incompatible()); } + // Q19 A: the chain stops at depth two. A withdrawal cannot be withdrawn; + // to reinstate a closure, resolve the subject again at the next sequence. + if subject_kind == K::Resolution + && let Some(D::Resolution(subject)) = docs.find(K::Resolution, subject_id) + && subject.subject.kind == K::Resolution + { + return Err(incompatible()); + } for referent in outcome_references(&resolution.outcome) { if !docs.in_force(referent.kind, &referent.id) { return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id }); @@ -718,16 +802,13 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M Ok(()) } -/// A10: a write whose identity the image already holds. A resolution's key -/// is outcome-invariant, so its collision means the subject is already -/// resolved. +/// A10: a write whose identity the image already holds. Every kind answers +/// the same way now that a resolution key carries a sequence: the identity is +/// taken. Whether a subject is closed is A8's question, not this one's. fn refuse_collisions(docs: &Docs) -> Result<(), MindRefusal> { for staged in &docs.batch { if docs.in_image(staged.kind, &staged.key).is_some() { - return Err(match &staged.document { - PipelineDocument::Resolution(resolution) => MindRefusal::AlreadyResolved { subject: resolution.subject.id.0.clone() }, - _ => MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key.clone() }, - }); + return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key.clone() }); } } Ok(()) @@ -797,7 +878,7 @@ mod tests { assert_ne!(Mind::path_for(root.path(), &slug(INSTANCE)), Mind::path_for(root.path(), &slug(OTHER_INSTANCE))); committed(admit(&mut yggdrasil, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); committed(admit(&mut thought_cage, vec![instance(OTHER_INSTANCE)])); - let key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany"); + let key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"); assert!(yggdrasil.envelope(K::Stewardship, &key).is_some()); assert!(thought_cage.envelope(K::Stewardship, &key).is_none()); assert_eq!(yggdrasil.envelopes().len(), 4); @@ -973,16 +1054,25 @@ mod tests { assert_eq!(mind.get(K::Ruling, &id("ruling", "R8")).unwrap(), Some(D::Ruling(ruling("R8")))); } + /// A8: one resolution of a subject is in force at a time, and the next + /// record of that subject is the next sequence. The two rules are + /// separate refusals: a second closure at the right sequence is refused + /// because the first still stands, and one at a taken sequence is refused + /// before the question of standing arises. #[test] - fn subject_resolves_at_most_once() { + fn a_subject_with_a_resolution_in_force_refuses_another() { let mut mind = seeded(); committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); let subject = r(K::Question, &id("question", "Q1")); committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); assert_eq!( - refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])), + refusal(admit(&mut mind, vec![resolution_n(subject.clone(), 2, ResolutionOutcome::Withdrawn { reason: "again".into() })])), MindRefusal::AlreadyResolved { subject: id("question", "Q1") } ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])), + MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 2, actual: 1 } + ); let mut answering = ruling("R1"); answering.answers = Some(s(&id("question", "Q1"))); answering.choice = Some(l("A")); @@ -1021,7 +1111,7 @@ mod tests { let spec_ref = r(K::CutSpec, &id("cut_spec", "cut-1.r1")); let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1")); let follow_up_ref = r(K::FollowUp, &id("follow_up", "FU-1")); - let stewardship_ref = r(K::Stewardship, &format!("{INSTANCE}:stewardship:GameCult_-Epiphany")); + let stewardship_ref = r(K::Stewardship, &format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1")); let campaign_ref = r(K::Campaign, &id("campaign", "self")); let report_ref = r(K::CutReport, &id("cut_report", "cut-1.h1")); let verdict_ref = r(K::Verdict, &id("verdict", "cut-1.s1")); @@ -1039,7 +1129,7 @@ mod tests { committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())])); let mut mind = world(); committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); - let nested = r(K::Resolution, &id("resolution", "question.Q1")); + let nested = r(K::Resolution, &id("resolution", "question.Q1.n1")); committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); // Refused, one per kind, and every non-resolvable kind. @@ -1129,10 +1219,10 @@ mod tests { ); answering.choice = Some(l("A")); let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)])); - assert_eq!(writes, vec![r(K::Ruling, &id("ruling", "R1")), r(K::Resolution, &id("resolution", "question.Q1"))]); + assert_eq!(writes, vec![r(K::Ruling, &id("ruling", "R1")), r(K::Resolution, &id("resolution", "question.Q1.n1"))]); let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); assert_eq!(receipt.writes.len(), 2); - let Some(D::Resolution(derived)) = mind.get(K::Resolution, &id("resolution", "question.Q1")).unwrap() else { panic!() }; + let Some(D::Resolution(derived)) = mind.get(K::Resolution, &id("resolution", "question.Q1.n1")).unwrap() else { panic!() }; assert_eq!(derived.outcome, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); let mut again = ruling("R2"); again.answers = Some(s(&id("question", "Q1"))); @@ -1424,7 +1514,7 @@ mod tests { fn a_hand_off_derives_this_minds_side_only() { let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.2026-09-16"); let campaign_key = id("campaign", "self"); - let stewardship_key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany"); + let stewardship_key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"); let mut source = seeded(); assert_eq!( @@ -1445,7 +1535,7 @@ mod tests { let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE); committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)])); let (_, writes) = committed(admit(&mut receiving, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])])); - let assigned = format!("{OTHER_INSTANCE}:stewardship:GameCult_-Epiphany"); + let assigned = format!("{OTHER_INSTANCE}:stewardship:GameCult_-Epiphany.n1"); assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Stewardship, &assigned)]); let Some(D::Stewardship(derived)) = receiving.get(K::Stewardship, &assigned).unwrap() else { panic!() }; assert_eq!(derived.note, Line(key.clone())); @@ -1455,9 +1545,12 @@ mod tests { assert_eq!(derived.assigned_on, date()); assert_eq!(source.envelope(K::HandOff, &key).map(|e| &e.payload), receiving.envelope(K::HandOff, &key).map(|e| &e.payload)); - // A derived write passes A3-A7 like any other. A batch carrying its own - // stewardship of the repo collides with the derived one at A4, as a - // typed refusal, not as a duplicate identity the store complains about. + // A derived write passes A3-A7 and A8 like any other, and it counts as + // a record of its scope. A batch carrying its own assignment of the + // repo beside the hand-off is two assignments: the derived one takes + // the sequence after the batch's, and the batch's own is then out of + // sequence -- a typed refusal, not a duplicate identity the store + // complains about. let mut colliding = opened(MemoryStore::new(), OTHER_INSTANCE); committed(admit(&mut colliding, vec![instance(OTHER_INSTANCE)])); assert_eq!( @@ -1465,15 +1558,16 @@ mod tests { hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]), stewardship(OTHER_INSTANCE, REPO), ])), - MindRefusal::IdentityCollision { kind: K::Stewardship, id: assigned } + MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 3, actual: 1 } ); } - /// The key of the resolution of a stewardship: the subject's root, then - /// its kind and local. + /// The key of the first resolution of a stewardship: the subject's root, + /// then its kind and local -- the subject's own sequence included -- then + /// this resolution's own sequence. fn id_of(stewardship_key: &str) -> String { let (root, rest) = stewardship_key.split_once(':').unwrap(); - format!("{root}:resolution:{}", rest.replace(':', ".")) + format!("{root}:resolution:{}.n1", rest.replace(':', ".")) } /// The second construction site of a receipt, deliberately: a receipt diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index 0084b2e..a6ae3cb 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -98,9 +98,17 @@ pub(crate) fn instance(name: &str) -> PipelineDocument { } pub(crate) fn stewardship(instance: &str, repo_name: &str) -> PipelineDocument { + stewardship_n(instance, repo_name, 1) +} + +/// A repo's `n`th assignment to a mind. The sequence is per `(instance, +/// repo)` and the leaf keys it last, so `n2` is the record of a repo +/// transferred away and taken back. +pub(crate) fn stewardship_n(instance: &str, repo_name: &str, sequence: u32) -> PipelineDocument { PipelineDocument::Stewardship(PipelineStewardship { instance: slug(instance), repo: repo(repo_name), + sequence, assigned_on: date(), note: "assigned".into(), }) @@ -261,8 +269,16 @@ pub(crate) fn follow_up(label: &str, source: PipelineRef) -> PipelineDocument { } pub(crate) fn resolution(subject: PipelineRef, outcome: ResolutionOutcome) -> PipelineDocument { + resolution_n(subject, 1, outcome) +} + +/// A subject's `n`th resolution. The sequence is per subject and the leaf +/// keys it last, so a withdrawn closure stays under its subject and the next +/// one is nameable. +pub(crate) fn resolution_n(subject: PipelineRef, sequence: u32, outcome: ResolutionOutcome) -> PipelineDocument { PipelineDocument::Resolution(PipelineResolution { subject, + sequence, outcome, rationale: "Resolved.".into(), resolved_on: date(), diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 571f1a2..466f639 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -10,6 +10,11 @@ use serde::{Deserialize, Serialize}; /// formats, key identity, foreign type); the rest are the organ's: store /// identity, epoch, ownership, batch shape, references, and every cross-field /// and cross-document rule of admission. +/// +/// The two sequenced kinds refuse in the same pair of shapes: an +/// `OutOfSequence` when the writer's `sequence` is not the previous plus one, +/// and `AlreadyResolved`/`AlreadyStewarded` when a record of that scope is +/// still in force. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum MindRefusal { Document( @@ -26,6 +31,9 @@ pub enum MindRefusal { IdentityCollision { kind: PipelineKind, id: String }, MissingReference { kind: PipelineKind, id: String }, AlreadyResolved { subject: String }, + ResolutionOutOfSequence { subject: String, expected: u32, actual: u32 }, + AlreadyStewarded { repo: String }, + StewardshipOutOfSequence { repo: String, expected: u32, actual: u32 }, IncompatibleResolution { subject_kind: PipelineKind, outcome: String }, CitesResolvedDocument { kind: PipelineKind, id: String }, EmptySupersession, From fcb7fdd061e2243dccca44938929b75d9cf6d4af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 19:44:15 +0200 Subject: [PATCH 013/100] Pin the reopened subject, the retaken repo, and six blind spots Two tests for the rules the last commit landed. `a_withdrawn_resolution_reopens_its_subject_and_stays_readable` withdraws a closure, admits the next one at `n2`, reads the first back with its outcome intact, refuses a third while `n2` stands, refuses the withdrawal of the withdrawal, and has a ruling answer the reopened question -- the derived resolution lands at `n2`, not `n1`. `a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer` refuses a second assignment while the first stands and a second claiming the taken sequence, hands the repo away, finds nothing stewarding it, hands it back and gets `n2` with the second `handed_on`, the first still readable by key. Entries H40-H46 carry the mutants: the two sequence rules, the recursive in-force, `AlreadyStewarded`, the Q19 cap, and `derive`'s two sequences. Then the six places Soul found the code right and the suite blind, each a fixture and an entry (H47-H51): a handed-away repo is not stewarded, so `stewardship_of`'s in-force half is pinned; a document two batch documents cite is pinned once rather than cancelled; what a derived write cites in the image is pinned, which is H32's surviving half; a finding may cite the vocabulary of the target landing beside it; `Answered` coherence is checked against a ruling in the image as against one in the batch; a branch differing only in case is a mismatch. H32 is re-anchored rather than left to rot. Its A4 half is unreachable now: a derived write takes the sequence after the batch's own records, so a derived identity cannot collide with one the batch holds. What survives is that derived writes are validated, staged and resolved like any other, and the transfer test's strong read is what fails when they are not. README stops claiming the two stub crates do anything. H1-H51 all killed, M0 green. 33 tests, 0 warnings; `cargo check --workspace` clean; `git diff --stat 4094e68 -- .voidbot` empty. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +- crates/huginn-mind/src/admission.rs | 151 +++++++++++++++++++++++++- tools/eureka-cut8-mutations.psd1 | 158 +++++++++++++++++++++++++++- 3 files changed, 306 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e942ff6..1eb2c3e 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,10 @@ edits `.cc` state. Huginn reads and writes minds. A Cargo workspace of three crates: - `crates/huginn-mind`: storage, identity, and admission of memory documents. -- `crates/huginn-daemon`: the CultNet surface and serve loop. -- `crates/eureka-state`: typed state for the Eureka pipeline. +- `crates/huginn-daemon`: a stub. It will carry the CultNet surface and serve + loop; today it holds neither. +- `crates/eureka-state`: a stub. It will carry typed state for the Eureka + pipeline; today it holds none. `huginn-mind` is live: it opens one instance's store (an owned redb CultCache at `/minds//mind.redb`, locked for the mind's lifetime), diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 1a2dd5d..a0865be 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -1082,6 +1082,118 @@ mod tests { ); } + /// Q17 B and Q19 A together: withdrawing a resolution reopens its subject + /// without erasing the record. The withdrawn closure is still readable by + /// key, the subject takes its next resolution at the next sequence, and + /// the chain stops at depth two -- a withdrawal cannot be withdrawn. + #[test] + fn a_withdrawn_resolution_reopens_its_subject_and_stays_readable() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + let first = id("resolution", "question.Q1.n1"); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + + // The withdrawal of that closure: one nesting out, under the + // resolution it withdraws. + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + let withdrawal = id("resolution", "resolution.question.Q1.n1.n1"); + assert!(mind.get(K::Resolution, &withdrawal).unwrap().is_some()); + + // The subject is open again, and its next record is `n2`. The first + // one is still there, outcome and all: this is a log, not an erasure. + committed(admit(&mut mind, vec![resolution_n(subject.clone(), 2, withdrawn())])); + let Some(D::Resolution(kept)) = mind.get(K::Resolution, &first).unwrap() else { panic!() }; + assert_eq!(kept.outcome, withdrawn()); + assert_eq!(kept.sequence, 1); + + // `n2` stands, so a third is refused, and the withdrawal cannot + // itself be withdrawn to re-raise `n1` over it (Q19 A). + assert_eq!( + refusal(admit(&mut mind, vec![resolution_n(subject, 3, withdrawn())])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])), + MindRefusal::IncompatibleResolution { subject_kind: K::Resolution, outcome: "Withdrawn".into() } + ); + + // And a ruling may answer a question whose first closure was + // withdrawn: the derived resolution takes the next sequence. + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + let (_, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!(writes, vec![ + r(K::Ruling, &id("ruling", "R1")), + r(K::Resolution, &id("resolution", "question.Q1.n2")), + ]); + } + + /// Q18 A and Q20 A at admission: a repo is stewarded by one record at a + /// time on a mind, and a repo handed away and handed back is a second + /// record rather than a collision or an overwrite. + #[test] + fn a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer() { + let mut mind = seeded(); + let first = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 2)])), + MindRefusal::AlreadyStewarded { repo: REPO.into() } + ); + let D::Stewardship(mut retaken) = stewardship(INSTANCE, REPO) else { panic!() }; + retaken.note = "assigned again".into(); + assert_eq!( + refusal(admit(&mut mind, vec![D::Stewardship(retaken)])), + MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 2, actual: 1 } + ); + + // Handing the repo away withdraws the assignment, and the withdrawal + // is a resolution of that record, sequence and all. The stewardship it + // reads to derive that is an image document, so it is pinned. + let (receipt_id, writes) = committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let away = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.{}", date().0); + assert_eq!(writes, vec![ + r(K::HandOff, &away), + r(K::Resolution, &format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1")), + ]); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + let stewardship_envelope = mind.envelope(K::Stewardship, &first).unwrap().clone(); + assert!( + receipt.strong_reads.contains(&DocumentVersion::from_envelope(&stewardship_envelope)), + "the derived withdrawal's own citation is pinned: {:?}", + receipt.strong_reads + ); + + // Nothing stewards the repo now, and `stewardship_of` says so: a new + // campaign over it is refused. + let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; + second.slug = slug("second"); + assert_eq!( + refusal(admit(&mut mind, vec![D::Campaign(second)])), + MindRefusal::RepoNotStewarded { repo: REPO.into() } + ); + + // Handed back, the mind stewards it again, as `n2`. No field names a + // return: this is the same hand-off shape with the instances swapped. + let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; + back.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + let (_, writes) = committed(admit(&mut mind, vec![D::HandOff(back)])); + let again = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n2"); + assert_eq!(writes, vec![ + r(K::HandOff, &format!("{OTHER_INSTANCE}:hand_off:{INSTANCE}.GameCult_-Epiphany.2026-09-17")), + r(K::Stewardship, &again), + ]); + let Some(D::Stewardship(taken)) = mind.get(K::Stewardship, &again).unwrap() else { panic!() }; + assert_eq!(taken.sequence, 2); + assert_eq!(taken.assigned_on, epiphany_pipeline::Date("2026-09-17".into())); + assert!(mind.envelope(K::Stewardship, &first).is_some(), "the first assignment is still readable by key"); + } + /// A seeded mind with one document of every resolvable kind, and the /// non-resolvable ones beside them. fn world() -> Mind { @@ -1238,7 +1350,15 @@ mod tests { answering.choice = Some(l("A")); let wrong = resolution(r(K::Question, &id("question", "Q2")), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); assert_eq!( - refusal(admit(&mut mind, vec![D::Ruling(answering), wrong])), + refusal(admit(&mut mind, vec![D::Ruling(answering.clone()), wrong.clone()])), + MindRefusal::IncompatibleResolution { subject_kind: K::Question, outcome: "Answered".into() } + ); + // The coherence is checked whether the ruling is in the batch or in + // the image: a later batch citing the stored R1 is refused the same + // way, and the derived resolution of Q1 is not what saves it. + committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!( + refusal(admit(&mut mind, vec![wrong])), MindRefusal::IncompatibleResolution { subject_kind: K::Question, outcome: "Answered".into() } ); } @@ -1306,6 +1426,17 @@ mod tests { for envelope in &cited { assert!(receipt.strong_reads.contains(&DocumentVersion::from_envelope(envelope)), "{}", envelope.key); } + + // Two batch documents citing one image document pin it once: the pins + // are a set, so a second citation neither duplicates the pin nor + // cancels it. + let mut twice = cut_spec("2", 1); + twice.rulings = vec![s(&id("ruling", "R1"))]; + let D::Question(mut raised) = question("Q1", &["A", "B"], "A") else { panic!() }; + raised.raised_in = Some(r(K::Ruling, &id("ruling", "R1"))); + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(twice), D::Question(raised)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads, vec![DocumentVersion::from_envelope(&cited[0])], "cited twice, pinned once"); } #[test] @@ -1351,6 +1482,11 @@ mod tests { let mut report = cut_report("1", 1); report.branch = s("another-branch"); assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); + // A branch is compared as written. Git's refs are case-sensitive, and + // a report of `Codex/...` is a report of another branch or of none. + let mut report = cut_report("1", 1); + report.branch = s("CODEX/eureka-pipeline-state"); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); let mut report = cut_report("1", 1); report.repo = repo(OTHER_REPO); assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "repo".into() }); @@ -1428,6 +1564,7 @@ mod tests { unknown.invariants = vec![l("nope")]; assert_eq!(refusal(admit(&mut mind, vec![D::Finding(unknown)])), MindRefusal::UnknownInvariant { label: "nope".into() }); + // A raw envelope without `range` cannot be built from the type, and // it is the leaf's decode that refuses it, before any organ rule. #[derive(serde::Serialize)] @@ -1469,10 +1606,20 @@ mod tests { // The in-force target owns the invariant vocabulary. Supersede r1 with // an r2 that drops its label, and the label is unknown again; r2's own - // label is what a finding may cite. + // label is what a finding may cite. The target is looked for in image + // and batch, like every other citation, so a finding may cite the + // vocabulary of the revision landing beside it: `other` is admitted in + // r2's own batch, before r2 is anywhere but here. + let mut fresh = finding("1", 1, "F4", FindingConfidence::Plausible); + fresh.invariants = vec![l("other")]; + assert_eq!( + refusal(admit(&mut mind, vec![D::Finding(fresh.clone())])), + MindRefusal::UnknownInvariant { label: "other".into() } + ); committed(admit(&mut mind, vec![ target(2, &["other"]), resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + D::Finding(fresh), ])); assert_eq!( refusal(admit(&mut mind, vec![D::Finding(finding("1", 1, "F2", FindingConfidence::Plausible))])), diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index 9086272..54856d2 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -1,6 +1,15 @@ -# Cut 8 mutations, H1-H39: each entry restores one permissiveness the cut -# closed and names the test that must fail while it is applied. Run through -# Epiphany's harness from this repo: +# Cut 8 mutations, H1-H51: each entry restores one permissiveness the cut +# closed and names the test that must fail while it is applied. H40-H46 are +# the Cut 6d follow-up's rules (the two sequences, the recursive in-force, the +# single in-force stewardship, the Q19 cap, and the sequences `derive` sets); +# H47-H51 close the gaps Soul found where the code was right and the suite +# blind. Run through Epiphany's harness from this repo: +# +# H32 is re-anchored to the same follow-up: its A4 half is unreachable now +# that a derived write takes the sequence after the batch's own records, so a +# derived identity can no longer collide with one the batch holds. What it +# still pins is that derived writes are validated, staged and resolved like +# any other, which the transfer test's strong read shows. # # $env:CARGO_TARGET_DIR = 'C:\Users\Meta\.cargo-target-codex' # powershell -File F:\Projects\Epiphany\tools\eureka-mutations.ps1 -Repo F:\Projects\Huginn ` @@ -397,8 +406,8 @@ impl MindStore for OwnedRedbMessagePackBackingStore { } @{ Id = 'H32' - Rule = 'Derived writes pass A3-A7 like any other; a derived identity the batch already holds is A4''s refusal, not a store error.' - Test = 'admission::tests::a_hand_off_derives_this_minds_side_only' + Rule = 'Derived writes go through validation, A4 and A7 like any other: what a derived write cites in the image is pinned.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' File = 'crates/huginn-mind/src/admission.rs' Old = @' for document in derive(&docs, &mind) { @@ -486,5 +495,144 @@ impl MindStore for OwnedRedbMessagePackBackingStore { '@ New = ' let _ = (ruling, subject_id);' } + @{ + Id = 'H40' + Rule = 'resolution: the sequence is the previous plus one over image and batch (Q17 B).' + Test = 'admission::tests::a_subject_with_a_resolution_in_force_refuses_another' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if resolution.sequence != expected {' + New = ' if false {' + } + @{ + Id = 'H41' + Rule = 'In force is recursive: a withdrawn resolution no longer closes its subject.' + Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + && !own(resolution) + && self.in_force(PipelineKind::Resolution, key) +'@ + New = @' + && !own(resolution) + && { let _ = key; true } +'@ + } + @{ + Id = 'H42' + Rule = 'stewardship: the sequence is the previous plus one, as a resolution''s is (Q18 A, Q20 A).' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if stewardship.sequence != expected {' + New = ' if false {' + } + @{ + Id = 'H43' + Rule = 'stewardship: at most one assignment of a repo is in force on a mind.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) { + return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() }); + } +'@ + New = '' + } + @{ + Id = 'H44' + Rule = 'Q19 A: a withdrawal cannot be withdrawn; the chain stops at depth two.' + Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if subject_kind == K::Resolution + && let Some(D::Resolution(subject)) = docs.find(K::Resolution, subject_id) + && subject.subject.kind == K::Resolution + { + return Err(incompatible()); + } +'@ + New = '' + } + @{ + Id = 'H45' + Rule = 'derive: a derived resolution takes the subject''s next sequence, not always the first.' + Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + sequence: docs.latest_resolution(&subject, None) + 1, + subject, + outcome: ResolutionOutcome::Answered +'@ + New = @' + sequence: 1, + subject, + outcome: ResolutionOutcome::Answered +'@ + } + @{ + Id = 'H46' + Rule = 'derive: H45''s other half, a derived stewardship takes the repo''s next sequence on this mind.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' sequence: docs.latest_stewardship(mind, &hand_off.repo, None) + 1,' + New = ' sequence: 1,' + } + @{ + Id = 'H47' + Rule = 'stewardship_of: only an in-force assignment counts, so a handed-away repo is not stewarded.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + .filter(|(key, stewardship)| { + stewardship.instance == *mind + && stewardship.repo == *repo + && self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { + matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) + }) +'@ + New = @' + .filter(|(_key, stewardship)| { + let _ = hand_off_key; + stewardship.instance == *mind && stewardship.repo == *repo + }) +'@ + } + @{ + Id = 'H48' + Rule = 'A7: a document two batch documents cite is pinned once, not cancelled by the second citation.' + Test = 'admission::tests::every_cited_image_document_is_a_strong_read' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' strong.insert((reference.kind.type_id().to_string(), reference.id));' + New = @' + let pin = (reference.kind.type_id().to_string(), reference.id); + if !strong.insert(pin.clone()) { + strong.remove(&pin); + } +'@ + } + @{ + Id = 'H49' + Rule = 'finding: the in-force target is looked for in image and batch, not the image alone.' + Test = 'admission::tests::a_finding_names_evidence_locations_and_known_invariants' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' let target = docs.of_kind(K::Target).find_map(|(target_key, document)| match document {' + New = ' let target = docs.image.iter().map(|held| (held.key.as_str(), &held.document)).find_map(|(target_key, document)| match document {' + } + @{ + Id = 'H50' + Rule = 'resolution: the Answered coherence check reads the ruling wherever it is, batch or image.' + Test = 'admission::tests::a_ruling_answering_a_question_derives_the_answered_resolution_atomically' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' let Some(D::Ruling(ruling)) = docs.find(K::Ruling, &by.id.0) else { return Ok(()) };' + New = ' let Some(D::Ruling(ruling)) = docs.in_batch(K::Ruling, &by.id.0) else { return Ok(()) };' + } + @{ + Id = 'H51' + Rule = 'cut_report: the branch is compared as written; git''s refs are case-sensitive.' + Test = 'admission::tests::a_cut_report_cites_an_in_force_spec_and_agrees_with_it' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if report.branch != spec.branch {' + New = ' if !report.branch.0.eq_ignore_ascii_case(&spec.branch.0) {' + } ) } From f566fe6bb3000bd1b6de61313875ab3f63db245c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 20:21:04 +0200 Subject: [PATCH 014/100] Re-derive what the batch derived, and stop re-raising an earlier record Two rules Soul's follow-up pass falsified, both in admission. A batch that derives a write could not be replayed. `derive` set every derived sequence to `latest + 1` over an image that already held the first admission's derived record, so the second admission produced a different document, A8 refused it -- `AlreadyResolved` on the source side, and `AlreadyStewarded` on the receiving one -- and A9 never saw the digest it would have recognised. `derive` now reproduces the record it already made when image or batch holds one, found by what the derivation writes into it: a resolution `Answered` by this ruling, a withdrawal whose reason is this hand-off's key, an assignment whose note is. The same document comes back byte for byte, A9 answers with the first receipt, and only a batch that never landed takes `latest + 1`. A withdrawal could re-raise an earlier record over a later one, which is the rationale Q19 A's cap rests on and the cap alone does not carry. Withdrawing a resolution puts its subject back in force, so a repo handed away and handed back could have the hand-off's withdrawal withdrawn and stand under two assignments at once; a supersession withdrawn while the revision that superseded it stood left two revisions in force, which is what the finding rule reads for its invariant vocabulary. `WouldReinstateOverLater` refuses that: the record the withdrawal would reinstate, against anything in force and later in the scope that record is sequenced in -- `(instance, repo)` for a stewardship, the subject for a resolution, the campaign for a target and the cut for a cut spec. Four tests, one per fix and kind: the ruling's resolution and both sides of a hand-off replayed to `AlreadyAdmitted` with the first receipt and no second receipt in the store; the retaken repo and the superseded revision refused, with the later record still the only one in force. Four more tests for rules the code already held and the suite could not tell from weaker ones: a sequence gap refuses on both sequenced kinds rather than only a taken sequence; the latest sequence counts the batch as well as the image; the Q19 cap reads a chain landing in one batch; and the hand-off's second withdrawal of one assignment takes the next sequence, not `1`. 41 tests, 0 warnings. Co-Authored-By: Claude Fable 5.1 --- crates/huginn-mind/src/admission.rs | 240 +++++++++++++++++++++++++++- crates/huginn-mind/src/refusal.rs | 1 + 2 files changed, 237 insertions(+), 4 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index a0865be..8cb2f0c 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -272,6 +272,24 @@ impl Docs { .unwrap_or(0) } + /// The sequence a derivation takes: the one the record it already derived + /// carries, when image or batch holds that record, so an exact replay + /// re-derives the same document and A9 answers with the stored receipt; + /// otherwise the next. A derivation is recognised by what it writes into + /// its record: a resolution by its outcome, an assignment by its note. + fn derived_resolution_sequence(&self, subject: &PipelineRef, derived: impl Fn(&ResolutionOutcome) -> bool) -> u32 { + self.resolutions() + .find(|(_, resolution)| resolution.subject == *subject && derived(&resolution.outcome)) + .map_or_else(|| self.latest_resolution(subject, None) + 1, |(_, resolution)| resolution.sequence) + } + + /// The same for the assignment a hand-off derives, which notes its key. + fn derived_stewardship_sequence(&self, mind: &Slug, repo: &OrgRepo, note: &str) -> u32 { + self.stewardships() + .find(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && stewardship.note.0 == note) + .map_or_else(|| self.latest_stewardship(mind, repo, None) + 1, |(_, stewardship)| stewardship.sequence) + } + /// The same for a repo's assignments to one mind. fn latest_stewardship(&self, mind: &Slug, repo: &OrgRepo, own: Option<&PipelineStewardship>) -> u32 { self.stewardships() @@ -317,6 +335,14 @@ impl Docs { .collect() } + /// The in-force record that stands later than `base` in the scope `base` + /// is sequenced in, if any: what a withdrawal that put `base` back in + /// force would leave standing beside it. + fn later_in_force(&self, base: &PipelineDocument) -> Option<&str> { + let kind = base.kind(); + self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key) + } + /// The in-force stewardship of `(mind, repo)`, if any. fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> { self.stewardships_of(mind, repo, hand_off_key).first().copied() @@ -462,8 +488,11 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec { D::Ruling(ruling) => { if let Some(question) = &ruling.answers { let subject = PipelineRef { kind: K::Question, id: question.clone() }; + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == staged.key) + }); derived.push(D::Resolution(PipelineResolution { - sequence: docs.latest_resolution(&subject, None) + 1, + sequence, subject, outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: K::Ruling, id: Short(staged.key.clone()) } }, rationale: ruling.ruling.clone(), @@ -476,8 +505,11 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec { && let Some((stewardship_key, _)) = docs.stewardship_of(mind, &hand_off.repo, Some(&staged.key)) { let subject = PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) }; + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == staged.key) + }); derived.push(D::Resolution(PipelineResolution { - sequence: docs.latest_resolution(&subject, None) + 1, + sequence, subject, outcome: ResolutionOutcome::Withdrawn { reason: Line(staged.key.clone()) }, rationale: hand_off.reason.clone(), @@ -487,7 +519,7 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec { if hand_off.to_instance == *mind { derived.push(D::Stewardship(PipelineStewardship { instance: mind.clone(), - sequence: docs.latest_stewardship(mind, &hand_off.repo, None) + 1, + sequence: docs.derived_stewardship_sequence(mind, &hand_off.repo, &staged.key), repo: hand_off.repo.clone(), assigned_on: hand_off.handed_on.clone(), note: Line(staged.key.clone()), @@ -735,8 +767,28 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { fits } +/// Whether `other` is a later record than `base` in the scope `base` is +/// sequenced in: `(instance, repo)` for a stewardship, the subject for a +/// resolution, the campaign for a target and the cut for a cut spec. Kinds +/// that carry no sequence have no scope and no order. +fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool { + use PipelineDocument as D; + match (base, other) { + (D::Stewardship(base), D::Stewardship(other)) => { + other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence + } + (D::Resolution(base), D::Resolution(other)) => other.subject == base.subject && other.sequence > base.sequence, + (D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision, + (D::CutSpec(base), D::CutSpec(other)) => { + other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision + } + _ => false, + } +} + /// The resolution row: the sequence, the in-force subject, the matrix, the -/// Q19 cap, a non-empty supersession, every referent in force, and the two +/// Q19 cap and the reinstatement rule it rests on, a non-empty supersession, +/// every referent in force, and the two /// coherence rules (an `Answered` ruling answers this question; a cut spec is /// superseded within its cut). fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), MindRefusal> { @@ -775,6 +827,22 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M { return Err(incompatible()); } + // Q19 A's rationale one step out from the cap: a withdrawal never + // re-raises an earlier record over a later one. Withdrawing a resolution + // puts its subject back in force, so refuse while a later record of that + // subject's own scope stands -- a repo's next assignment, a subject's next + // resolution, a document's next revision. + if matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. }) + && subject_kind == K::Resolution + && let Some(D::Resolution(reinstating)) = docs.find(K::Resolution, subject_id) + && let Some(base) = docs.find(reinstating.subject.kind, &reinstating.subject.id.0) + && let Some(later) = docs.later_in_force(base) + { + return Err(MindRefusal::WouldReinstateOverLater { + subject: reinstating.subject.id.0.clone(), + later: later.into(), + }); + } for referent in outcome_references(&resolution.outcome) { if !docs.in_force(referent.kind, &referent.id) { return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id }); @@ -985,6 +1053,46 @@ mod tests { assert_eq!(receipt_count(&mind), receipts + 1); } + /// A batch that derives a write replays like any other: the second + /// admission re-derives the record the first one landed, sequence and + /// all, so A9 recognises the digest and answers with the first receipt + /// instead of the derived write colliding with itself. + #[test] + fn an_exact_replay_re_derives_the_rulings_resolution() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering.clone())])); + assert!(writes.contains(&r(K::Resolution, &id("resolution", "question.Q1.n1")))); + let receipts = receipt_count(&mind); + assert_eq!( + admit(&mut mind, vec![D::Ruling(answering)]), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id } + ); + assert_eq!(receipt_count(&mind), receipts, "a replay writes no second receipt"); + } + + /// The same on both sides of a hand-off: the source re-derives the + /// withdrawal it landed, the receiving mind the assignment. + #[test] + fn an_exact_replay_re_derives_both_sides_of_a_hand_off() { + let away = hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]); + let mut source = seeded(); + let (receipt_id, _) = committed(admit(&mut source, vec![away.clone()])); + let receipts = receipt_count(&source); + assert_eq!(admit(&mut source, vec![away.clone()]), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }); + assert_eq!(receipt_count(&source), receipts); + + let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE); + committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)])); + let (receipt_id, _) = committed(admit(&mut receiving, vec![away.clone()])); + let receipts = receipt_count(&receiving); + assert_eq!(admit(&mut receiving, vec![away]), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }); + assert_eq!(receipt_count(&receiving), receipts); + } + #[test] fn a_refused_batch_is_not_answered_from_a_stored_receipt() { let mut mind = seeded(); @@ -1134,6 +1242,67 @@ mod tests { ]); } + /// The sequence is exactly the previous plus one: a gap refuses as loudly + /// as a taken sequence, on both sequenced kinds, and before the question + /// of what still stands arises. + #[test] + fn a_sequence_gap_refuses_on_both_sequenced_kinds() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn())])); + committed(admit(&mut mind, vec![resolution_n(subject.clone(), 2, withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution_n(subject, 4, withdrawn())])), + MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 3, actual: 4 } + ); + + let mut steward = seeded(); + committed(admit(&mut steward, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; + back.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + committed(admit(&mut steward, vec![D::HandOff(back)])); + assert_eq!( + refusal(admit(&mut steward, vec![stewardship_n(INSTANCE, REPO, 4)])), + MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 3, actual: 4 } + ); + } + + /// The sequence counts the batch as well as the image: two records of one + /// subject landing together are the second and the third, never two + /// firsts, and the first of them is refused for the sequence rather than + /// for what the other closes. + #[test] + fn the_latest_resolution_counts_the_batch() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + assert_eq!( + refusal(admit(&mut mind, vec![ + resolution_n(subject.clone(), 1, withdrawn()), + resolution_n(subject, 2, ResolutionOutcome::Withdrawn { reason: "again".into() }), + ])), + MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 3, actual: 1 } + ); + } + + /// The Q19 cap reads the batch too: a chain three deep landing in one + /// batch is capped exactly as one landing over three admissions. + #[test] + fn the_cap_reads_a_chain_landing_in_one_batch() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![ + resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn()), + resolution(r(K::Resolution, &id("resolution", "resolution.question.Q1.n1.n1")), withdrawn()), + ])), + MindRefusal::IncompatibleResolution { subject_kind: K::Resolution, outcome: "Withdrawn".into() } + ); + } + /// Q18 A and Q20 A at admission: a repo is stewarded by one record at a /// time on a mind, and a repo handed away and handed back is a second /// record rather than a collision or an overwrite. @@ -1194,6 +1363,69 @@ mod tests { assert!(mind.envelope(K::Stewardship, &first).is_some(), "the first assignment is still readable by key"); } + /// Q19 A's rationale, which the cap alone does not carry: a withdrawal + /// never re-raises an earlier record over a later one. The repo went away + /// and came back, so withdrawing the hand-off's withdrawal would put the + /// first assignment back in force beside the second. + #[test] + fn a_withdrawal_does_not_reinstate_a_stewardship_over_a_later_one() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; + back.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + committed(admit(&mut mind, vec![D::HandOff(back)])); + let first = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"); + let again = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n2"); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])), + MindRefusal::WouldReinstateOverLater { subject: first, later: again.clone() } + ); + let docs = Docs::from_image(mind.envelopes()).unwrap(); + let in_force = docs.stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| *key).collect::>(); + assert_eq!(in_force, vec![again.as_str()], "one assignment stands, and it is the later one"); + } + + /// The same rule one kind over: withdrawing a supersession while the + /// revision that superseded stands would leave two revisions in force. + #[test] + fn a_withdrawal_does_not_reinstate_a_revision_over_a_later_one() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + target(2, &[INVARIANT]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "target.r1.n1")), withdrawn())])), + MindRefusal::WouldReinstateOverLater { subject: id("target", "r1"), later: id("target", "r2") } + ); + assert!(docs_of(&mind).in_force(K::Target, &id("target", "r2"))); + assert!(!docs_of(&mind).in_force(K::Target, &id("target", "r1"))); + } + + fn docs_of(mind: &Mind) -> Docs { + Docs::from_image(mind.envelopes()).unwrap() + } + + /// The withdrawal a hand-off derives is a record of its subject like any + /// other: the second time the repo leaves, it takes the next sequence. + #[test] + fn a_hand_offs_derived_withdrawal_takes_the_next_sequence() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + // Nothing later stands over the first assignment, so withdrawing the + // withdrawal puts it back in force and the repo is this mind's again. + let withdrawal = format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])); + let D::HandOff(mut again) = hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]) else { panic!() }; + again.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + let (_, writes) = committed(admit(&mut mind, vec![D::HandOff(again)])); + assert_eq!(writes, vec![ + r(K::HandOff, &format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.2026-09-17")), + r(K::Resolution, &format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n2")), + ]); + } + /// A seeded mind with one document of every resolvable kind, and the /// non-resolvable ones beside them. fn world() -> Mind { diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 466f639..3f6846a 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -35,6 +35,7 @@ pub enum MindRefusal { AlreadyStewarded { repo: String }, StewardshipOutOfSequence { repo: String, expected: u32, actual: u32 }, IncompatibleResolution { subject_kind: PipelineKind, outcome: String }, + WouldReinstateOverLater { subject: String, later: String }, CitesResolvedDocument { kind: PipelineKind, id: String }, EmptySupersession, UnknownSupersessor { id: String }, From 564d492853762936f2169590d6526776828b895c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 20:21:16 +0200 Subject: [PATCH 015/100] Carry the follow-up's ten mutants in the suite that found them H52-H61, each restoring one permissiveness the follow-up closed and naming the test that must fail while it is applied. H52-H54 revert the three derivation sites to an unconditional `latest + 1`, one per derived kind, killed by the two replay tests. H55 and H56 drop the reinstatement check against the retaken repo and the superseded revision. H57 and H58 let each sequence rule accept a gap, which only a test whose sequence is above the expected one can tell from the rule. H59 reads the Q19 cap's subject from the image alone, H60 `latest_resolution` from the image alone, and H61 sets the hand-off's derived withdrawal to sequence 1 -- the three Soul's second pass could restore with the suite still green. Co-Authored-By: Claude Fable 5.1 --- tools/eureka-cut8-mutations.psd1 | 110 ++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index 54856d2..ba3fd60 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -3,7 +3,11 @@ # the Cut 6d follow-up's rules (the two sequences, the recursive in-force, the # single in-force stewardship, the Q19 cap, and the sequences `derive` sets); # H47-H51 close the gaps Soul found where the code was right and the suite -# blind. Run through Epiphany's harness from this repo: +# blind. H52-H61 are the follow-up's own fixes and the gaps Soul's second pass +# left: the sequences a replay re-derives (H52-H54), the withdrawal that would +# re-raise an earlier record over a later one (H55-H56), the gap a sequence +# rule must refuse (H57-H58), and three rules the suite could not tell apart +# from weaker ones (H59-H61). Run through Epiphany's harness from this repo: # # H32 is re-anchored to the same follow-up: its A4 half is unreachable now # that a derived write takes the sequence after the batch's own records, so a @@ -634,5 +638,109 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Old = ' if report.branch != spec.branch {' New = ' if !report.branch.0.eq_ignore_ascii_case(&spec.branch.0) {' } + @{ + Id = 'H52' + Rule = 'derive: a ruling''s resolution takes the next sequence even when the image already holds the one this batch derived, so an exact replay never matches.' + Test = 'admission::tests::an_exact_replay_re_derives_the_rulings_resolution' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == staged.key) + }); +'@ + New = @' + let sequence = docs.latest_resolution(&subject, None) + 1; +'@ + } + @{ + Id = 'H53' + Rule = 'derive: the hand-off''s withdrawal takes the next sequence even when the image holds the one this hand-off derived.' + Test = 'admission::tests::an_exact_replay_re_derives_both_sides_of_a_hand_off' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == staged.key) + }); +'@ + New = @' + let sequence = docs.latest_resolution(&subject, None) + 1; +'@ + } + @{ + Id = 'H54' + Rule = 'derive: the hand-off''s assignment takes the next sequence even when the image holds the one this hand-off derived.' + Test = 'admission::tests::an_exact_replay_re_derives_both_sides_of_a_hand_off' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' sequence: docs.derived_stewardship_sequence(mind, &hand_off.repo, &staged.key),' + New = ' sequence: docs.latest_stewardship(mind, &hand_off.repo, None) + 1,' + } + @{ + Id = 'H55' + Rule = 'Q19 A: a withdrawal may re-raise an earlier stewardship over a later one.' + Test = 'admission::tests::a_withdrawal_does_not_reinstate_a_stewardship_over_a_later_one' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. })' + New = ' if false' + } + @{ + Id = 'H56' + Rule = 'H55 against the revision case: an earlier revision re-raised over the one that superseded it.' + Test = 'admission::tests::a_withdrawal_does_not_reinstate_a_revision_over_a_later_one' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. })' + New = ' if false' + } + @{ + Id = 'H57' + Rule = 'resolution: the sequence rule accepts a gap (only a taken sequence refuses).' + Test = 'admission::tests::a_sequence_gap_refuses_on_both_sequenced_kinds' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if resolution.sequence != expected {' + New = ' if resolution.sequence < expected {' + } + @{ + Id = 'H58' + Rule = 'stewardship: the sequence rule accepts a gap (only a taken sequence refuses).' + Test = 'admission::tests::a_sequence_gap_refuses_on_both_sequenced_kinds' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if stewardship.sequence != expected {' + New = ' if stewardship.sequence < expected {' + } + @{ + Id = 'H59' + Rule = 'Q19 cap: the subject resolution is looked for in the image only, so a chain landing in one batch is not capped.' + Test = 'admission::tests::the_cap_reads_a_chain_landing_in_one_batch' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' && let Some(D::Resolution(subject)) = docs.find(K::Resolution, subject_id)' + New = ' && let Some(D::Resolution(subject)) = docs.in_image(K::Resolution, subject_id)' + } + @{ + Id = 'H60' + Rule = 'latest_resolution reads the image only, not the batch.' + Test = 'admission::tests::the_latest_resolution_counts_the_batch' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + self.resolutions() + .filter(|(_, resolution)| resolution.subject == *subject && own != Some(*resolution)) +'@ + New = @' + self.image.iter().filter(|held| held.kind == PipelineKind::Resolution).filter_map(|held| match &held.document { PipelineDocument::Resolution(resolution) => Some((held.key.as_str(), resolution)), _ => None }) + .filter(|(_, resolution)| resolution.subject == *subject && own != Some(*resolution)) +'@ + } + @{ + Id = 'H61' + Rule = 'derive: the hand-off''s withdrawal always takes sequence 1.' + Test = 'admission::tests::a_hand_offs_derived_withdrawal_takes_the_next_sequence' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == staged.key) + }); +'@ + New = @' + let sequence = 1; +'@ + } ) } From 7b6773020d3d2d3a92533084efd72aa55ff772f4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 20:29:38 +0200 Subject: [PATCH 016/100] Re-anchor H45 and H46 on the derivation they still pin Both anchored on the `latest_resolution`/`latest_stewardship` call `derive` no longer makes, so the harness stopped at H45 with no verdict rather than running. The rule each entry states is unchanged -- a derived record takes the next sequence of its scope, not always the first -- so each moves to the line that now decides it and keeps its killer. H1-H61 all killed, M0 green. Co-Authored-By: Claude Fable 5.1 --- tools/eureka-cut8-mutations.psd1 | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index ba3fd60..e25af17 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -562,14 +562,12 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' File = 'crates/huginn-mind/src/admission.rs' Old = @' - sequence: docs.latest_resolution(&subject, None) + 1, - subject, - outcome: ResolutionOutcome::Answered + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == staged.key) + }); '@ New = @' - sequence: 1, - subject, - outcome: ResolutionOutcome::Answered + let sequence = 1; '@ } @{ @@ -577,7 +575,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Rule = 'derive: H45''s other half, a derived stewardship takes the repo''s next sequence on this mind.' Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' File = 'crates/huginn-mind/src/admission.rs' - Old = ' sequence: docs.latest_stewardship(mind, &hand_off.repo, None) + 1,' + Old = ' sequence: docs.derived_stewardship_sequence(mind, &hand_off.repo, &staged.key),' New = ' sequence: 1,' } @{ From 1bcb7ceb3e5dbca495cb614c3c0b097d978d111e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 20:58:34 +0200 Subject: [PATCH 017/100] Pick the assignment a replayed hand-off withdraws by content A hand-off's derived withdrawal read whichever stewardship `stewardships_of` listed first. While the withdrawal is being derived the hand-off's own is excluded from what stands, so two assignments are in force at once: the one this hand-off withdrew, and whatever stewards the repo now. Key order does not tell them apart -- once a repo has changed hands ten times, `n11` sorts before `n2` -- so replaying an old hand-off derived a second withdrawal of a standing assignment and died on the identity collision instead of answering with its first receipt. `stewardship_of` now takes the stewardship whose withdrawal already carries this hand-off's key, the same content match the ruling's answer and the receiving side's assignment already use, and falls back to the one in force. The comment claiming at most one is ever in force said the opposite of what the exclusion does; it is gone. `later_than`'s resolution arm is deleted. Nothing could reach it: the Q19 cap refuses a withdrawal whose subject is a resolution of a resolution before the reinstatement rule asks what stands later, so the arm was dead and the entry Soul wrote against it could not fail. Dead code with a test that cannot reach it is what subtraction is for -- an arm no caller enters is a rule nobody can break and nobody can rely on, and leaving it would have bought a green mutation line for a branch the machine never runs. The two doc sentences that named the resolution scope go with it. Four fixtures close gaps where the code was right and the suite could not tell it from something weaker: a ruling answering a reopened question takes the next sequence rather than reproducing the withdrawn answer's; a cut spec's revisions are read within its own cut, so cut B's second does not block reinstating cut A's first; `later_in_force` counts only what stands, and reads the batch, so an assignment derived beside a withdrawal still blocks it. Entries H62-H67 pin all six. H1-H67 killed by their own tests, M0 green; 46 tests pass with no warnings; `cargo check --workspace` clean. Co-Authored-By: Claude Fable 5.1 --- crates/huginn-mind/src/admission.rs | 184 ++++++++++++++++++++++++++-- tools/eureka-cut8-mutations.psd1 | 73 ++++++++++- 2 files changed, 246 insertions(+), 11 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 8cb2f0c..d33c2e6 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -321,8 +321,9 @@ impl Docs { } /// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal - /// `hand_off_key` derives. At most one is in force once the stewardship - /// row holds, which is why `stewardship_of` needs no tie-break. + /// `hand_off_key` derives. The stewardship row holds one in force at a + /// time, but that exclusion can put a second back in the list: the one a + /// replayed hand-off withdrew, beside whatever stewards the repo now. fn stewardships_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Vec<(&str, &PipelineStewardship)> { self.stewardships() .filter(|(key, stewardship)| { @@ -343,9 +344,28 @@ impl Docs { self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key) } - /// The in-force stewardship of `(mind, repo)`, if any. + /// The stewardship of `(mind, repo)` a hand-off acts on: the one whose + /// withdrawal already carries `hand_off_key`, when image or batch holds + /// that withdrawal, so a replay derives the same withdrawal again and A9 + /// answers with the stored receipt; otherwise the one in force. Key order + /// cannot pick between the two the exclusion leaves standing -- once a + /// repo has changed hands ten times, `n11` sorts before `n2` -- so the + /// content match picks, as it does for the sequence the other derivations + /// take. fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> { - self.stewardships_of(mind, repo, hand_off_key).first().copied() + let candidates = self.stewardships_of(mind, repo, hand_off_key); + hand_off_key + .and_then(|hand_off| { + candidates.iter().find(|(key, _)| { + self.resolutions().any(|(_, resolution)| { + resolution.subject.kind == PipelineKind::Stewardship + && resolution.subject.id.0 == *key + && matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == hand_off) + }) + }) + }) + .or_else(|| candidates.first()) + .copied() } } @@ -768,16 +788,17 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { } /// Whether `other` is a later record than `base` in the scope `base` is -/// sequenced in: `(instance, repo)` for a stewardship, the subject for a -/// resolution, the campaign for a target and the cut for a cut spec. Kinds -/// that carry no sequence have no scope and no order. +/// sequenced in: `(instance, repo)` for a stewardship, the campaign for a +/// target and the cut for a cut spec. Kinds that carry no sequence have no +/// scope and no order, and a resolution is never a base here: the cap above +/// refuses the withdrawal that would reinstate one before the question of a +/// later record arises. fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool { use PipelineDocument as D; match (base, other) { (D::Stewardship(base), D::Stewardship(other)) => { other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence } - (D::Resolution(base), D::Resolution(other)) => other.subject == base.subject && other.sequence > base.sequence, (D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision, (D::CutSpec(base), D::CutSpec(other)) => { other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision @@ -830,8 +851,8 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M // Q19 A's rationale one step out from the cap: a withdrawal never // re-raises an earlier record over a later one. Withdrawing a resolution // puts its subject back in force, so refuse while a later record of that - // subject's own scope stands -- a repo's next assignment, a subject's next - // resolution, a document's next revision. + // subject's own scope stands -- a repo's next assignment, a document's + // next revision. if matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. }) && subject_kind == K::Resolution && let Some(D::Resolution(reinstating)) = docs.find(K::Resolution, subject_id) @@ -1093,6 +1114,30 @@ mod tests { assert_eq!(receipt_count(&receiving), receipts); } + /// The sequence a ruling's answer takes is the one that ruling's own + /// answer carries, not whatever answer the question already has. A + /// question reopened by withdrawing its first answer is answered again at + /// the next sequence, so the first answer stays readable under it. + #[test] + fn a_ruling_answering_a_reopened_question_takes_the_next_sequence() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut first = ruling("R1"); + first.answers = Some(s(&id("question", "Q1"))); + first.choice = Some(l("A")); + committed(admit(&mut mind, vec![D::Ruling(first)])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn())])); + let mut second = ruling("R2"); + second.answers = Some(s(&id("question", "Q1"))); + second.choice = Some(l("B")); + let (_, writes) = committed(admit(&mut mind, vec![D::Ruling(second)])); + assert_eq!(writes, vec![ + r(K::Ruling, &id("ruling", "R2")), + r(K::Resolution, &id("resolution", "question.Q1.n2")), + ]); + assert!(mind.envelope(K::Resolution, &id("resolution", "question.Q1.n1")).is_some()); + } + #[test] fn a_refused_batch_is_not_answered_from_a_stored_receipt() { let mut mind = seeded(); @@ -1426,6 +1471,125 @@ mod tests { ]); } + fn hand_off_on(from: &str, to: &str, day: &str) -> D { + let D::HandOff(mut hand_off) = hand_off(from, to, REPO, &[]) else { panic!() }; + hand_off.handed_on = epiphany_pipeline::Date(day.into()); + D::HandOff(hand_off) + } + + fn stewardship_key(sequence: u32) -> String { + format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n{sequence}") + } + + /// Which assignment a hand-off's derived withdrawal names is a question of + /// content, not of order. While the withdrawal is being derived the + /// hand-off's own is excluded from what stands, so two assignments are in + /// force at once, and after ten transfers the one that stewards the repo + /// now sorts first: `n11` before `n2`. The replay must still withdraw the + /// assignment it withdrew the first time, or it derives a second + /// withdrawal of a standing assignment and collides instead of answering + /// with its receipt. + #[test] + fn a_source_side_replay_withdraws_the_assignment_it_withdrew() { + let mut mind = seeded(); + let mut aways = Vec::new(); + for transfer in 1..=10u32 { + let day = format!("2026-09-{transfer:02}"); + let away = hand_off_on(INSTANCE, OTHER_INSTANCE, &day); + let (receipt_id, _) = committed(admit(&mut mind, vec![away.clone()])); + aways.push((away, receipt_id)); + committed(admit(&mut mind, vec![hand_off_on(OTHER_INSTANCE, INSTANCE, &day)])); + } + let in_force = docs_of(&mind).stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| key.to_string()).collect::>(); + assert_eq!(in_force, vec![stewardship_key(11)]); + + for index in [1usize, 8] { + let (away, receipt_id) = &aways[index]; + assert_eq!( + admit(&mut mind, vec![away.clone()]), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: receipt_id.clone() }, + "the replay of transfer {} re-derives its own withdrawal", + index + 1 + ); + } + + // The standing assignment is handed away and reinstated, so it too + // carries a withdrawal -- of another hand-off. Sorting first, it is + // what a match on the outcome alone would pick. + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-11")])); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n11.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])); + let (away, receipt_id) = &aways[1]; + assert_eq!( + admit(&mut mind, vec![away.clone()]), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: receipt_id.clone() } + ); + } + + /// `later_in_force` counts what stands, not what exists: the repo went + /// away, came back and went away again, so the second assignment is later + /// than the first but withdrawn, and reinstating the first leaves one + /// assignment in force rather than two. + #[test] + fn a_withdrawn_later_assignment_does_not_block_a_reinstatement() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-16")])); + committed(admit(&mut mind, vec![hand_off_on(OTHER_INSTANCE, INSTANCE, "2026-09-17")])); + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-18")])); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])); + let in_force = docs_of(&mind).stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| key.to_string()).collect::>(); + assert_eq!(in_force, vec![stewardship_key(1)]); + } + + /// And it reads the batch: the assignment that stands later than the one + /// a withdrawal would reinstate may be landing beside that withdrawal, + /// derived by a hand-off in the same batch. + #[test] + fn a_reinstatement_is_blocked_by_an_assignment_in_its_own_batch() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-16")])); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![ + hand_off_on(OTHER_INSTANCE, INSTANCE, "2026-09-17"), + resolution(r(K::Resolution, &withdrawal), withdrawn()), + ])), + MindRefusal::WouldReinstateOverLater { subject: stewardship_key(1), later: stewardship_key(2) } + ); + } + + /// A cut spec's revisions are sequenced within its cut, so another cut's + /// later revision is not what a withdrawal reads: once cut A's second + /// revision is gone, reinstating its first commits while cut B's second + /// stands. + #[test] + fn a_reinstatement_reads_the_revisions_of_its_own_cut() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("A", 1)), D::CutSpec(cut_spec("B", 1))])); + for cut in ["A", "B"] { + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec(cut, 2)), + resolution( + r(K::CutSpec, &id("cut_spec", &format!("cut-{cut}.r1"))), + superseded(&[r(K::CutSpec, &id("cut_spec", &format!("cut-{cut}.r2")))]), + ), + ])); + } + let supersession = id("resolution", "cut_spec.cut-A.r1.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &supersession), withdrawn())])), + MindRefusal::WouldReinstateOverLater { + subject: id("cut_spec", "cut-A.r1"), + later: id("cut_spec", "cut-A.r2"), + } + ); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-A.r2")), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &supersession), withdrawn())])); + assert!(docs_of(&mind).in_force(K::CutSpec, &id("cut_spec", "cut-A.r1"))); + assert!(docs_of(&mind).in_force(K::CutSpec, &id("cut_spec", "cut-B.r2"))); + } + /// A seeded mind with one document of every resolvable kind, and the /// non-resolvable ones beside them. fn world() -> Mind { diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index e25af17..d398e5e 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -7,7 +7,14 @@ # left: the sequences a replay re-derives (H52-H54), the withdrawal that would # re-raise an earlier record over a later one (H55-H56), the gap a sequence # rule must refuse (H57-H58), and three rules the suite could not tell apart -# from weaker ones (H59-H61). Run through Epiphany's harness from this repo: +# from weaker ones (H59-H61). H62-H67 are the second pass's own: the +# assignment a replayed hand-off withdraws, picked by content rather than key +# order (H62-H63), and four more rules the suite could not tell apart from +# weaker ones (H64-H67). Run through Epiphany's harness from this repo: +# +# Soul's Y7 -- `later_than`'s resolution arm returning `false` -- has no entry +# here and can have none: the arm is deleted. It was dead code behind the Q19 +# cap, so no test could reach it and no mutation of it could fail. # # H32 is re-anchored to the same follow-up: its A4 half is unreachable now # that a derived write takes the sequence after the batch's own records, so a @@ -740,5 +747,69 @@ impl MindStore for OwnedRedbMessagePackBackingStore { let sequence = 1; '@ } + @{ + Id = 'H62' + Rule = 'stewardship_of: the assignment a hand-off withdraws is picked by key order, so a replay after ten transfers withdraws whichever sorts first.' + Test = 'admission::tests::a_source_side_replay_withdraws_the_assignment_it_withdrew' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + let candidates = self.stewardships_of(mind, repo, hand_off_key); + hand_off_key + .and_then(|hand_off| { + candidates.iter().find(|(key, _)| { + self.resolutions().any(|(_, resolution)| { + resolution.subject.kind == PipelineKind::Stewardship + && resolution.subject.id.0 == *key + && matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == hand_off) + }) + }) + }) + .or_else(|| candidates.first()) + .copied() +'@ + New = @' + self.stewardships_of(mind, repo, hand_off_key).first().copied() +'@ + } + @{ + Id = 'H63' + Rule = 'stewardship_of: H62''s weaker half, any withdrawal of a candidate identifies it, whatever hand-off gave the reason.' + Test = 'admission::tests::a_source_side_replay_withdraws_the_assignment_it_withdrew' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' && matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == hand_off)' + New = ' && { let _ = hand_off; matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { .. }) }' + } + @{ + Id = 'H64' + Rule = 'derive: the ruling''s resolution matches any answer of the question, whichever ruling answered it.' + Test = 'admission::tests::a_ruling_answering_a_reopened_question_takes_the_next_sequence' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == staged.key)' + New = ' matches!(outcome, ResolutionOutcome::Answered { .. })' + } + @{ + Id = 'H65' + Rule = 'later_than: a cut spec is sequenced within its campaign, not within its cut.' + Test = 'admission::tests::a_reinstatement_reads_the_revisions_of_its_own_cut' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision' + New = ' other.campaign == base.campaign && other.revision > base.revision' + } + @{ + Id = 'H66' + Rule = 'later_in_force: a later record blocks a reinstatement whether or not it still stands.' + Test = 'admission::tests::a_withdrawn_later_assignment_does_not_block_a_reinstatement' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)' + New = ' self.of_kind(kind).find(|(key, other)| later_than(base, other)).map(|(key, _)| key)' + } + @{ + Id = 'H67' + Rule = 'later_in_force: the image only, so a later record landing in the same batch does not block.' + Test = 'admission::tests::a_reinstatement_is_blocked_by_an_assignment_in_its_own_batch' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)' + New = ' self.image.iter().filter(|held| held.kind == kind).map(|held| (held.key.as_str(), &held.document)).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)' + } ) } From 5ae34f814785ad00b65529827e7fcfdd5088228f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 21:13:03 +0200 Subject: [PATCH 018/100] Move every derivation out of admission into docs.rs `Docs`, `Staged`, `Held`, `kind_of_type` and `later_than` leave `admission.rs` whole and land in `src/docs.rs`, so the read side of Cut 9 can reach the derivations without admission exporting its internals. One owner for in force: the views will call the same `Docs` the rules do, never a second copy. Three edits ride along, each an extraction the views need and admission now runs through: - `closing_resolution_unless` is `in_force_unless`'s body, returning the resolution that closes a document rather than a bool; `in_force_unless` is `.is_none()` over it. - `resolutions_of` and `assignments_of` are the scope selectors `latest_resolution`, `latest_stewardship` and `stewardships_of` already spelled out one at a time. Nothing else changes: same bodies, same call sites, `admission.rs` keeps every rule and every `docs.` call reads as before. Its module doc says where in force lives now. Nine cut-8 entries re-anchor because their code moved: H33, H41, H62, H63, H65, H66 and H67 by file, H47 and H60 on the scope selectors the extraction introduced. Their rules are unchanged, and H49's mutant still compiles because `Docs`'s fields are `pub(crate)`. `cargo test -p huginn-mind --lib`: 46 passed, 0 failed, 0 warnings. `eureka-cut8-mutations.psd1` through Epiphany's harness: M0 green, H1-H67 all killed. Co-Authored-By: Claude Fable 5.1 --- crates/huginn-mind/src/admission.rs | 216 +---------------------- crates/huginn-mind/src/docs.rs | 259 ++++++++++++++++++++++++++++ crates/huginn-mind/src/lib.rs | 1 + tools/eureka-cut8-mutations.psd1 | 48 +++--- 4 files changed, 291 insertions(+), 233 deletions(-) create mode 100644 crates/huginn-mind/src/docs.rs diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index d33c2e6..b942e05 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -22,22 +22,22 @@ //! //! Every cross-field and cross-document rule lives here and nowhere else: //! the leaf never gains one, the daemon and the client never re-derive one. -//! "In force" is recursive: a document is in force when no resolution that is -//! itself in force names it, so a withdrawn resolution stops closing its -//! subject. It is computed at rule time over image and batch, never stored. +//! In force, the sequences and the scopes are `docs.rs`'s, shared with the +//! views (Cut 9). use std::collections::BTreeSet; use chrono::{DateTime, Utc}; use cultcache_rs::CultCacheEnvelope; use epiphany_pipeline::{ - ClaimOutcome, FindingConfidence, Line, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal, + ClaimOutcome, FindingConfidence, Line, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal, PipelineResolution, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key, validate_pipeline_write_envelope, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::docs::{Docs, Staged}; use crate::mind::{HuginnMindEpoch, Mind, unavailable}; use crate::receipt::{self, CommitOutcome, PipelineProvenance}; use crate::refusal::MindRefusal; @@ -174,31 +174,6 @@ impl Mind { } } -/// A batch document after A3 and A5: decoded, keyed, and its envelope. -struct Staged { - kind: PipelineKind, - key: String, - document: PipelineDocument, - envelope: CultCacheEnvelope, -} - -/// An image document, decoded. -struct Held { - kind: PipelineKind, - key: String, - document: PipelineDocument, -} - -/// The image and the batch, the two places a rule may look. -struct Docs { - image: Vec, - batch: Vec, -} - -fn kind_of_type(type_id: &str) -> Option { - PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == type_id) -} - /// The kind segment of a full id, for the one field typed `Short` whose /// referent may be of any kind. fn kind_of_id(id: &str) -> Option { @@ -206,169 +181,6 @@ fn kind_of_id(id: &str) -> Option { PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name) } -impl Docs { - fn from_image(envelopes: &[CultCacheEnvelope]) -> Result { - let mut image = Vec::new(); - for envelope in envelopes { - let Some(kind) = kind_of_type(&envelope.r#type) else { continue }; - let document = PipelineDocument::decode(envelope).map_err(|refusal| MindRefusal::Unavailable { - detail: format!("stored document {} does not decode: {refusal}", envelope.key), - })?; - image.push(Held { kind, key: envelope.key.clone(), document }); - } - Ok(Self { image, batch: Vec::new() }) - } - - /// A4: one identity, once. - fn push(&mut self, staged: Staged) -> Result<(), MindRefusal> { - if self.batch.iter().any(|other| other.kind == staged.kind && other.key == staged.key) { - return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key }); - } - self.batch.push(staged); - Ok(()) - } - - fn in_batch(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { - self.batch.iter().find(|staged| staged.kind == kind && staged.key == id).map(|staged| &staged.document) - } - - fn in_image(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { - self.image.iter().find(|held| held.kind == kind && held.key == id).map(|held| &held.document) - } - - fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { - self.in_batch(kind, id).or_else(|| self.in_image(kind, id)) - } - - fn of_kind(&self, kind: PipelineKind) -> impl Iterator { - let batch = self.batch.iter().filter(move |staged| staged.kind == kind).map(|staged| (staged.key.as_str(), &staged.document)); - let image = self.image.iter().filter(move |held| held.kind == kind).map(|held| (held.key.as_str(), &held.document)); - batch.chain(image) - } - - fn resolutions(&self) -> impl Iterator { - self.of_kind(PipelineKind::Resolution).filter_map(|(key, document)| match document { - PipelineDocument::Resolution(resolution) => Some((key, resolution)), - _ => None, - }) - } - - fn stewardships(&self) -> impl Iterator { - self.of_kind(PipelineKind::Stewardship).filter_map(|(key, document)| match document { - PipelineDocument::Stewardship(stewardship) => Some((key, stewardship)), - _ => None, - }) - } - - /// The greatest sequence among a subject's resolutions in image or batch, - /// `0` when it has none, so the next is `latest + 1`. `own` is the - /// document being checked, excluded by content identity so that an exact - /// replay measures the same `latest` the first admission did. - fn latest_resolution(&self, subject: &PipelineRef, own: Option<&PipelineResolution>) -> u32 { - self.resolutions() - .filter(|(_, resolution)| resolution.subject == *subject && own != Some(*resolution)) - .map(|(_, resolution)| resolution.sequence) - .max() - .unwrap_or(0) - } - - /// The sequence a derivation takes: the one the record it already derived - /// carries, when image or batch holds that record, so an exact replay - /// re-derives the same document and A9 answers with the stored receipt; - /// otherwise the next. A derivation is recognised by what it writes into - /// its record: a resolution by its outcome, an assignment by its note. - fn derived_resolution_sequence(&self, subject: &PipelineRef, derived: impl Fn(&ResolutionOutcome) -> bool) -> u32 { - self.resolutions() - .find(|(_, resolution)| resolution.subject == *subject && derived(&resolution.outcome)) - .map_or_else(|| self.latest_resolution(subject, None) + 1, |(_, resolution)| resolution.sequence) - } - - /// The same for the assignment a hand-off derives, which notes its key. - fn derived_stewardship_sequence(&self, mind: &Slug, repo: &OrgRepo, note: &str) -> u32 { - self.stewardships() - .find(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && stewardship.note.0 == note) - .map_or_else(|| self.latest_stewardship(mind, repo, None) + 1, |(_, stewardship)| stewardship.sequence) - } - - /// The same for a repo's assignments to one mind. - fn latest_stewardship(&self, mind: &Slug, repo: &OrgRepo, own: Option<&PipelineStewardship>) -> u32 { - self.stewardships() - .filter(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && own != Some(*stewardship)) - .map(|(_, stewardship)| stewardship.sequence) - .max() - .unwrap_or(0) - } - - /// No resolution that is itself in force names the document. The - /// recursion is the whole rule: a withdrawn resolution stops counting, so - /// its subject is open again. `key(R)` is strictly longer than the id it - /// resolves, so the chain is bounded and this terminates. - fn in_force(&self, kind: PipelineKind, id: &str) -> bool { - self.in_force_unless(kind, id, |_| false) - } - - /// In force, ignoring a resolution the caller's own document is or - /// derives: the ruling that answers a question is what resolves it, the - /// hand-off that withdraws a stewardship is what resolves that, and a - /// resolution does not resolve its own subject out from under itself. - fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&PipelineResolution) -> bool) -> bool { - !self.resolutions().any(|(key, resolution)| { - resolution.subject.kind == kind - && resolution.subject.id.0 == id - && !own(resolution) - && self.in_force(PipelineKind::Resolution, key) - }) - } - - /// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal - /// `hand_off_key` derives. The stewardship row holds one in force at a - /// time, but that exclusion can put a second back in the list: the one a - /// replayed hand-off withdrew, beside whatever stewards the repo now. - fn stewardships_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Vec<(&str, &PipelineStewardship)> { - self.stewardships() - .filter(|(key, stewardship)| { - stewardship.instance == *mind - && stewardship.repo == *repo - && self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { - matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) - }) - }) - .collect() - } - - /// The in-force record that stands later than `base` in the scope `base` - /// is sequenced in, if any: what a withdrawal that put `base` back in - /// force would leave standing beside it. - fn later_in_force(&self, base: &PipelineDocument) -> Option<&str> { - let kind = base.kind(); - self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key) - } - - /// The stewardship of `(mind, repo)` a hand-off acts on: the one whose - /// withdrawal already carries `hand_off_key`, when image or batch holds - /// that withdrawal, so a replay derives the same withdrawal again and A9 - /// answers with the stored receipt; otherwise the one in force. Key order - /// cannot pick between the two the exclusion leaves standing -- once a - /// repo has changed hands ten times, `n11` sorts before `n2` -- so the - /// content match picks, as it does for the sequence the other derivations - /// take. - fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> { - let candidates = self.stewardships_of(mind, repo, hand_off_key); - hand_off_key - .and_then(|hand_off| { - candidates.iter().find(|(key, _)| { - self.resolutions().any(|(_, resolution)| { - resolution.subject.kind == PipelineKind::Stewardship - && resolution.subject.id.0 == *key - && matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == hand_off) - }) - }) - }) - .or_else(|| candidates.first()) - .copied() - } -} - /// A3 through the leaf, then A5. fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { validate_pipeline_write_envelope(&envelope)?; @@ -787,26 +599,6 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { fits } -/// Whether `other` is a later record than `base` in the scope `base` is -/// sequenced in: `(instance, repo)` for a stewardship, the campaign for a -/// target and the cut for a cut spec. Kinds that carry no sequence have no -/// scope and no order, and a resolution is never a base here: the cap above -/// refuses the withdrawal that would reinstate one before the question of a -/// later record arises. -fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool { - use PipelineDocument as D; - match (base, other) { - (D::Stewardship(base), D::Stewardship(other)) => { - other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence - } - (D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision, - (D::CutSpec(base), D::CutSpec(other)) => { - other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision - } - _ => false, - } -} - /// The resolution row: the sequence, the in-force subject, the matrix, the /// Q19 cap and the reinstatement rule it rests on, a non-empty supersession, /// every referent in force, and the two diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs new file mode 100644 index 0000000..c172047 --- /dev/null +++ b/crates/huginn-mind/src/docs.rs @@ -0,0 +1,259 @@ +//! The image and the batch, the two places a rule or a view may look, and +//! every derivation over them. "In force" is recursive: a document is in force +//! when no resolution that is itself in force names it, so a withdrawn +//! resolution stops closing its subject. Computed over image and batch at rule +//! time, over the image at read time, never stored, and never from +//! `stored_at`. + +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{ + OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineStewardship, ResolutionOutcome, + Slug, +}; + +use crate::refusal::MindRefusal; + +/// A batch document after A3 and A5: decoded, keyed, and its envelope. +pub(crate) struct Staged { + pub(crate) kind: PipelineKind, + pub(crate) key: String, + pub(crate) document: PipelineDocument, + pub(crate) envelope: CultCacheEnvelope, +} + +/// An image document, decoded. +pub(crate) struct Held { + pub(crate) kind: PipelineKind, + pub(crate) key: String, + pub(crate) document: PipelineDocument, +} + +/// The image and the batch, the two places a rule may look. +pub(crate) struct Docs { + pub(crate) image: Vec, + pub(crate) batch: Vec, +} + +fn kind_of_type(type_id: &str) -> Option { + PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == type_id) +} + +impl Docs { + pub(crate) fn from_image(envelopes: &[CultCacheEnvelope]) -> Result { + let mut image = Vec::new(); + for envelope in envelopes { + let Some(kind) = kind_of_type(&envelope.r#type) else { continue }; + let document = PipelineDocument::decode(envelope).map_err(|refusal| MindRefusal::Unavailable { + detail: format!("stored document {} does not decode: {refusal}", envelope.key), + })?; + image.push(Held { kind, key: envelope.key.clone(), document }); + } + Ok(Self { image, batch: Vec::new() }) + } + + /// A4: one identity, once. + pub(crate) fn push(&mut self, staged: Staged) -> Result<(), MindRefusal> { + if self.batch.iter().any(|other| other.kind == staged.kind && other.key == staged.key) { + return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key }); + } + self.batch.push(staged); + Ok(()) + } + + pub(crate) fn in_batch(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.batch.iter().find(|staged| staged.kind == kind && staged.key == id).map(|staged| &staged.document) + } + + pub(crate) fn in_image(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.image.iter().find(|held| held.kind == kind && held.key == id).map(|held| &held.document) + } + + pub(crate) fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.in_batch(kind, id).or_else(|| self.in_image(kind, id)) + } + + pub(crate) fn of_kind(&self, kind: PipelineKind) -> impl Iterator { + let batch = self.batch.iter().filter(move |staged| staged.kind == kind).map(|staged| (staged.key.as_str(), &staged.document)); + let image = self.image.iter().filter(move |held| held.kind == kind).map(|held| (held.key.as_str(), &held.document)); + batch.chain(image) + } + + pub(crate) fn resolutions(&self) -> impl Iterator { + self.of_kind(PipelineKind::Resolution).filter_map(|(key, document)| match document { + PipelineDocument::Resolution(resolution) => Some((key, resolution)), + _ => None, + }) + } + + pub(crate) fn stewardships(&self) -> impl Iterator { + self.of_kind(PipelineKind::Stewardship).filter_map(|(key, document)| match document { + PipelineDocument::Stewardship(stewardship) => Some((key, stewardship)), + _ => None, + }) + } + + /// Every resolution of one subject, in image and batch: the scope a + /// subject's sequence counts in and the scope its history is read from. + pub(crate) fn resolutions_of(&self, subject: &PipelineRef) -> impl Iterator { + self.resolutions().filter(move |(_, resolution)| resolution.subject == *subject) + } + + /// Every assignment of one repo to one mind, in image and batch, in force + /// or not: the scope a stewardship's sequence counts in and the scope its + /// history is read from. + pub(crate) fn assignments_of(&self, mind: &Slug, repo: &OrgRepo) -> impl Iterator { + self.stewardships().filter(move |(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo) + } + + /// The greatest sequence among a subject's resolutions in image or batch, + /// `0` when it has none, so the next is `latest + 1`. `own` is the + /// document being checked, excluded by content identity so that an exact + /// replay measures the same `latest` the first admission did. + pub(crate) fn latest_resolution(&self, subject: &PipelineRef, own: Option<&PipelineResolution>) -> u32 { + self.resolutions_of(subject) + .filter(|(_, resolution)| own != Some(*resolution)) + .map(|(_, resolution)| resolution.sequence) + .max() + .unwrap_or(0) + } + + /// The sequence a derivation takes: the one the record it already derived + /// carries, when image or batch holds that record, so an exact replay + /// re-derives the same document and A9 answers with the stored receipt; + /// otherwise the next. A derivation is recognised by what it writes into + /// its record: a resolution by its outcome, an assignment by its note. + pub(crate) fn derived_resolution_sequence( + &self, + subject: &PipelineRef, + derived: impl Fn(&ResolutionOutcome) -> bool, + ) -> u32 { + self.resolutions() + .find(|(_, resolution)| resolution.subject == *subject && derived(&resolution.outcome)) + .map_or_else(|| self.latest_resolution(subject, None) + 1, |(_, resolution)| resolution.sequence) + } + + /// The same for the assignment a hand-off derives, which notes its key. + pub(crate) fn derived_stewardship_sequence(&self, mind: &Slug, repo: &OrgRepo, note: &str) -> u32 { + self.stewardships() + .find(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && stewardship.note.0 == note) + .map_or_else(|| self.latest_stewardship(mind, repo, None) + 1, |(_, stewardship)| stewardship.sequence) + } + + /// The same for a repo's assignments to one mind. + pub(crate) fn latest_stewardship(&self, mind: &Slug, repo: &OrgRepo, own: Option<&PipelineStewardship>) -> u32 { + self.assignments_of(mind, repo) + .filter(|(_, stewardship)| own != Some(*stewardship)) + .map(|(_, stewardship)| stewardship.sequence) + .max() + .unwrap_or(0) + } + + /// No resolution that is itself in force names the document. The + /// recursion is the whole rule: a withdrawn resolution stops counting, so + /// its subject is open again. `key(R)` is strictly longer than the id it + /// resolves, so the chain is bounded and this terminates. + pub(crate) fn in_force(&self, kind: PipelineKind, id: &str) -> bool { + self.in_force_unless(kind, id, |_| false) + } + + /// In force, ignoring a resolution the caller's own document is or + /// derives: the ruling that answers a question is what resolves it, the + /// hand-off that withdraws a stewardship is what resolves that, and a + /// resolution does not resolve its own subject out from under itself. + pub(crate) fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&PipelineResolution) -> bool) -> bool { + self.closing_resolution_unless(kind, id, own).is_none() + } + + /// The in-force resolution naming the document, other than `own`, if any; + /// exact rather than a tie-break because A8 and the Q19 cap admit at most + /// one. + pub(crate) fn closing_resolution_unless( + &self, + kind: PipelineKind, + id: &str, + own: impl Fn(&PipelineResolution) -> bool, + ) -> Option<(&str, &PipelineResolution)> { + self.resolutions().find(|&(key, resolution)| { + resolution.subject.kind == kind + && resolution.subject.id.0 == id + && !own(resolution) + && self.in_force(PipelineKind::Resolution, key) + }) + } + + /// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal + /// `hand_off_key` derives. The stewardship row holds one in force at a + /// time, but that exclusion can put a second back in the list: the one a + /// replayed hand-off withdrew, beside whatever stewards the repo now. + pub(crate) fn stewardships_of( + &self, + mind: &Slug, + repo: &OrgRepo, + hand_off_key: Option<&str>, + ) -> Vec<(&str, &PipelineStewardship)> { + self.assignments_of(mind, repo) + .filter(|(key, _)| { + self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { + matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) + }) + .collect() + } + + /// The in-force record that stands later than `base` in the scope `base` + /// is sequenced in, if any: what a withdrawal that put `base` back in + /// force would leave standing beside it. + pub(crate) fn later_in_force(&self, base: &PipelineDocument) -> Option<&str> { + let kind = base.kind(); + self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key) + } + + /// The stewardship of `(mind, repo)` a hand-off acts on: the one whose + /// withdrawal already carries `hand_off_key`, when image or batch holds + /// that withdrawal, so a replay derives the same withdrawal again and A9 + /// answers with the stored receipt; otherwise the one in force. Key order + /// cannot pick between the two the exclusion leaves standing -- once a + /// repo has changed hands ten times, `n11` sorts before `n2` -- so the + /// content match picks, as it does for the sequence the other derivations + /// take. + pub(crate) fn stewardship_of( + &self, + mind: &Slug, + repo: &OrgRepo, + hand_off_key: Option<&str>, + ) -> Option<(&str, &PipelineStewardship)> { + let candidates = self.stewardships_of(mind, repo, hand_off_key); + hand_off_key + .and_then(|hand_off| { + candidates.iter().find(|(key, _)| { + self.resolutions().any(|(_, resolution)| { + resolution.subject.kind == PipelineKind::Stewardship + && resolution.subject.id.0 == *key + && matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == hand_off) + }) + }) + }) + .or_else(|| candidates.first()) + .copied() + } +} + +/// Whether `other` is a later record than `base` in the scope `base` is +/// sequenced in: `(instance, repo)` for a stewardship, the campaign for a +/// target and the cut for a cut spec. Kinds that carry no sequence have no +/// scope and no order, and a resolution is never a base here: the cap above +/// refuses the withdrawal that would reinstate one before the question of a +/// later record arises. +fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool { + use PipelineDocument as D; + match (base, other) { + (D::Stewardship(base), D::Stewardship(other)) => { + other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence + } + (D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision, + (D::CutSpec(base), D::CutSpec(other)) => { + other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision + } + _ => false, + } +} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index c6857b0..b3f188b 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -18,6 +18,7 @@ //! `now` is passed in. pub mod admission; +mod docs; pub mod mind; pub mod receipt; pub mod refusal; diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index d398e5e..4c8f053 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -25,11 +25,17 @@ # $env:CARGO_TARGET_DIR = 'C:\Users\Meta\.cargo-target-codex' # powershell -File F:\Projects\Epiphany\tools\eureka-mutations.ps1 -Repo F:\Projects\Huginn ` # -Entries tools/eureka-cut8-mutations.psd1 ` -# -Target crates/huginn-mind/src/mind.rs,crates/huginn-mind/src/receipt.rs,crates/huginn-mind/src/admission.rs,crates/huginn-mind/src/store.rs ` +# -Target crates/huginn-mind/src/mind.rs,crates/huginn-mind/src/receipt.rs,crates/huginn-mind/src/admission.rs,crates/huginn-mind/src/docs.rs,crates/huginn-mind/src/store.rs ` # -Test 'cargo test -p huginn-mind --lib' # # `store.rs` is a target only for H5, whose mutant needs a `MindStore` impl # for the transient redb store that the live crate deliberately lacks. +# +# Cut 9 moved `Docs`, its derivations and `later_than` into `docs.rs`, so the +# nine entries that anchor them name that file: H33, H41, H62, H63, H65, H66 +# and H67 by file alone, and H47 and H60 re-anchored on the scope selectors +# `assignments_of` and `resolutions_of` the move extracted. Their rules are +# unchanged. @{ Mutations = @( @{ @@ -442,7 +448,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H33' Rule = 'A4: one identity, once, within a batch.' Test = 'admission::tests::a_batch_is_one_to_sixty_four_documents_each_with_its_own_identity' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = @' if self.batch.iter().any(|other| other.kind == staged.kind && other.key == staged.key) { return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key }); @@ -518,7 +524,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H41' Rule = 'In force is recursive: a withdrawn resolution no longer closes its subject.' Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = @' && !own(resolution) && self.in_force(PipelineKind::Resolution, key) @@ -587,22 +593,22 @@ impl MindStore for OwnedRedbMessagePackBackingStore { } @{ Id = 'H47' - Rule = 'stewardship_of: only an in-force assignment counts, so a handed-away repo is not stewarded.' + Rule = 'stewardships_of: only an in-force assignment counts, so a handed-away repo is not stewarded.' Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = @' - .filter(|(key, stewardship)| { - stewardship.instance == *mind - && stewardship.repo == *repo - && self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { - matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) - }) + self.assignments_of(mind, repo) + .filter(|(key, _)| { + self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { + matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) }) '@ New = @' - .filter(|(_key, stewardship)| { + self.assignments_of(mind, repo) + .filter(|_| { let _ = hand_off_key; - stewardship.instance == *mind && stewardship.repo == *repo + true }) '@ } @@ -723,10 +729,10 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H60' Rule = 'latest_resolution reads the image only, not the batch.' Test = 'admission::tests::the_latest_resolution_counts_the_batch' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = @' - self.resolutions() - .filter(|(_, resolution)| resolution.subject == *subject && own != Some(*resolution)) + self.resolutions_of(subject) + .filter(|(_, resolution)| own != Some(*resolution)) '@ New = @' self.image.iter().filter(|held| held.kind == PipelineKind::Resolution).filter_map(|held| match &held.document { PipelineDocument::Resolution(resolution) => Some((held.key.as_str(), resolution)), _ => None }) @@ -751,7 +757,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H62' Rule = 'stewardship_of: the assignment a hand-off withdraws is picked by key order, so a replay after ten transfers withdraws whichever sorts first.' Test = 'admission::tests::a_source_side_replay_withdraws_the_assignment_it_withdrew' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = @' let candidates = self.stewardships_of(mind, repo, hand_off_key); hand_off_key @@ -775,7 +781,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H63' Rule = 'stewardship_of: H62''s weaker half, any withdrawal of a candidate identifies it, whatever hand-off gave the reason.' Test = 'admission::tests::a_source_side_replay_withdraws_the_assignment_it_withdrew' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = ' && matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == hand_off)' New = ' && { let _ = hand_off; matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { .. }) }' } @@ -791,7 +797,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H65' Rule = 'later_than: a cut spec is sequenced within its campaign, not within its cut.' Test = 'admission::tests::a_reinstatement_reads_the_revisions_of_its_own_cut' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = ' other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision' New = ' other.campaign == base.campaign && other.revision > base.revision' } @@ -799,7 +805,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H66' Rule = 'later_in_force: a later record blocks a reinstatement whether or not it still stands.' Test = 'admission::tests::a_withdrawn_later_assignment_does_not_block_a_reinstatement' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = ' self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)' New = ' self.of_kind(kind).find(|(key, other)| later_than(base, other)).map(|(key, _)| key)' } @@ -807,7 +813,7 @@ impl MindStore for OwnedRedbMessagePackBackingStore { Id = 'H67' Rule = 'later_in_force: the image only, so a later record landing in the same batch does not block.' Test = 'admission::tests::a_reinstatement_is_blocked_by_an_assignment_in_its_own_batch' - File = 'crates/huginn-mind/src/admission.rs' + File = 'crates/huginn-mind/src/docs.rs' Old = ' self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)' New = ' self.image.iter().filter(|held| held.kind == kind).map(|held| (held.key.as_str(), &held.document)).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)' } From 49cc6e3e623845667e3fa019cba1352ab924dcf3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 21:22:41 +0200 Subject: [PATCH 019/100] Read a mind: views, queries, open items and history `query.rs` is the read side, over a `&Mind` with no store file, no daemon and no index. A `PipelineDocumentView` is four fields: the id, because a `PipelineDocument` does not carry its key and a client must never derive one; the document; the facts of its admission; and its status. The facts come from the commit receipts and from their `writes` alone, so a document a later batch cited as a strong read keeps the receipt that wrote it, and `admitted_at` is that receipt's `committed_at` -- the `now` admission was passed -- never the store's `stored_at`. A pipeline document the image holds and no receipt wrote is an integrity fault and refuses; the store has one writer and the receipt is its proof. Status is derived per call through `docs::Docs`, the same owner the admission rules ask, so a withdrawn resolution reopens its subject for a reader exactly when it reopens it for a rule. `closing_resolution` is `in_force_unless`'s own body asked for the resolution rather than the bool, so there is no second derivation to drift. Four reads: `view` by id, `query` over the filters (root, repo, cut, kinds, in force, faculty, an admission window), `open_items` for one campaign's unanswered questions, standing findings, follow-ups, unreported specs and unjudged reports, and `history` for every resolution a subject ever had or every assignment of a repo to this mind, withdrawn ones included with their reasons. A query is ordered by `(admitted_at, id)`, capped at 200, and carries `matched`, the count before the cap, so a truncation is never a short page that reads like the whole answer. `semantic` is accepted by the type and refused typed until Cut 11 owns the index; no substring filter lives here to become a second answer to "the text of a document". `cargo test -p huginn-mind --lib`: 56 passed, 0 failed, 0 warnings (46 + the ten tests). `cargo check --workspace` clean. No dependency, no target, no kind, no schema, no epoch; `Cargo.toml` and `Cargo.lock` untouched. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 8 +- README.md | 11 +- crates/huginn-mind/src/docs.rs | 6 + crates/huginn-mind/src/fixtures.rs | 27 + crates/huginn-mind/src/lib.rs | 8 + crates/huginn-mind/src/query.rs | 922 +++++++++++++++++++++++++++++ 6 files changed, 976 insertions(+), 6 deletions(-) create mode 100644 crates/huginn-mind/src/query.rs diff --git a/AGENTS.md b/AGENTS.md index 0f32062..429897b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,12 @@ Studio's job, not Huginn's. - Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and `eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh runtimes come from `F:\Projects\CultLib\packages`. `huginn-mind` is live: - it persists and admits an instance's mind, over `cultcache-rs` and - `epiphany-pipeline` pinned by git rev. `huginn-daemon` and `eureka-state` + it persists, admits and reads back an instance's mind, queries and derived + status included, over `cultcache-rs` and `epiphany-pipeline` pinned by git + rev. `huginn-daemon` and `eureka-state` are stubs with no dependencies; nothing here publishes or connects yet. -- Owned: an instance's memory documents and their admission, in `huginn-mind`, +- Owned: an instance's memory documents, their admission and the read side + that derives their status, in `huginn-mind`, over a redb CultCache store at `/minds//mind.redb`. Their CultNet publication is owned here and not built; `huginn-daemon` is a stub. diff --git a/README.md b/README.md index 1eb2c3e..f04fafd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ edits `.cc` state. Huginn reads and writes minds. A Cargo workspace of three crates: -- `crates/huginn-mind`: storage, identity, and admission of memory documents. +- `crates/huginn-mind`: storage, identity, admission, and queries and derived + status over memory documents. - `crates/huginn-daemon`: a stub. It will carry the CultNet surface and serve loop; today it holds neither. - `crates/eureka-state`: a stub. It will carry typed state for the Eureka @@ -43,8 +44,12 @@ keys, then the organ's cross-document rules (references, in-force status, the resolution matrix, derived resolutions and stewardships), then one compare-and-swap that lands the batch whole with a receipt naming the exact bytes it read and wrote. An exact replay answers with the stored receipt. -Document shape and keys come from `epiphany-pipeline`; the store is CultLib's -Rust CultCache. +It reads them back the same way: a document with the facts of its admission +joined from that receipt and its status derived at read time, typed queries +over one mind, a campaign's open work, and a subject's or a repo's history. +Status is never stored, and the views derive it through the same rules +admission does. Document shape and keys come from `epiphany-pipeline`; the +store is CultLib's Rust CultCache. `huginn-daemon` and `eureka-state` are stubs; the campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns what each crate must do next. diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index c172047..5ac02ab 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -181,6 +181,12 @@ impl Docs { }) } + /// The resolution that closes the document, if any: the views' status, + /// derived where admission derives whether there is one at all. + pub(crate) fn closing_resolution(&self, kind: PipelineKind, id: &str) -> Option<(&str, &PipelineResolution)> { + self.closing_resolution_unless(kind, id, |_| false) + } + /// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal /// `hand_off_key` derives. The stewardship row holds one in force at a /// time, but that exclusion can put a second back in the list: the one a diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index a6ae3cb..69b8d97 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -330,10 +330,37 @@ pub(crate) fn opened(store: S, instance: &str) -> Mind { /// Admits as Hands at the fixed clock. pub(crate) fn admit(mind: &mut Mind, documents: Vec) -> PipelineAdmissionOutcome { + admit_at(mind, documents, now()) +} + +/// Admits as Hands at the clock the caller names: the views order by the +/// receipt's `committed_at`, which is this `now`. +pub(crate) fn admit_at( + mind: &mut Mind, + documents: Vec, + now: DateTime, +) -> PipelineAdmissionOutcome { let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(Faculty::Hands), documents }; + mind.admit(batch, now) +} + +/// Admits as another faculty at the fixed clock: attribution the views filter +/// on and no rule reads (ruling 18). +pub(crate) fn admit_as( + mind: &mut Mind, + faculty: Faculty, + documents: Vec, +) -> PipelineAdmissionOutcome { + let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(faculty), documents }; mind.admit(batch, now()) } +/// The `n`th plain question, for the tests that need more of them than they +/// need to tell apart. +pub(crate) fn question_n(n: u32) -> PipelineDocument { + question(&format!("Q{n}"), &["A", "B"], "A") +} + /// The first write into a fresh yggdrasil mind: its identity, stewardship of /// the campaign repo, the campaign and a first target with one invariant. pub(crate) fn seed(mind: &mut Mind) { diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index b3f188b..45a0606 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -10,6 +10,9 @@ //! cross-document rule, every derived write, and the replay check, in one //! ordered path that `admit`, the daemon's sink and the hand-off all share. //! +//! The read side derives status, joins the receipts and answers typed queries +//! through the same `docs` the rules use; nothing is stored for it. +//! //! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the //! organ registers, prepares, decodes and validates through the leaf's four //! doors and never re-derives a key. The store is CultLib's owned redb @@ -20,6 +23,7 @@ pub mod admission; mod docs; pub mod mind; +pub mod query; pub mod receipt; pub mod refusal; pub mod store; @@ -29,6 +33,10 @@ pub(crate) mod fixtures; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; pub use mind::{HuginnMindEpoch, Mind}; +pub use query::{ + AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage, + PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery, +}; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::MindRefusal; pub use store::MindStore; diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs new file mode 100644 index 0000000..7de29ed --- /dev/null +++ b/crates/huginn-mind/src/query.rs @@ -0,0 +1,922 @@ +//! The read side of a mind: a document with the facts of its admission and +//! its status, and the four queries over them. +//! +//! Nothing here is stored. Status is derived per call through the same +//! `docs::Docs` the admission rules use, so a withdrawn resolution reopens its +//! subject for a reader exactly when it reopens it for a rule. The facts come +//! from the commit receipts, from their `writes` and from nothing else: a +//! document's `admitted_at` is the `now` its batch was admitted at +//! (`committed_at`), never the store's `stored_at` stamp. A pipeline document +//! in the image that no receipt wrote is an integrity fault and refuses; the +//! store has one writer and the receipt is its proof. +//! +//! `semantic` is accepted by the type and refused typed until Cut 11 wires the +//! index. No clock, no store handle, no network. + +use std::collections::BTreeMap; + +use epiphany_pipeline::{ + Label, Line, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, Short, Slug, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::docs::{Docs, Held}; +use crate::mind::Mind; +use crate::receipt::{Faculty, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The most views one query answers with. A query that matched more says so in +/// `matched`; the cap never truncates silently. +pub const QUERY_LIMIT_MAX: usize = 200; + +/// What admission recorded about a document when it landed: the receipt that +/// wrote it, the `now` that receipt was taken at, and who asked. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AdmissionFacts { + pub receipt_id: String, + pub admitted_at: String, + pub provenance: PipelineProvenance, +} + +/// Whether a document still stands, and if not, what closed it. Derived at +/// read time; no document carries a status field and no admission writes one. +/// The whole closing record travels, not only its outcome, because a reader +/// rehydrating wants why. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineStatus { + InForce, + Resolved { resolution: PipelineRef, record: PipelineResolution }, +} + +/// One document as a reader gets it: its id, the document, the facts of its +/// admission and its status. The id travels with the document because a +/// `PipelineDocument` does not carry its key and a client must never derive +/// one. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineDocumentView { + pub id: PipelineRef, + pub document: PipelineDocument, + pub admission: AdmissionFacts, + pub status: PipelineStatus, +} + +/// A semantic query, accepted by the type and refused until Cut 11 owns the +/// index. Cut 11 also owns which fields of each kind are text, so no substring +/// filter lives here to become a second answer to that question. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticQuery { + pub text: Line, + pub top_k: u32, +} + +/// The filters over one mind. Each is one rule, and an unset filter matches +/// everything. The mind is the instance, so there is no `instance` field: the +/// daemon picks the mind before it calls here. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineQuery { + /// The key's root segment: a campaign slug, or an instance for + /// stewardships, hand-offs and the identity document. + pub campaign: Option, + pub repo: Option, + pub cut: Option