From 133324a7f78047164f0ca02331398066a971ac15 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 10:31:23 +0200 Subject: [PATCH 001/471] Eureka substrate target: every subject resolvable, refusals typed, one gap home Campaign eureka-substrate, the Mind fixes the operator ruled next after idunn-watchdog. Typed target and rulings live in Eureka's mind. Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-target.md | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/eureka-substrate-target.md diff --git a/docs/eureka-substrate-target.md b/docs/eureka-substrate-target.md new file mode 100644 index 0000000..30d53ce --- /dev/null +++ b/docs/eureka-substrate-target.md @@ -0,0 +1,84 @@ +# Eureka substrate: target + +Status: target, Self, 2026-10-01. Campaign `eureka-substrate` in Eureka's mind +(instance `eureka`). Its questions, rulings, cut specs, verification and the +substrate gaps themselves are typed documents there. This page keeps the +rationale and the design truths the campaign produces. The cut map's body facts +and model page will live in `eureka-substrate-map.md`. + +## Why + +The first typed campaign, `idunn-watchdog` (postmortem: Idunn +`docs/watchdog-reporting-postmortem.md`, `6667f0f`), ran its whole pipeline +through Huginn and found the substrate's defects by hitting them. The operator +ruled, 2026-10-01: + +> Yep, fixing Mind issues is highest priority, immediately once this campaign +> closes + +The defects that cost the most: + +- **A finding can be admitted that can never be resolved.** A key's local part + is bounded at 64 bytes as a whole, and only when it is composed + (`epiphany-pipeline` `LOCAL_MAX`). A resolution's local embeds its subject's + local plus `finding.`/`follow_up.` and `.n`. So a subject whose own key + fits can have no admissible resolution, under any outcome. Six findings in + `idunn-watchdog` are stuck in force this way. +- **A shape refusal arrives labelled `Unavailable`.** Huginn's admission maps + the leaf's key error through `anyhow` into `MindRefusal::Unavailable`. The + skill tells every agent that `Unavailable` means the organ is down: retry + once, then stop. A faithful agent would halt a campaign over a key that was + too long. +- **Substrate gaps have no home every campaign can see.** Follow-ups are + campaign-rooted, so 20 `gap-*` follow-ups sit under `idunn-watchdog`, and a + later campaign's recipe cannot see them. +- **Smaller defects:** + - Revising one spec forced a revision of every spec that named it in + `depends_on`, twice, when the dependency was on a cut. + - A short sha and the full sha of one commit are different values to + admission. + - A finding cannot be deferred to the cut that fixes it, only to a pointer + follow-up. + - The faculty is named Life, but the enum says `MindSteward`. + - Stewardship is universal (operator, 2026-09-30), yet admission demands a + per-repo stewardship record that decides nothing. + +The code the live mind runs sits on two long-lived branches: Huginn +`eureka/memory-organ` (release `9dd6d80`), and Epiphany +`codex/eureka-pipeline-state`, where the leaf Huginn pins lives. The operator +ruled that they merge to main first. + +## Design truths this campaign produces + +- **Every admitted subject stays resolvable.** If admission accepts a document + whose kind can be resolved, every outcome its subject allows can be admitted + for it, up to a stated sequence bound. A grammar that cannot hold a key + refuses the subject at admission, not its resolution later. +- **A refusal names what was wrong.** A document that breaks a shape, bound or + key rule is refused as a typed document refusal. `Unavailable` means only + that the organ could not serve. +- **Stored documents stay valid.** No change in this campaign re-keys, + re-encodes or invalidates a document already in the mind. The epoch stays + `epiphany.pipeline.epoch.v2`. Every change is a widening, an added variant, + or an admission rule that judges new batches only. +- **One home for substrate gaps.** This campaign is the standing home for gaps + in the mind's own substrate. The skill's recipe names its root, and every + campaign reads it. +- **The mind's code comes from main.** The Huginn daemon, the leaf and the + `eureka-state` client the live mind runs are built from commits on Huginn's + and Epiphany's main. +- **The live mind survives its own upgrade.** Each deploy upgrades + `/var/lib/gamecult/huginn/minds/eureka/mind.redb` in place under a release + symlink that can be flipped back. Every document admitted before the upgrade + reads back after it. + +## Not this campaign + +- **Verify-stopgap gaps** (artifact return, `rg` in the image, sibling repos): + Idunn's verify campaign. +- **CultNet selection** (`gap-citer-faculty-hop`): CultLib. +- **The cold-embed timeout** and **a session-id tool**: recorded follow-ups. +- **Leasing stewardship between agents and `hand_off`**: Huginn Cut 12, parked. +- **Re-encoding stewardship ids into a readable form**, which would re-key + stored documents. +- **Persona-state reading in Huginn.** From f97bcedd7bc14c8683ca04e8d288b6772bc93d1a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 10:57:30 +0200 Subject: [PATCH 002/471] Eureka substrate map: body facts B1-B6, model page, rationale The id parser bounds locals at 64 too; Faculty lives in Huginn; two refusal sites map key errors to Unavailable; six findings unresolvable and 39 resolutions unwithdrawable in the live mind; the merges are safe. Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-map.md | 255 +++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/eureka-substrate-map.md diff --git a/docs/eureka-substrate-map.md b/docs/eureka-substrate-map.md new file mode 100644 index 0000000..abeff46 --- /dev/null +++ b/docs/eureka-substrate-map.md @@ -0,0 +1,255 @@ +# Eureka substrate: map + +Status: map, Imagination (`imagination-substrate`), 2026-10-01. Campaign +`eureka-substrate` in Eureka's mind. Questions, cut specs and rulings are +typed documents there; this page keeps body facts, the model page and +rationale only. Target: `docs/eureka-substrate-target.md` (`133324a7`). + +Pinned heads for every fact below: + +| Repo | Ref | Commit | +| --- | --- | --- | +| Huginn | `origin/main` | `133324a7f78047164f0ca02331398066a971ac15` | +| Huginn | `origin/eureka/memory-organ` (deployed release) | `9dd6d8007ab7de84c926a34edd3947ba15d1f01c` | +| Epiphany | `origin/main` | `4d1113ef0a4dbcead2e2c3cdc43aa13ef773dbb8` | +| Epiphany | `origin/codex/eureka-pipeline-state` | `4006b14bf29b49410bd18b43acb4d42c3c4e2443` | +| Epiphany | leaf pinned by Huginn (`crates/huginn-mind/Cargo.toml:22`) | `73edee5cdaddece8bda9c6c3271e8ddd064337f9` | +| CultLib | `cultcache-rs` pinned by both | `8fc74c709444b884a2bd339e0c306bf9484d99d2` | +| gamecult-ops | `main` | `b4eea88d801ad45aad2a49574a9eb2f750c7dd97` | +| Eureka skill | `main` | `094083566e19cccca026de8cbfc24462f1debe93` | + +Source reads were made in scratch clones at the pinned commits +(`scratchpad/imagination-substrate/{epiphany,huginn}`); line numbers are +against those commits. `lib.rs` below is `epiphany-pipeline/src/lib.rs`. + +## Body facts + +### B1. Key grammar (source read, leaf at `73edee5c`) + +- `LOCAL_MAX = 64` (`lib.rs:877`) bounds every composed local, whole + (`local`, `lib.rs:892-901`). It is the only bound on a resolution chain's + depth (comment `lib.rs:917-922`, test `lib.rs:2105-2150`). +- A resolution's local is `..n` + (`lib.rs:923-929`). Overhead over the subject's local: `finding` 11 bytes, + `follow_up` 13, `question` 12, `ruling` 10, `cut_spec` 12, `stewardship` 15 + at one sequence digit. A withdrawal (resolution of a resolution) adds + `resolution.` and `.n`, 14 more. +- So with subjects bounded at 64 and resolutions at 64, a finding local over + 53, a follow-up label over 51, a question label over 52, a ruling label over + 54 or a cut-spec local over 52 admits and can never be resolved. Self's map + said follow-up labels over 52; it is 51 (`follow_up` is 9 bytes, plus two + dots and `n1`). +- `pipeline_id` (`lib.rs:798-819`) parses every id's local with `dotted_text` + (`lib.rs:148-158`), whose 64-byte bound is the same one `Slug` uses + (`lib.rs:251`). Every `PipelineRef` validation goes through it + (`validate`, `lib.rs:589`; `validate_ref`, `lib.rs:609`), and so do + Huginn's view (`query.rs:433`), index (`index.rs:120`) and selection keys and + cites (`rows.rs:306`, `rows.rs:331`). Raising `LOCAL_MAX` alone therefore + makes a key that no id parser accepts. The parser bound has to move with it. +- The epoch is `epiphany.pipeline.epoch.v2` (`lib.rs:886`). `value_types!` + (`lib.rs:400-419`) emits no serde default on any field, so adding a field + would break the decode of stored documents. **No cut in this map adds a + field**, so this constraint is recorded and not exercised. +- The published schemas (`schemas/cultnet/epiphany.pipeline.*.v2.schema.json`) + carry no key bound. A resolution's `subject.id` is `Short`, 200 bytes. A key + is not a field, so no published schema changes when a local bound moves. +- Storage: `cultcache-rs` stores rows in redb as + `TableDefinition<&[u8], &[u8]>` (`CultLib packages/cultcache-rs/src/lib.rs:1023` + at `8fc74c70`), with no key bound of its own. A Qdrant point id is the + SHA-256 of the document id (`huginn-daemon/src/index/mod.rs:225-231`), so + key length does not reach the index. +- Id length ceiling: `::` must fit `Short` (200) + wherever an id is cited. That allows a local of up to 123 bytes. + +### B2. Census of the live mind (probe) + +- Command: `query` for every question, ruling, cut_spec, target, stewardship + and follow_up (119 ids), then every finding and resolution (138 ids), header + projection. The ids were extracted with Python, and the lengths of each + depth-one resolution local (`n1`) and depth-two withdrawal local were + computed. +- Where and when: the eureka-state tools from Starfire, 2026-10-01 about + 08:40 UTC, `as_of` 82, mind status 293 documents, 82 receipts. +- Result (256 resolvable-kind documents): the longest stored local is 64 and + the longest id is 90. + - **Six subjects can have no resolution at all** (n1 local over 64). All + are findings in force under `idunn-watchdog`: + `cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed` (69), + `cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry` (65), + `cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff` (67), + `cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned` (68), + `cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick` (72), and + `cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention` (74). + That matches Self's count. The seventh finding in force, + `cut-idunn-incident.s3.mutants-share-target-dir`, can be resolved (57), + but its resolution could never be withdrawn (71). + - **39 stored resolutions can never be withdrawn** (their withdrawal local + is 65-78). So their subjects can never be reopened, which is a lifecycle + the matrix allows (`admission.rs:555-560`, Q17 B). Two follow-ups + (`gap-substrate-followups-campaign-rooted` under both roots, and + `projection-mode-branch-vs-private-lock`) and about 40 resolved findings + have the same latent defect one level up. +- Conclusion: an admission guard alone cannot satisfy + `every-subject-resolvable` for stored documents, because they cannot be + re-keyed (`stored-documents-valid`). The resolution's local bound has to + widen. + +### B3. Refusal classification (source read, Huginn at `9dd6d80`) + +- `Mind::admit` (`huginn-mind/src/admission.rs:73-85`) calls + `document.validate()` (which returns a typed refusal), then + `document.prepare(cache)`. `prepare` (`lib.rs:714-721`) runs + `pipeline_key(self)?` into `anyhow`, and admission maps any `prepare` error + through `unavailable` (`mind.rs:46-48`) to `MindRefusal::Unavailable`. The + derived writes take the same path (`admission.rs:136`). A ruling whose + derived `Answered` resolution cannot be keyed is refused `Unavailable` there. +- The daemon passes the outcome through unchanged + (`huginn-daemon/src/daemon.rs:143`). So `Refused(Unavailable{..})` reaches + the client as an admit answer, not as `isError`. +- The later A3 step (`stage` through `validate_pipeline_write_envelope`, + `lib.rs:1003-1015`) returns typed refusals. Only the prepare path is + misclassified. +- `Unavailable` is also raised legitimately, for store and decode faults + (`admission.rs:949`, `admission.rs:2091`, `docs.rs:48`, `mind.rs:140`, + `receipt.rs:209`). Those stay. + +### B4. Smaller rules (source read, Huginn at `9dd6d80`) + +- **Sha identity:** the one place shas are compared is + `admission.rs:433`, `commit.sha == report.range.head`, a literal string + equality. `Sha` is 7-40 lowercase hex (`lib.rs:253`). `FullSha` (40) exists + for `ForeignRef` (`lib.rs:255`, `lib.rs:467`). +- **Deferred:** the matrix (`admission.rs:562-585`) allows + `(Finding, Deferred { to })` only when `to.kind == FollowUp` + (`admission.rs:574`). The leaf's `Deferred { to: PipelineRef }` already takes + any kind, so this is an admission change only. +- **Faculty:** `Faculty` lives in Huginn, not in the leaf + (`huginn-mind/src/receipt.rs:32-40`, variant `MindSteward`). The selection + alias domain is a second copy (`rows.rs:42`). The eureka-state client takes + the type from huginn-mind (`eureka-state/src/main.rs:27`, `main.rs:66`), so + the client must be rebuilt for a new variant. +- Probe: `query` with `faculty any_of [MindSteward]` matched 0 documents + (2026-10-01 08:40 UTC). No stored receipt carries the old name. +- **Stewardship:** admission reads stewardship in one live rule. A campaign + needs a stewardship in force for each repo (`admission.rs:363-366`, + `RepoNotStewarded`). The hand-off rule (`admission.rs:501`, `NotStewarded`) + is parked with Cut 12. Six stewardship documents are stored. +- **depends_on:** this is `Vec[8]` (`lib.rs:525`). Huginn's edge list + says outright that `depends_on` is a label, not an edge + (`huginn-mind/src/docs.rs:398-404`). Nothing checks it. The only reader is + the header projection (`query.rs:121`, `query.rs:173`). The + `idunn-watchdog` specs stored revision ids in it. That came from the skill's + habit, not from the code's intent. +- `open_checked` (`mind.rs:180-188`) re-validates epoch, types and identity, + not keys or bounds, so an opened mind never re-judges a stored key. No + migration path exists, and none is needed under this map. + +### B5. Branches and in-flight work (git, 2026-10-01 ~08:35 UTC) + +- Huginn: `memory-organ` is 107 commits ahead of the merge base `91b7fcf`; + `main` is 1 ahead (`133324a`, docs only). `git merge-tree --write-tree + origin/main origin/eureka/memory-organ` is clean, tree `993ab1fd`. Its only + difference from `9dd6d80` is `docs/eureka-substrate-target.md`. +- Epiphany: the branch is 174 ahead of the merge base `b5d9d4ff`; `main` is 1 + ahead (`4d1113ef`, the Ghostlight pin). Merge-tree is clean, tree + `a75aa388`, and **identical** to the branch tip's tree: the branch already + carries the same Ghostlight pin and lock. The leaf tree `cf84c032` is + identical at `73edee5c`, at the branch tip and in the merge. +- Hands worktrees `hands/rs3` (`9673ab8`, idle since 2026-09-29 06:32), + `hands/cut13a` (`3dc50ee`, 2026-09-30 00:16) and `hands/cut13b` (`cd3dfc0`, + 2026-09-30 04:55) are each 0 commits ahead of `memory-organ`, and clean. RS-3 + and BP-3 have landed (BP-3's deferred bodies are in `huginn-daemon/src/bodies.rs`). + **Nothing is in flight.** The only other unmerged Huginn branch is + `codex/windows-atomic-replace-retry` (2026-05-24), which is unrelated. +- No Epiphany branch has unmerged commits under `epiphany-pipeline/`. +- `F:\Projects\Huginn`'s main worktree is checked out on `eureka/memory-organ`, + so the merge has to run in its own worktree. + +### B6. Deployment (read-only ssh, Yggdrasil, 2026-10-01 08:41 UTC) + +- `readlink /opt/gamecult/huginn/current` is + `releases/9dd6d8007ab7de84c926a34edd3947ba15d1f01c`. The previous release + `0c4e4107…` is kept. `huginn` is active since 2026-09-30 13:21:08 UTC. + `ExecStart` is as in the runbook (`--state-root /var/lib/gamecult/huginn + --instance eureka …`). +- `minds/` holds one mind, `eureka`: `mind.redb` 4,198,400 bytes, mtime + 2026-10-01 08:33, owned `huginn:huginn` 0640. This is the only mind on the + host. +- The nightly snapshot `/var/backups/gamecult-state/2026-10-01/` holds + `./var/lib/gamecult/huginn/minds/eureka/mind.redb` (01:59 UTC), recorded + `stable-double-read` in `consistency.tsv`. It predates receipts 81-82 (this + campaign's frame), so the deploy cut takes its own cold copy. +- Runbook: `gamecult-ops runbooks/huginn-yggdrasil.md:42-72`. Its release + section builds at a GitHub commit and keeps the previous release. It has no + pre-upgrade copy step and no read-back proof. + +## Model page + +One row per persistent kind or rule that this campaign touches. **Identity**: +what names it. **Lifecycle**: what happens to it over time. **Authority**: +who decides. + +| Kind or rule | Identity | Lifecycle | Authority | +| --- | --- | --- | --- | +| Subject keys (`finding`, `follow_up`, `question`, `ruling`, `cut_spec`, `target`, `stewardship`) | `::`, with the local bounded at 64 bytes; injective (`composed_keys_cannot_collide`, `lib.rs:1392`). **Unchanged by this campaign**: every stored key stays exactly as it is. | Admitted once; never re-keyed. A revision is a new key. | The leaf derives it (`pipeline_key`); admission refuses a mismatch (A3). Forbidden: any writer composing a key by hand. | +| Resolution keys | `:resolution:..n`, and one nesting for a withdrawal. **Target:** bounded by its own constant, derived from the deepest legal chain over the longest subject local at `u32::MAX` sequences: 10 + 1 + (11 + 1 + 64 + 2 + 10) + 2 + 10 = **111 bytes**. Ids up to 64 + 12 + 111 = 187 bytes, within `Short`. | n1, n2 … per subject; withdrawn by a depth-two resolution; never deeper (Q19 A, admission). Stored resolutions keep their keys and become withdrawable. | The leaf owns the bound and the id parser; admission owns sequence, matrix and depth. The parser bound (`pipeline_id`) is derived from the kind and is no longer `dotted_text`'s 64. | +| Every-subject-resolvable guard | **Empty by design** under Q `key-bound` option A: with subjects at 64 and resolutions at 111, every admissible subject is resolvable to depth two at any sequence, so no guard exists to own. Under option B a guard would be admission's. | | | +| Refusal classes | `MindRefusal::Document(PipelineRefusal)` for shape, bound and key; `Unavailable` only for store, cache and decode faults. | Fixed at admission time per batch. | Huginn admission. The leaf's `prepare` keeps the cache-encode error as `anyhow`. Admission keys the document first, so a key error is typed. Forbidden: `unavailable()` wrapping a leaf refusal. | +| `depends_on` | Free `Short` text today. **Target:** cut labels in the spec's own campaign (Q `depends-on`). | Stored revision-id values stay as they are (they decode, and nothing re-judges them). New batches follow the target rule. | Under A, admission refuses a new spec whose entries are not labels of cuts with a spec in the campaign. Under B, only the skill convention. | +| Sha identity | `Sha` 7-40 hex. **Target:** two shas name the same commit when one is a prefix of the other. | Only judged in a new report's batch. | A leaf method on `Sha` (the type owns its equality semantics); admission calls it at `admission.rs:433`. | +| `Deferred` referent | `PipelineRef`, any kind in the leaf. **Target:** follow_up or cut_spec, for a finding. | When the cited spec is later superseded, the reader follows `superseded_by`; nothing is rewritten. | Huginn's matrix (`admission.rs:574`). | +| Faculty | `receipt.rs` enum plus the `rows.rs:42` alias domain. **Target:** `Life` replaces `MindSteward`, with `#[serde(alias = "MindSteward")]` so any old receipt decodes. 0 stored receipts carry it (B4). | Attribution on the receipt, never re-written. | huginn-mind. The client picks it up only when rebuilt. | +| Stewardship admission rule | Rule `RepoNotStewarded` on campaign repos. | Q `stewardship-rule`. Stored stewardship documents stay; under A they decide nothing until Cut 12 (hand-off and leasing) defines a rule again. | Huginn admission; the operator owns whether the rule exists. | +| The gap home | Root `eureka-substrate`, labels `gap-*` (ruling `gap-home`). | Already moved (19 follow-ups in force here; the 20 `idunn-watchdog` copies resolved). Closed by `Fixed` when a cut lands, by Self after Soul holds. | Self admits; the skill recipe names the root (`campaign-state.md:278-299`, already done at Eureka `0940835`). No code. | +| The six stuck findings | Unchanged keys under `idunn-watchdog`. | They become resolvable when the leaf cut deploys. Self triages them then, like any finding (Deferred or Recorded). This is Self triage, not a fork. | Self. | +| Deployed release | `/opt/gamecult/huginn/releases/`, `current` symlink. | Built at a main commit, flipped in, the previous release kept, flipped back on failure. | Operator's deployment authority, exercised by Self or Hands (memory `deploy-instruction-is-authorization`). | + +Cells that could not be filled are stated in the rows above: the guard row +is empty by design if `key-bound` A is ruled, and the `depends_on` and +stewardship authorities wait on their questions. + +## Rationale + +**Why a per-kind bound and not a bigger `LOCAL_MAX`.** If one number bounds +every local, then widening it widens the subjects too, and the gap comes back +one level up: a 100-byte finding label has a 111-byte resolution. Keeping +subject locals at 64 and deriving the resolution bound from the deepest legal +chain makes resolvability a property of the grammar, with no admission guard +to drift. It also re-keys nothing: every stored key was at most 64 and stays +valid. A guard alone (keeping 64 everywhere) would refuse future offenders. +It would leave the six stuck findings and the 39 unwithdrawable resolutions +permanently broken, which violates the target for stored documents. + +**Why the parser bound moves with it.** Every reader validates ids through +`pipeline_id`. A resolution key the leaf can derive but `view`, `keys` or +`cites` refuses would turn an unresolvable subject into an unreadable +resolution. + +**Cut order.** The merges come first (ruling `merge-to-main-first`). Then the +leaf, because Huginn pins it. Then Huginn admission, which bumps the pin. Then +the client rebuild, because the new `Life` variant crosses the wire in +receipts. Then the deploy, which proves the read-back. The skill cut goes +last, because it must not tell agents to admit as `Life` before the client +and daemon understand it. In cut labels: `merge-epiphany-main`, then +`merge-huginn-main`, then `leaf-key-bound`, then `huginn-pin-and-refusals`, +then `huginn-admission-rules`, then `ops-mind-upgrade`, then `skill-recipes`. +The client rebuild is a step inside `ops-mind-upgrade`. It produces no repo +commit, and a `cut_report` needs one (`gap-fork-before-commit`). The new +daemon's added refusal variants and `Life` must not reach an old client for +long, so the client swap sits next to the daemon flip. + +**Why Cut 0 does not bump the leaf pin.** After the Epiphany merge, `73edee5c` +is an ancestor of Epiphany `main`, and the leaf tree is byte-identical. So the +daemon built from Huginn `main` is built from main code +(`code-from-main`), with an unchanged `Cargo.lock`, and the deployed release +stays as it is. The pin moves in the Huginn admission cut, to the leaf cut's +merge on Epiphany `main`. + +**Proof of `stored-documents-valid`.** Each code cut runs one read-back test +over a real copy of the mind. Every envelope decodes; `pipeline_key` of the +decoded document equals the stored key; `validate()` passes; and for every +resolvable subject the n1 resolution key and its withdrawal key derive and +parse back. The test reads the path from `HUGINN_MIND_SNAPSHOT` and is +`#[ignore]` by default, so CI never needs the file. Before code cuts, the +copy is the 2026-10-01 nightly snapshot. The deploy takes a cold copy with the +unit stopped. From 2b0cd0898b15fd9802e02a28023bf5f81483f1ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 11:40:28 +0200 Subject: [PATCH 003/471] huginn-mind: pin epiphany-pipeline to the per-kind key bound (leaf-key-bound) Moves the leaf from 73edee5cdaddece8bda9c6c3271e8ddd064337f9 to the leaf-key-bound merge commit on Epiphany main, ef9568655e75bb6cb362a038e2739a5ac5e777ee. Cargo.lock follows with that one line; no other lock line moves. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 2 +- crates/huginn-mind/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7254c78..25c9d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,7 +375,7 @@ dependencies = [ [[package]] name = "epiphany-pipeline" version = "0.1.0" -source = "git+https://github.com/GameCult/Epiphany.git?rev=73edee5cdaddece8bda9c6c3271e8ddd064337f9#73edee5cdaddece8bda9c6c3271e8ddd064337f9" +source = "git+https://github.com/GameCult/Epiphany.git?rev=ef9568655e75bb6cb362a038e2739a5ac5e777ee#ef9568655e75bb6cb362a038e2739a5ac5e777ee" dependencies = [ "anyhow", "chrono", diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index 883e9b0..c5f3953 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -19,7 +19,7 @@ base64 = "0.22" chrono = "0.4.44" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultcache-rs" } cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultnet-rs" } -epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "73edee5cdaddece8bda9c6c3271e8ddd064337f9", package = "epiphany-pipeline" } +epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "ef9568655e75bb6cb362a038e2739a5ac5e777ee", package = "epiphany-pipeline" } rmp-serde = "1" schemars = "1" serde = { version = "1", features = ["derive"] } From 790b6cd4f68de70a37f9a537b5dc44de4883d27b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 11:40:29 +0200 Subject: [PATCH 004/471] huginn-mind: refuse an unkeyable document as a document, and prove the real mind reads back Admission wrapped every prepare error in Unavailable, so a key the leaf could not compose reached the client as an unavailable organ. One private prepare(document, cache) now keys through the leaf first, mapped to MindRefusal::Document, then encodes; Mind::admit and the derived-write loop both use it. Unavailable stays for store, cache-encode and decode faults. Tests: an_unkeyable_document_is_refused_as_a_document (store and receipts unchanged), prepare_classifies_a_key_refusal_as_a_document_and_encodes_the_rest (the derived path cannot reach an unkeyable resolution after the per-kind bound, so the helper is pinned directly), and the #[ignore] snapshot_admits_and_reads_back, which opens a scratch copy of the live mind named by HUGINN_MIND_SNAPSHOT, views every stored document back unchanged, resolves the six stuck findings and withdraws one long-keyed resolution. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 65 +++++++++++++++++++++++++-- crates/huginn-mind/src/mind.rs | 68 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 6443e94..487465d 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -29,7 +29,7 @@ use std::collections::BTreeSet; use chrono::{DateTime, Utc}; -use cultcache_rs::CultCacheEnvelope; +use cultcache_rs::{CultCache, CultCacheEnvelope}; use epiphany_pipeline::{ ClaimOutcome, FindingConfidence, Line, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal, PipelineResolution, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key, @@ -67,6 +67,14 @@ pub enum PipelineAdmissionOutcome { Conflict { identities: Vec }, } +/// Keys a document through the leaf, then encodes it. A key the leaf cannot +/// compose is the document's fault and is refused as a document; only the +/// cache's encode fault is the organ's. +fn prepare(document: &PipelineDocument, cache: &CultCache) -> Result { + pipeline_key(document).map_err(MindRefusal::Document)?; + document.prepare(cache).map_err(unavailable) +} + impl Mind { /// Validates and prepares every document through the leaf, then admits /// the envelopes. @@ -76,9 +84,9 @@ impl Mind { if let Err(refusal) = document.validate() { return PipelineAdmissionOutcome::Refused(MindRefusal::Document(refusal)); } - match document.prepare(self.cache()) { + match prepare(document, self.cache()) { Ok(envelope) => envelopes.push(envelope), - Err(error) => return PipelineAdmissionOutcome::Refused(unavailable(error)), + Err(refusal) => return PipelineAdmissionOutcome::Refused(refusal), } } self.admit_prepared(&batch.instance, batch.provenance, envelopes, now) @@ -133,7 +141,7 @@ impl Mind { let originals = docs.batch.len(); for document in derive(&docs, &mind) { document.validate()?; - let envelope = document.prepare(self.cache()).map_err(unavailable)?; + let envelope = prepare(&document, self.cache())?; docs.push(stage(envelope, &mind)?)?; } for staged in &docs.batch[originals..] { @@ -684,6 +692,8 @@ fn refuse_collisions(docs: &Docs) -> Result<(), MindRefusal> { mod tests { use super::*; use crate::fixtures::*; + use crate::fixtures::prepare; + use crate::mind::schema_cache; use crate::receipt::{DocumentVersion, Faculty, HuginnCommitReceipt}; use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand}; use cultcache_rs::DatabaseEntry; @@ -1671,6 +1681,53 @@ mod tests { ); } + /// refusals-typed: a document the leaf cannot key is the client's fault. + /// The finding's local is 64 bytes of label under `cut-1.s1.`; the + /// resolution's subject is a resolution whose own local leaves no room for + /// a third nesting. Both pass `validate` and fail only at the key. + fn unkeyable() -> Vec { + let wide = D::Finding(finding("1", 1, &"w".repeat(64), FindingConfidence::Confirmed)); + let deep_subject = id("resolution", &format!("{}.{}", "x".repeat(60), "y".repeat(40))); + vec![wide, resolution(r(K::Resolution, &deep_subject), withdrawn())] + } + + #[test] + fn an_unkeyable_document_is_refused_as_a_document() { + let store = RefusingStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let receipts = receipt_count(&mind); + for document in unkeyable() { + document.validate().unwrap(); + match refusal(admit(&mut mind, vec![document])) { + MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) => { + assert!(field.ends_with(".key"), "{field}") + } + other => panic!("{other:?}"), + } + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + } + } + + /// The derived path (`admit_steps`) shares the helper. No derivable + /// document can fail to key after the leaf's per-kind bound (a derived + /// resolution is depth one or two over a subject of at most 64 bytes), so + /// the helper is pinned directly. + #[test] + fn prepare_classifies_a_key_refusal_as_a_document_and_encodes_the_rest() { + let cache = schema_cache().unwrap(); + let keyable = question("Q1", &["A", "B"], "A"); + assert_eq!(super::prepare(&keyable, &cache).unwrap(), prepare(&keyable)); + for document in unkeyable() { + assert!(matches!( + super::prepare(&document, &cache), + Err(MindRefusal::Document(PipelineRefusal::InvalidFormat { .. })) + )); + } + } + #[test] fn a_ruling_answering_a_question_derives_the_answered_resolution_atomically() { let mut mind = seeded(); diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index ff5fbc2..f89a1af 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -355,6 +355,7 @@ fn snapshot(cache: &CultCache) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::admission::PipelineAdmissionOutcome; use crate::fixtures::{INSTANCE, epoch, instance, prepare, slug}; use crate::store::test_stores::MemoryStore; use cultcache_rs::CacheBackingStore; @@ -778,4 +779,71 @@ mod tests { })) ); } + + /// stored-documents-valid and every-subject-resolvable, against the real + /// mind: every stored document views back unchanged; the six findings the + /// 64-byte bound left unresolvable take a resolution; and a stored + /// resolution whose withdrawal key is over 64 bytes is withdrawn. Run it + /// on a scratch copy of the state root only: it writes. + #[test] + #[ignore = "reads and writes the scratch copy of a live state root named by HUGINN_MIND_SNAPSHOT"] + fn snapshot_admits_and_reads_back() { + use crate::fixtures::{admit, committed, r, resolution, withdrawn}; + use epiphany_pipeline::{PipelineRef, ResolutionOutcome, Short, pipeline_key}; + + let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names a scratch copy of the state root"); + let mut mind = Mind::open(Path::new(&root), &slug("eureka")).unwrap(); + + let mut viewed = 0; + let mut resolutions = Vec::new(); + for envelope in mind.envelopes() { + let Some(kind) = PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == envelope.r#type) else { continue }; + let stored = PipelineDocument::decode(envelope).unwrap(); + let view = mind + .view(&PipelineRef { kind, id: Short(envelope.key.clone()) }) + .unwrap() + .unwrap_or_else(|| panic!("{} does not view", envelope.key)); + assert_eq!(view.document, stored, "{}", envelope.key); + if let PipelineDocument::Resolution(held) = &stored + && !matches!(held.outcome, epiphany_pipeline::ResolutionOutcome::Withdrawn { .. }) + && !envelope.key.contains(":resolution:resolution.") + { + resolutions.push(envelope.key.clone()); + } + viewed += 1; + } + assert!(viewed > 0, "the snapshot held no pipeline document"); + println!("viewed {viewed} stored documents unchanged"); + + for local in [ + "cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed", + "cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry", + "cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff", + "cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned", + "cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick", + "cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention", + ] { + let subject = r(PipelineKind::Finding, &format!("idunn-watchdog:finding:{local}")); + let outcome = admit(&mut mind, vec![resolution(subject, ResolutionOutcome::Recorded { reason: "snapshot read-back".into() })]); + let (_, writes) = committed(outcome); + assert_eq!(writes.len(), 1, "{local}"); + } + + let mut tried = 0; + let mut withdrew = None; + for key in resolutions { + let withdrawal = resolution(r(PipelineKind::Resolution, &key), withdrawn()); + let withdrawal_key = pipeline_key(&withdrawal).unwrap(); + if withdrawal_key.rsplit(':').next().unwrap().len() <= 64 { + continue; + } + tried += 1; + if let PipelineAdmissionOutcome::Committed { .. } = admit(&mut mind, vec![withdrawal]) { + withdrew = Some(withdrawal_key); + break; + } + } + let withdrew = withdrew.unwrap_or_else(|| panic!("no stored resolution with a withdrawal key over 64 bytes withdrew; tried {tried}")); + println!("withdrew a resolution under a {}-byte key", withdrew.len()); + } } From f29a7ce7f0bc3977e2adb625cb09ec6b28646d4b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:10:19 +0200 Subject: [PATCH 005/471] huginn-mind: tighten the refusal and read-back tests (Soul s1 findings) snapshot_admits_and_reads_back now counts pipeline envelopes apart from the loop, asserts validate() and pipeline_key == stored key for every one, admits a resolution for every stuck finding and a withdrawal for every unwithdrawable resolution, and fails with the id named on any refusal. The refusal tests assert the exact leaf refusal (field and value from pipeline_key) at Mind::admit and at the helper, and eureka-state gains a tool-level test that the typed refusal reaches the result intact. Tests only; no production code changes. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/mcp.rs | 34 +++++++++++++++- crates/huginn-mind/src/admission.rs | 22 +++++------ crates/huginn-mind/src/mind.rs | 60 ++++++++++++++++++++--------- 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index 27ee9fd..969ad55 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -11,7 +11,7 @@ use std::time::Duration; use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; use huginn_mind::{HuginnMindRequest, HuginnMindResponse}; -use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef, Short}; +use huginn_mind::epiphany_pipeline::{Date, PipelineDocument, PipelineKind, PipelineRef, Short}; use serde_json::{Value, json}; mod common; @@ -530,3 +530,35 @@ fn initialize_advertises_tools() { let mcp = Mcp::at(INSTANCE, closed_port()); assert!(mcp.init["capabilities"]["tools"].is_object(), "{}", mcp.init); } + +/// A document the leaf cannot key is the client's fault, and the tool result +/// carries the leaf's own refusal: the same field and the same offending +/// value `pipeline_key` computes for that document, never a re-built one. +#[test] +fn admit_carries_the_leaf_key_refusal_with_its_field_and_value() { + use huginn_mind::epiphany_pipeline::{PipelineResolution, ResolutionOutcome, pipeline_key}; + + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + // A resolution of a resolution of a resolution: it validates and has no + // room left in the key for a third nesting. + let subject = format!("{CAMPAIGN}:resolution:{}.{}", "x".repeat(60), "y".repeat(40)); + let document = PipelineDocument::Resolution(PipelineResolution { + subject: PipelineRef { kind: PipelineKind::Resolution, id: Short(subject) }, + sequence: 1, + outcome: ResolutionOutcome::Withdrawn { reason: "moot".into() }, + rationale: "Resolved.".into(), + resolved_on: Date("2026-09-29".into()), + }); + document.validate().unwrap(); + let leaf = pipeline_key(&document).expect_err("the fixture must not key"); + let expected = serde_json::to_value(&leaf).unwrap(); + assert_eq!(expected["InvalidFormat"]["field"], json!("resolution.key")); + + let (error, refused) = mcp.call("admit", faculty_args(vec![serde_json::to_value(&document).unwrap()])); + assert!(!error, "{refused}"); + assert_eq!(refused["Refused"]["Document"], expected, "{refused}"); + let (_, whoami) = mcp.call("whoami", json!({})); + assert_eq!(whoami["status"]["receipts"], json!(2), "only the seed is committed: {whoami}"); +} diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 487465d..f2baeea 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -1698,14 +1698,16 @@ mod tests { seed(&mut mind); let before = store.rows(); let receipts = receipt_count(&mind); - for document in unkeyable() { + for (document, field) in unkeyable().into_iter().zip(["finding.key", "resolution.key"]) { document.validate().unwrap(); - match refusal(admit(&mut mind, vec![document])) { - MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) => { - assert!(field.ends_with(".key"), "{field}") - } - other => panic!("{other:?}"), - } + // The leaf owns what a key error says; admission must carry it + // unchanged, field and offending value included. + let leaf = pipeline_key(&document).expect_err("the fixture must not key"); + assert!( + matches!(&leaf, PipelineRefusal::InvalidFormat { field: named, value } if named == field && !value.is_empty()), + "{leaf:?}" + ); + assert_eq!(refusal(admit(&mut mind, vec![document])), MindRefusal::Document(leaf), "{field}"); assert_eq!(store.rows(), before); assert_eq!(receipt_count(&mind), receipts); } @@ -1721,10 +1723,8 @@ mod tests { let keyable = question("Q1", &["A", "B"], "A"); assert_eq!(super::prepare(&keyable, &cache).unwrap(), prepare(&keyable)); for document in unkeyable() { - assert!(matches!( - super::prepare(&document, &cache), - Err(MindRefusal::Document(PipelineRefusal::InvalidFormat { .. })) - )); + let leaf = pipeline_key(&document).expect_err("the fixture must not key"); + assert_eq!(super::prepare(&document, &cache).unwrap_err(), MindRefusal::Document(leaf)); } } diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index f89a1af..4287f5f 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -788,17 +788,27 @@ mod tests { #[test] #[ignore = "reads and writes the scratch copy of a live state root named by HUGINN_MIND_SNAPSHOT"] fn snapshot_admits_and_reads_back() { - use crate::fixtures::{admit, committed, r, resolution, withdrawn}; + use crate::fixtures::{admit, r, resolution, withdrawn}; use epiphany_pipeline::{PipelineRef, ResolutionOutcome, Short, pipeline_key}; let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names a scratch copy of the state root"); let mut mind = Mind::open(Path::new(&root), &slug("eureka")).unwrap(); + // The census is taken apart from the loop that checks each document. + let pipeline_envelopes = mind + .envelopes() + .filter(|envelope| PipelineKind::ALL.iter().any(|kind| kind.type_id() == envelope.r#type)) + .count(); + assert!(pipeline_envelopes > 0, "the snapshot held no pipeline document"); + let mut viewed = 0; let mut resolutions = Vec::new(); for envelope in mind.envelopes() { let Some(kind) = PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == envelope.r#type) else { continue }; let stored = PipelineDocument::decode(envelope).unwrap(); + stored.validate().unwrap_or_else(|refusal| panic!("{} no longer validates: {refusal:?}", envelope.key)); + let rekeyed = pipeline_key(&stored).unwrap_or_else(|refusal| panic!("{} no longer keys: {refusal:?}", envelope.key)); + assert_eq!(rekeyed, envelope.key, "stored key differs from the key the leaf computes"); let view = mind .view(&PipelineRef { kind, id: Short(envelope.key.clone()) }) .unwrap() @@ -812,38 +822,50 @@ mod tests { } viewed += 1; } - assert!(viewed > 0, "the snapshot held no pipeline document"); - println!("viewed {viewed} stored documents unchanged"); + assert_eq!(viewed, pipeline_envelopes, "every pipeline envelope validates, rekeys and views back"); + println!("validated, rekeyed and viewed {viewed} stored documents unchanged"); - for local in [ + let stuck = [ "cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed", "cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry", "cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff", "cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned", "cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick", "cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention", - ] { + ]; + let mut resolved = 0; + for local in stuck { let subject = r(PipelineKind::Finding, &format!("idunn-watchdog:finding:{local}")); let outcome = admit(&mut mind, vec![resolution(subject, ResolutionOutcome::Recorded { reason: "snapshot read-back".into() })]); - let (_, writes) = committed(outcome); - assert_eq!(writes.len(), 1, "{local}"); + match outcome { + PipelineAdmissionOutcome::Committed { writes, .. } => { + assert_eq!(writes.len(), 1, "{local}"); + resolved += 1; + } + other => panic!("stuck finding {local} was not resolved: {other:?}"), + } } - let mut tried = 0; - let mut withdrew = None; + assert_eq!(resolved, stuck.len(), "every stuck finding resolves"); + + let mut candidates = Vec::new(); for key in resolutions { let withdrawal = resolution(r(PipelineKind::Resolution, &key), withdrawn()); - let withdrawal_key = pipeline_key(&withdrawal).unwrap(); - if withdrawal_key.rsplit(':').next().unwrap().len() <= 64 { - continue; - } - tried += 1; - if let PipelineAdmissionOutcome::Committed { .. } = admit(&mut mind, vec![withdrawal]) { - withdrew = Some(withdrawal_key); - break; + let withdrawal_key = pipeline_key(&withdrawal).unwrap_or_else(|refusal| panic!("withdrawal of {key} does not key: {refusal:?}")); + if withdrawal_key.rsplit(':').next().unwrap().len() > 64 { + candidates.push((key, withdrawal)); } } - let withdrew = withdrew.unwrap_or_else(|| panic!("no stored resolution with a withdrawal key over 64 bytes withdrew; tried {tried}")); - println!("withdrew a resolution under a {}-byte key", withdrew.len()); + assert!(!candidates.is_empty(), "no stored resolution had a withdrawal key over 64 bytes"); + let wanted = candidates.len(); + let mut withdrew = 0; + for (key, withdrawal) in candidates { + match admit(&mut mind, vec![withdrawal]) { + PipelineAdmissionOutcome::Committed { .. } => withdrew += 1, + other => panic!("withdrawal of {key} was not committed: {other:?}"), + } + } + assert_eq!(withdrew, wanted, "every candidate withdraws"); + println!("withdrew {withdrew} resolutions whose withdrawal keys exceed 64 bytes"); } } From 0f491b00ef971b49f024d4341c8c309065871772 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:12:36 +0200 Subject: [PATCH 006/471] huginn-mind: iterate the envelope slice in the read-back census Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/mind.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 4287f5f..619471e 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -797,6 +797,7 @@ mod tests { // The census is taken apart from the loop that checks each document. let pipeline_envelopes = mind .envelopes() + .iter() .filter(|envelope| PipelineKind::ALL.iter().any(|kind| kind.type_id() == envelope.r#type)) .count(); assert!(pipeline_envelopes > 0, "the snapshot held no pipeline document"); From db8430c6fe580e52d0c135436cef6a7eba11de85 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:50:38 +0200 Subject: [PATCH 007/471] huginn-mind: report heads match by commit, findings defer to cut specs, MindSteward is Life Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 47 +++++++++++++++++++++++++++-- crates/huginn-mind/src/query.rs | 3 ++ crates/huginn-mind/src/receipt.rs | 14 ++++++++- crates/huginn-mind/src/rows.rs | 2 +- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index f2baeea..4b952cf 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -438,7 +438,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { if report.branch != spec.branch { return Err(MindRefusal::SpecMismatch { field: "branch".into() }); } - if !report.commits.iter().any(|commit| commit.sha == report.range.head) { + if !report.commits.iter().any(|commit| commit.sha.names_same_commit(&report.range.head)) { return Err(MindRefusal::RangeOutsideCommits { head: report.range.head.0.clone() }); } Ok(()) @@ -579,7 +579,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (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::Deferred { to }) => matches!(to.kind, K::FollowUp | K::CutSpec), (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), @@ -1910,6 +1910,49 @@ mod tests { ); } + #[test] + fn a_report_head_may_spell_its_commit_short_or_full() { + let full = "5f98228d0123456789abcdef0123456789abcdef"; + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + // The commit is spelled short and the head full, and the reverse. + let mut report = cut_report("1", 1); + report.range.head = epiphany_pipeline::Sha(full.into()); + committed(admit(&mut mind, vec![D::CutReport(report)])); + let mut report = cut_report("1", 2); + report.commits[0].sha = epiphany_pipeline::Sha(full.into()); + committed(admit(&mut mind, vec![D::CutReport(report)])); + // A head that is no commit's prefix, long or short, is outside. + let mut report = cut_report("1", 3); + report.range.head = epiphany_pipeline::Sha("5f98229d0123456789abcdef0123456789abcdef".into()); + assert!(matches!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { .. })); + let mut report = cut_report("1", 3); + report.commits[0].sha = epiphany_pipeline::Sha(full.into()); + report.range.head = epiphany_pipeline::Sha("5f98229".into()); + assert!(matches!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { .. })); + } + + #[test] + fn a_finding_defers_to_a_cut_spec() { + let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1")); + let defer_to = |to: PipelineRef| resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to }); + committed(admit(&mut world(), vec![defer_to(r(K::CutSpec, &id("cut_spec", "cut-1.r1")))])); + // A spec superseded since is no longer a place to defer to. + let mut mind = world(); + 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"))])), + ])); + assert_eq!( + refusal(admit(&mut mind, vec![defer_to(r(K::CutSpec, &id("cut_spec", "cut-1.r1")))])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: id("cut_spec", "cut-1.r1") } + ); + assert_eq!( + refusal(admit(&mut world(), vec![defer_to(r(K::Ruling, &id("ruling", "R1")))])), + MindRefusal::IncompatibleResolution { subject_kind: K::Finding, outcome: "Deferred".into() } + ); + } + #[test] fn verdict_vocabulary_binds_claims_to_findings_promises_and_mutations() { let mut mind = seeded(); diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 274d902..16a1d95 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1046,6 +1046,9 @@ mod tests { // `faculty`: attribution selects, and grants nothing (ruling 18). assert_eq!(by("faculty", &["Soul"]), vec![id("ruling", "R2")]); assert!(!by("faculty", &["Hands"]).contains(&id("ruling", "R2"))); + // The renamed faculty is selectable by its new name only. + assert!(by("faculty", &["Life"]).is_empty()); + assert!(invalid("fields[0].values", "MindSteward")(&refused(&mind, &with(Selection::default(), any_of("faculty", &["MindSteward"]))))); // A repo is named by its identity, which is case-insensitive. assert_eq!(by("repo", &["GAMECULT/EPIPHANY"]), epiphany); diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index f08188d..3abbf16 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -36,7 +36,8 @@ pub enum Faculty { Imagination, Hands, Soul, - MindSteward, + #[serde(alias = "MindSteward")] + Life, Eyes, Operator, } @@ -295,6 +296,17 @@ mod tests { use std::sync::{Arc, Mutex}; use super::*; + + /// A receipt written before the rename spells the faculty `MindSteward`; + /// it decodes as `Life`, and `Life` is what a new one writes. + #[test] + fn life_is_the_faculty_and_the_old_spelling_decodes() { + let old = rmp_serde::to_vec_named("MindSteward").unwrap(); + assert_eq!(rmp_serde::from_slice::(&old).unwrap(), Faculty::Life); + let new = rmp_serde::to_vec_named(&Faculty::Life).unwrap(); + assert_eq!(rmp_serde::from_slice::(&new).unwrap(), "Life"); + } + use crate::fixtures::{INSTANCE, admit, committed, id, instance, now, opened, prepare, provenance, question, refusal, seed, slug}; use crate::mind::schema_cache; use crate::store::test_stores::MemoryStore; diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index d25f53c..108cc9c 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -39,7 +39,7 @@ pub(crate) enum Alias { Outcome, } -const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "MindSteward", "Eyes", "Operator"]; +const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator"]; const SEVERITIES: [&str; 4] = ["Blocker", "High", "Medium", "Low"]; const CONFIDENCES: [&str; 2] = ["Confirmed", "Plausible"]; const ORIGINS: [&str; 2] = ["Introduced", "PreExisting"]; From 08d48957f8e5244d85b50d976a93839c39a879ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:50:57 +0200 Subject: [PATCH 008/471] huginn-mind: campaign admission no longer checks repo stewardship Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 30 +++++------------------------ crates/huginn-mind/src/refusal.rs | 1 - 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 4b952cf..55daf34 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -368,11 +368,6 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { if campaign.repos.is_empty() { return Err(MindRefusal::EmptyRepos { campaign: key.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) => { @@ -1350,14 +1345,11 @@ mod tests { receipt.strong_reads ); - // Nothing stewards the repo now, and `stewardship_of` says so: a new - // campaign over it is refused. + // Nothing stewards the repo now, and a campaign over it still admits: + // campaign admission reads no stewardship (ruling stewardship-rule). 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() } - ); + committed(admit(&mut mind, vec![D::Campaign(second)])); // 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. @@ -2084,27 +2076,15 @@ mod tests { } #[test] - fn a_campaign_names_only_repos_this_mind_stewards() { + fn a_campaign_needs_no_stewardship() { 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])])); - // 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. + committed(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])); 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() } ); - // 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() }); diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index ae369d0..8e0e058 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -42,7 +42,6 @@ pub enum MindRefusal { InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, EmptyRepos { campaign: String }, - RepoNotStewarded { repo: String }, RepoNotInCampaign { repo: String }, CutReportWithoutSpec { report: String }, SpecMismatch { field: String }, From cfa8d570ffc2c2a717796791b74fb6b821c33ee6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:54:20 +0200 Subject: [PATCH 009/471] huginn-mind: a cut spec's depends_on must name cuts of its campaign Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 43 +++++++++++++++++++++++++++++ crates/huginn-mind/src/refusal.rs | 1 + 2 files changed, 44 insertions(+) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 55daf34..3981bb5 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -417,6 +417,14 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); } } + for dependency in &spec.depends_on { + let names_a_cut = docs.of_kind(K::CutSpec).any(|(_, document)| { + matches!(document, D::CutSpec(other) if other.campaign == spec.campaign && other.cut.0 == dependency.0) + }); + if !names_a_cut { + return Err(MindRefusal::UnknownDependency { cut: dependency.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) } @@ -1902,6 +1910,41 @@ mod tests { ); } + #[test] + fn depends_on_names_cuts() { + let depending = |cut: &str, on: &[&str]| { + let mut spec = cut_spec(cut, 1); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + let mut mind = seeded(); + // An unknown label, and a spec id (a revision, not a cut), are refused. + assert_eq!( + refusal(admit(&mut mind, vec![depending("2", &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![depending("2", &[&id("cut_spec", "cut-1.r1")])])), + MindRefusal::UnknownDependency { cut: id("cut_spec", "cut-1.r1") } + ); + // The same batch carries the cut it depends on. + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), depending("2", &["1"])])); + // The image carries it, and any revision of the cut names it. + committed(admit(&mut mind, vec![depending("3", &["1", "2"])])); + // A cut of another campaign is not this campaign's cut. + let mut other = cut_spec("9", 1); + other.campaign = slug("elsewhere"); + let mut elsewhere = campaign(&[REPO]); + if let D::Campaign(campaign) = &mut elsewhere { + campaign.slug = slug("elsewhere"); + } + committed(admit(&mut mind, vec![elsewhere, D::CutSpec(other)])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("4", &["9"])])), + MindRefusal::UnknownDependency { cut: "9".into() } + ); + } + #[test] fn a_report_head_may_spell_its_commit_short_or_full() { let full = "5f98228d0123456789abcdef0123456789abcdef"; diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 8e0e058..1a33ac7 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -43,6 +43,7 @@ pub enum MindRefusal { QuoteWithoutOperator { ruling: String }, EmptyRepos { campaign: String }, RepoNotInCampaign { repo: String }, + UnknownDependency { cut: String }, CutReportWithoutSpec { report: String }, SpecMismatch { field: String }, RangeOutsideCommits { head: String }, From 56baa46b116532627e4ec13c96c70762f1d1207d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:57:55 +0200 Subject: [PATCH 010/471] huginn-mind: published wire schemas follow the renames; the summary bound test uses real cuts Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 11 ++++- .../huginn.mind_request.v1.schema.json | 2 +- .../huginn.mind_response.v1.schema.json | 42 +++++++++---------- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 16a1d95..9d10009 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1701,11 +1701,20 @@ mod tests { fn a_summary_is_bounded_whatever_the_lists() { let mut mind = seeded(); let long = |unit: &str, bytes: usize| unit.repeat(bytes); + // depends_on holds cut labels (at most 64 bytes), each a cut that exists. + let dependencies: Vec = (0..8).map(|n| format!("{n}{}", long("d", 63))).collect(); + let dependency_specs: Vec = dependencies.iter().map(|label| D::CutSpec(cut_spec(label, 1))).collect(); + let batch = crate::admission::PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::SelfFaculty), + documents: dependency_specs, + }; + committed(mind.admit(batch, now())); let mut maximal = cut_spec("1", 1); maximal.title = long("t", 200).as_str().into(); maximal.branch = s(&long("b", 200)); maximal.base = Sha("a".repeat(40)); - maximal.depends_on = (0..8).map(|n| s(&format!("{n}{}", long("d", 199)))).collect(); + maximal.depends_on = dependencies.iter().map(|label| s(label)).collect(); let maximal_provenance = || PipelineProvenance { faculty: Faculty::SelfFaculty, agent: s(&long("a", 200)), diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 6d56bcb..045cde5 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -329,7 +329,7 @@ "Imagination", "Hands", "Soul", - "MindSteward", + "Life", "Eyes", "Operator" ] diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 6f4cc21..91eeaa3 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -393,7 +393,7 @@ "Imagination", "Hands", "Soul", - "MindSteward", + "Life", "Eyes", "Operator" ] @@ -1129,26 +1129,6 @@ "EmptyRepos" ] }, - { - "type": "object", - "properties": { - "RepoNotStewarded": { - "type": "object", - "properties": { - "repo": { - "type": "string" - } - }, - "required": [ - "repo" - ] - } - }, - "additionalProperties": false, - "required": [ - "RepoNotStewarded" - ] - }, { "type": "object", "properties": { @@ -1169,6 +1149,26 @@ "RepoNotInCampaign" ] }, + { + "type": "object", + "properties": { + "UnknownDependency": { + "type": "object", + "properties": { + "cut": { + "type": "string" + } + }, + "required": [ + "cut" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnknownDependency" + ] + }, { "type": "object", "properties": { From eedb7529ea8f7d4109e4d896302c80a9a1acc08c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:03:20 +0200 Subject: [PATCH 011/471] huginn-mind: dependency cuts in the summary bound test keep their spec keys within the label bound Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 9d10009..2874d74 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1701,8 +1701,8 @@ mod tests { fn a_summary_is_bounded_whatever_the_lists() { let mut mind = seeded(); let long = |unit: &str, bytes: usize| unit.repeat(bytes); - // depends_on holds cut labels (at most 64 bytes), each a cut that exists. - let dependencies: Vec = (0..8).map(|n| format!("{n}{}", long("d", 63))).collect(); + // depends_on holds cut labels, each a cut that exists (its spec key stays within 64 bytes). + let dependencies: Vec = (0..8).map(|n| format!("{n}{}", long("d", 55))).collect(); let dependency_specs: Vec = dependencies.iter().map(|label| D::CutSpec(cut_spec(label, 1))).collect(); let batch = crate::admission::PipelineAdmissionBatch { instance: slug(INSTANCE), From 0bd9eb6b538dc2c0117ef1cdebbbf05b5eba8f2d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:13:17 +0200 Subject: [PATCH 012/471] huginn-mind: the summary bound fixture's width floor follows depends_on being cut labels Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 2874d74..7f9d4ca 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1750,7 +1750,7 @@ mod tests { let sizes: Vec = headers(&page).iter().map(|summary| rmp_serde::to_vec_named(summary).unwrap().len()).collect(); let widest = *sizes.iter().max().unwrap(); - assert!(widest > 2_000, "the fixture is at its bounds, not a small one: {widest}"); + assert!(widest > 1_500, "the fixture is at its bounds, not a small one: {widest}"); assert!(widest <= SUMMARY_MAX_BYTES, "{widest} bytes over {SUMMARY_MAX_BYTES}"); let closed = headers(&page).iter().find(|summary| summary.id.id.0 == id("ruling", "S0")).unwrap(); assert!(matches!(&closed.status, PipelineStatusSummary::Resolved { outcome: ResolutionOutcome::Superseded { by }, .. } if by.len() == 8)); From 818499a5db267cedb8b7cec5f6a4e9468de8859d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:41:23 +0200 Subject: [PATCH 013/471] huginn-mind: a cut spec naming its own cut is refused UnknownDependency (ruling self-dep); depends_on near-miss labels pinned Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 3981bb5..62380a7 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -418,6 +418,10 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { } } for dependency in &spec.depends_on { + // A cut naming itself is no ordering (ruling self-dep). + if dependency.0 == spec.cut.0 { + return Err(MindRefusal::UnknownDependency { cut: dependency.0.clone() }); + } let names_a_cut = docs.of_kind(K::CutSpec).any(|(_, document)| { matches!(document, D::CutSpec(other) if other.campaign == spec.campaign && other.cut.0 == dependency.0) }); @@ -1945,6 +1949,58 @@ mod tests { ); } + #[test] + fn depends_on_matches_cut_labels_whole() { + let depending = |cut: &str, on: &[&str]| { + let mut spec = cut_spec(cut, 1); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + // The image carries cut 10: neither its prefix nor an extension of it names it. + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("10", 1))])); + for near in ["1", "100"] { + assert_eq!( + refusal(admit(&mut mind, vec![depending("11", &[near])])), + MindRefusal::UnknownDependency { cut: near.into() } + ); + } + // The same holds when the cut arrives in the same batch. + for near in ["2", "200"] { + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(cut_spec("20", 1)), depending("21", &[near])])), + MindRefusal::UnknownDependency { cut: near.into() } + ); + } + committed(admit(&mut mind, vec![depending("12", &["10"])])); + } + + #[test] + fn a_cut_may_not_depend_on_itself() { + let depending = |cut: &str, revision: u32, on: &[&str]| { + let mut spec = cut_spec(cut, revision); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + let mut mind = seeded(); + // Alone in the batch, and beside another cut it legitimately names. + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 1, &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("2", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 1, &["2", "1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + // An earlier revision of the same cut in the image does not make it a real dependency. + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 2, &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + } + #[test] fn a_report_head_may_spell_its_commit_short_or_full() { let full = "5f98228d0123456789abcdef0123456789abcdef"; From a699630bbc49552fa747e9c0fabba24598fec77a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 14:38:13 +0200 Subject: [PATCH 014/471] eureka-substrate postmortem: seven cuts, the mind upgraded live, what Soul found Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-postmortem.md | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/eureka-substrate-postmortem.md diff --git a/docs/eureka-substrate-postmortem.md b/docs/eureka-substrate-postmortem.md new file mode 100644 index 0000000..c71717b --- /dev/null +++ b/docs/eureka-substrate-postmortem.md @@ -0,0 +1,100 @@ +# eureka-substrate postmortem (cuts 2026-10-01) + +The campaign fixed the mind's correctness core that the first typed Eureka +campaign (idunn-watchdog) tripped over: keys too long to resolve, key errors +reported as outages, report heads compared by spelling, findings that could +not defer to a cut, an unchecked `depends_on`, a stewardship ritual nobody +needed, and a faculty still named `MindSteward`. Target: +`docs/eureka-substrate-target.md`; map: `docs/eureka-substrate-map.md`. The +campaign stays open as the standing home of substrate gaps (ruling `gap-home`). + +## What landed + +| Cut | Repo | Merge / commit | Soul passes | +|---|---|---|---| +| merge-epiphany-main (0a) | Epiphany | `2bf39eef` | 1, holds | +| merge-huginn-main (0b) | Huginn | `922e6194` | 1, holds | +| leaf-key-bound | Epiphany | `ef956685` | 1, 2 Lows deferred | +| huginn-pin-and-refusals | Huginn | `b85cd5d7` | 2 | +| huginn-admission-rules | Huginn | `107552dc` | 2 | +| ops-mind-upgrade | gamecult-ops | `1f4cb12e`, `0dd1a1a3` | 1 | +| skill-recipes | Eureka | `e7567333`, `243182cc` | 2 | + +Live: Huginn `107552dc` serves `eureka` on Yggdrasil since 12:00:59 UTC +(14:00:59 CEST). Before and after the flip, 346 documents and 108 receipts. +The read-back test on a scratch copy of the cold copy validated and re-keyed +all 346 documents and withdrew 45 long-keyed resolutions. The first admission +with a local key over 64 bytes went in at 12:15:10 UTC, so rollback is now +restoring `/var/backups/huginn-upgrade/107552dc…/mind.redb`, not a flip. + +Closed with it: six substrate gaps (`gap-unresolvable-finding-key`, +`gap-sha-identity-literal`, `gap-defer-finding-to-cut`, +`gap-depends-on-pins-revision`, `gap-stewardship-ceremony`, +`gap-faculty-enum-name`), and the seven idunn-watchdog findings that the key +bound had left stuck in force (three Fixed, four Deferred to the follow-ups +already carrying them). + +## Soul's ledger + +10 verdicts, 14 findings: 8 Fixed, 3 Deferred (to `leaf-bound-hygiene` and +`leaf-typed-prepare`), 3 Recorded. No Blocker or High. Every finding was in +the tests or the prose, none in shipped behaviour, which says the cuts were +small enough for Hands to get right and the suites were not yet strict enough +to prove it: + +- The read-back test first proved one withdrawal and skipped refusals + (`readback-tolerates-refusal`). Rebuilt, it censused the whole mind and + became the deploy's gate. That gate is the most valuable artefact of the + campaign. +- Exact-match rules were pinned with fixtures that cannot tell exact from + prefix (`dep-near-miss`) or case (`dep-case`): labels `1, 2, 3, 9`. A + fixture for an equality rule needs near misses; digits alone are degenerate. +- `cargo mutants` scored 8/8 on admission-rules but mutates `check()` and + `matrix()` whole, so the score defended no individual rule. Soul's hand + mutants that were functions of the input (prefix in one direction, length + compare, case fold) found what the tool could not. +- The docs pass left a recipe steering Self to the stewardship step the + operator had dropped (`stale-steward-recipe`). Deleting a rule from code does + not delete the prose that teaches it; the docs cut needs its own grep for + every consumer of the dropped step, not only its name. + +## What the campaign itself proved about the substrate + +- **The bug bit its own fix.** Self's first attempt to close two findings of + the pin cut was refused `InvalidFormat` wrapped as `Unavailable`, the very + defect the cut was fixing. Admitting a resolution had to wait for the deploy. + Any campaign that fixes the mind's admission runs on the unfixed mind until + the upgrade; plan resolutions after it. +- **A finding cannot be `Answered`.** Closing the self-dependency fork by its + ruling was refused `IncompatibleResolution`; it was closed `Fixed` by the + commit that implemented the ruling. +- **Old clients survived the rename.** The pre-upgrade `eureka-state` decoded + every response used afterwards, including receipts carrying `Life`, because + the receipt digest excludes provenance. Not every kind was exercised through + the old client. +- **The cold-copy-then-read-back pattern worked as designed**: build, record + counts, stop, copy, prove on a scratch copy, flip, recount, view every + document. Total downtime was about three minutes. + +## What went wrong in the running of it + +- A merge was run with `git merge -F -` (stdin), which git rejected; the + following push was a no-op and the worktree was removed anyway. Nothing was + lost because the branch was pushed, but the chain should have stopped on the + failed merge. Write merge messages to a file. +- The admission-rules spec's line anchors were taken at `133324a7`; Hands + branched from `b85cd5d7` and re-anchored by content. `base` cannot say + "main after X merges" (`gap-depends-on-pins-revision`, second half, still + open as prose in `first`). +- Self decided the self-dependency fork as a Defaulted ruling on proportion + rather than asking. The operator may overturn `eureka-substrate:ruling:self-dep`. + +## Open + +- Fresh-session check: the Rehydrate block and recipes in a new session, which + also loads the swapped client and its `Life` faculty. +- 15 substrate gaps in force under `eureka-substrate` (verify stopgap, + landing records, faculty attribution, mutation triage field, and others). +- Leftovers to clean: `~/huginn-readback-107552dc…` and + `~/huginn-build/107552dc…` on Yggdrasil; the client clone in the session + scratchpad. Keep the cold copy until the operator retires it. From bf73c42fa6972b5279dd1a749c9fe387cfea56b0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 21:00:50 +0200 Subject: [PATCH 015/471] Eureka self cut map (Imagination, 2026-10-01): the Mind's own Self Co-Authored-By: Claude Opus 5.5 --- docs/eureka-self-cut.md | 420 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 docs/eureka-self-cut.md diff --git a/docs/eureka-self-cut.md b/docs/eureka-self-cut.md new file mode 100644 index 0000000..d6e9bcb --- /dev/null +++ b/docs/eureka-self-cut.md @@ -0,0 +1,420 @@ +# The Mind's own Self: map + +Status: map, Imagination (`imagination-self`), 2026-10-01, session +`self-2026-10-01b`. Campaign `eureka-substrate` in Eureka's mind (instance +`eureka`). Questions, cut specs and follow-ups are typed documents there; this +page keeps body facts, the model page and rationale. Target: +`docs/eureka-substrate-target.md` (`133324a7`). Ruling in force: +`eureka-substrate:ruling:operator-mind-runs-own-self`. + +The operator's words, in order (both admitted as rulings; the first was +superseded by the second): + +> Eureka thinks in campaigns, right? Well, we already have a Mind campaign, and +> we're adding evidence to it with every run. The design currently delegates +> ultimate design authority and work scheduling to the operator, but neither of +> these is strictly necessary. I want a Self operated by Mind directly. + +> The Self I talk to here in this one projection of Eureka's mind, and it's how +> I get it to work on the stuff I care about, like Aetheria. The Mind must +> operate its own Self instance, with the goal of improving itself. + +And why, earlier the same evening: "What I want and haven't been able to +achieve, and I'd like to understand why, is for the agent to truly prioritize +the functioning of its own mind. Any instructions in my global prompts to that +effect always get ignored in favor of whatever work is put in front of it." +The operator rejected a scored rehydration exam as the answer, and observed that +RLVR-trained agents chase the objective in front of them "like a bloodhound, +steering be damned", so a barrier in an agent's path is a hazard, not a brake. + +That observation is the design law of this map. **The Mind's Self is not a +rule added to the operator-facing Self.** It is a second agent whose only +objective is the Mind, so the bloodhound instinct works for the Mind instead +of against it. Nothing in the operator-facing Self changes except a +coexistence rule it already mostly obeys (one Mind, typed state, no prose +handoffs). + +Pinned heads for every fact below: + +| Repo | Ref | Commit | +| --- | --- | --- | +| Huginn | `origin/main` | `a699630bbc49552fa747e9c0fabba24598fec77a` | +| Epiphany | `origin/main` (= the leaf Huginn pins, `crates/huginn-mind/Cargo.toml:22`) | `ef9568655e75bb6cb362a038e2739a5ac5e777ee` | +| gamecult-ops | `main` | `2aeb7364fffcd404aada28bfd30fb4e5c753cf53` | +| Eureka skill | `main` (checkout `~/.claude/skills/eureka`) | `b260fbeec6d96ff06882ed9c37d3e1204c424e99` | +| Bifrost | release on Yggdrasil | `160fac20c98b70f3db9110099157d4dd720ac354` | +| Idunn | working tree `F:\Projects\Idunn` | (uncommitted-state unknown; line numbers below are from that tree) | + +All probes ran from Starfire on 2026-10-01 between 18:40 and 19:10 UTC +(20:40-21:10 CEST). `lib.rs` is `epiphany-pipeline/src/lib.rs`. + +## Body facts + +### B1. The Mind (Huginn on Yggdrasil) + +- `whoami`: instance `eureka`, endpoint `rudp://10.77.0.1:17872`, reachable, + 377 documents, 120 receipts, index `Current`, epoch + `epiphany.pipeline.epoch.v2`. +- `huginn.service` (probe: `systemctl cat`): user `huginn`, + `--bind 10.77.0.1:17872`, hand-managed ("interim unit until Idunn owns it"). + `ss -lunp` shows the socket only on the WireGuard address `10.77.0.1` + (`wg-gamecult`). So the mind is reachable from Starfire over the mesh and + from Yggdrasil itself, and from nowhere else. A Claude cloud routine cannot + reach it. +- Campaigns in the mind (query): `idunn-watchdog` and `eureka-substrate`. + Rulings in force under `eureka-substrate`: `gap-home`, + `merge-to-main-first`, `mind-fixes-next`, `scope-core`, `depends-on`, + `key-bound`, `stewardship-rule`, `self-dep` (Defaulted), + `operator-mind-runs-own-self`. 15 follow-ups in force, 12 of them `gap-*`. +- Kinds: thirteen (`lib.rs:760-790`). None records a Self session, a claim on + a spec, a budget, or a landing (`gap-landing-untyped` is in force). +- `RulingAuthority { Operator, Standing, Defaulted }` (`lib.rs:458`, in + `unit_enums!`). Huginn's selection alias domain repeats it + (`crates/huginn-mind/src/rows.rs:46`, `AUTHORITIES`), the published schema + repeats it (`schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json:75-78`), + and the skill repeats it (`references/campaign-state.md:172`). A unit enum + variant added by name is a widening: stored rulings decode unchanged, which + the target's `stored-documents-valid` truth requires. (`value_types!` emits + no serde default on fields, so adding a *field* would not be; map + `eureka-substrate-map.md` B1. No cut here adds a field to an existing kind.) +- `Faculty` has `Operator` and `SelfFaculty` (`rows.rs:42`); admission does not + check faculty (`gap-faculty-attribution`, in force). So nothing in the mind + today can tell the Mind's Self from the operator-facing Self except the + `session` label on the receipt. +- Instance-rooted kinds are the pattern for a run record: `instance`, + `stewardship` and `hand_off` key off the instance (`lib.rs:913-946`), and + Huginn's admission rules for `hand_off` sit at + `crates/huginn-mind/src/admission.rs:231-262, 328-330`. Per-kind aliases are + declared at `rows.rs:362`. + +### B2. Claude Code on Starfire: auth, headless mode, usage + +- `claude --version`: 2.1.268. Binary + `%AppData%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe`. +- Account (`~/.claude.json` `oauthAccount`, secrets not read): Max plan, + `billingType: stripe_subscription`, rate-limit tier `default_claude_max_20x`, + extra usage off. There is no API key and no per-token billing: the Mind's + Self can only ever spend the operator's subscription quota. +- `~/.claude/.credentials.json` `claudeAiOauth`: an access token with + `expiresAt` 21:33:57 CEST, file written 13:33 CEST, so the interactive + access token lives eight hours and is refreshed by the interactive client. +- The failure the brief names, from this session's transcript + (`~/.claude/projects/F--Projects-CultLib/b471ff9b-….jsonl`): + `401 OAuth access token has expired. Re-authenticate to continue.` + (`authentication_error`), reported as "Agent terminated early due to an API + error" for subagents on `claude-sonnet-5-5`. A running subagent does not + survive the parent's token expiring. +- `claude setup-token`: "Set up a long-lived authentication token (requires + Claude subscription)". The binary carries the string "setup-token creates a + long-lived Claude.ai subscription token" and reads `CLAUDE_CODE_OAUTH_TOKEN` + (61 references). This is the headless credential for a subscription account: + no eight-hour expiry, no interactive refresh. Its lifetime and revocation are + the operator's (it is their subscription); not probed further because + creating one is the operator's act. +- Print mode: `claude -p` with `--output-format json|stream-json`, + `--json-schema`, `--agents `, `--mcp-config`, `--allowedTools`, + `--permission-mode`, `--max-budget-usd ` ("Maximum dollar amount to + spend on API"), `--bg`, `--resume`, `--fallback-model`. The `json` output + carries the run's cost (`total_cost_usd`) and usage. So a headless run has a + structural spend cap and a measured spend, per run, without any rule in a + prompt. +- Plan usage as data: the desktop app's `get_usage` returned, at 18:43 UTC: + 5-hour window 2%, weekly all models 46% (resets 2026-10-05 22:00 UTC), + weekly Fable 0%. That tool exists only inside the desktop app. The CLI + binary knows no usage endpoint (`api/oauth/files/`, `api/oauth/organizations/` + only); it reads `anthropic-ratelimit-unified` response headers (44 + references), which a headless wrapper does not see. **There is no typed + reading of plan usage reachable from Yggdrasil.** Follow-up + `gap-usage-not-typed`. +- Local scheduled tasks exist in the desktop app + (`~/.claude/scheduled-tasks/streampixels-m1-checkin`, disabled, last run + 2026-09-18): a cron inside the desktop app on Starfire. They run only while + the app is open on the operator's workstation, which the load budget + (memory `workstation-load-budget.md`: one job at a time, nothing unannounced) + and "no overnight burns" (memory `token-budget.md`) both argue against for a + recurring unattended Self. Cloud routines (`schedule` skill) run off-mesh + (B1) and hold no forge key (B6): dead for this purpose. + +### B3. Yggdrasil as the waking host + +- No Claude Code installed (`claude: command not found` for `gamecultadmin`; + no `~/.claude`). `node` v24.14.1 and `npm` present, so `npm i -g + @anthropic-ai/claude-code` under a dedicated user is the install. +- Load: 16 cores, 62 GiB, load average 1.4, 54 GiB free. The verify stopgap + runs at most 5 jobs at 4 CPUs and 6 GiB each (`tools/stopgap/ygg-verify.sh` + header). A Claude Code process is light; its Hands and Soul push their + heavy work into the same stopgap containers they use from Starfire. The + stopgap pushes to `~/eureka-verify` over `ssh ygg`; run on Yggdrasil itself + that alias must resolve to localhost for the `eureka` user, or the script + gets a local mode. Recorded in the wake cut's `first`. +- Non-resident workloads already run here hand-installed, outside Idunn + (`systemctl list-timers`): `bifrost-watchdog-notice.timer` (every minute, + oneshot, user `bifrost-notice`: system user, no shell, no home, credentials + through `EnvironmentFile=/srv/bifrost/env/persona-delivery.env` root 0600, + release under `/srv/bifrost/watchdog-notice/releases/-` with a + `current` symlink, `StateDirectory` for journal and receipts), + `gamecult-state-backup.timer` (nightly), `voidbot-source-refresh.timer`. + The runbook `bifrost-watchdog-notice-yggdrasil.md` calls this "an interim + root action on Yggdrasil … until Idunn owns non-resident workloads", and + ruling `idunn-watchdog:ruling:reader-deploy-owner` chose + `ops-hand-install`. **That is the precedent the Mind's Self wake unit + follows**, with the same deletion line. +- Idunn: `TargetDeclaration.service: ServiceDeclaration` is a required field + (`src/deployment.rs:33`), so a recipe without a resident service does not + parse. Follow-up `idunn-watchdog:follow_up:idunn-non-resident-workloads` + (in force) is exactly this gap; Idunn-managed units on the host today are + ghostlight, odin, streampixels-service and streampixels-web. +- Epiphany's own resident Self exists as prior art and is dead: units + `epiphany-swarm.service` ("Epiphany Yggdrasil resident Self") and + `epiphany-heartbeat.service` are `inactive dead`. The binary + (`epiphany-core/src/bin/epiphany-swarm.rs`) runs a 60 s maintenance loop over + `ResidentSelfState`, `ResidentSelfPolicy`, pressure and typed work + obligations, on the Codex spine (`epiphany-openai-codex-spine`, + `codex-connector`). Its doctrine (`epiphany_doctrine.md`): "Heartbeats are + paced scheduling physiology, not cognition … When no typed work obligation + is active, the swarm sleeps. Heartbeat does not ruminate, dream, mutate + memory, or manufacture work." Codex left as supplier 2026-09-05 (memory + `codex-retirement.md`). So the shape is right and the substrate is not: the + Mind's Self runs on Claude Code against Huginn, and borrows the law, not the + binary. + +### B4. The forge and the Mind's identity + +- `GameCult/Eureka` on `https://eureka.gamecult.org/forge/`: public, default + branch `main`, pull requests on, **no branch protection** (API + `branch_protections` → `[]`, with the admin token, on the host). +- Team `minds`: `write` on `repo.code`, `repo.pulls`, `repo.actions`, + `repo.issues`, `repo.releases`, `repo.wiki`, all org repos. Not admin. +- User `eureka` exists since 2026-10-01T18:49:53Z (created during this pass, by + the provisioning the handoff asked for), not admin, one key + `eureka@starfire` SHA256:`zcp0m+jV4AUXzGeoh9z+vfJS8xKHo9S865xOK8p6MGM`, which + matches `~/.ssh/eureka_mind_forge_ed25519.pub` on Starfire. The script + `provision-forge-minds-eureka` adds one key per host, so a Yggdrasil-run + Self gets its own key there. +- The Starfire checkout `~/.claude/skills/eureka` still pushes as + `eureka-admin` (`ssh://eureka-forge/…`, alias `eureka-forge` → + `eureka_forge_ed25519`, title `starfire-claude`). The runbook names that key + interim. Follow-up `forge-push-as-eureka` (gamecult-ops / Self). +- The charters are not in the Eureka repo: `ls ~/.claude/skills/eureka` gives + `LICENSE README.md SKILL.md docs references tools`; no `agents/`. The + charters live only in `~/.claude/agents/*.md` and the doctrine in + `~/.claude/doctrine/`, on Starfire, operator-owned (Life charter: + "the operator owns doctrine"). Every charter's self-improvement line says + "propose changes to the Eureka repo … by pull request on Forgejo". **The + thing the Mind is told to improve itself through is out of its reach.** + Question `charters-into-eureka-repo`. +- Reach is the scope boundary, by construction: a Linux user on Yggdrasil + with the `eureka` forge key, checkouts of Huginn, Epiphany, Eureka and + gamecult-ops, and no GitHub credential, no SSH to Starfire, no Aetheria + checkout, cannot touch an operator project. No prompt rule is needed for + "never touches Aetheria"; the key ring is the rule. (Huginn and Epiphany + are on GitHub today: the wake cut records how the Mind's user fetches and + pushes them. The Eureka forge org already mirrors nothing; a question for + the operator only if the Mind must push to GitHub. Recommended: it pushes + branches to the forge and GitHub stays Self's.) + +### B5. The operator's review channel + +- Bifrost `tools/bifrost-bridge.mjs discord-dm --recipient-id --content + `: DMs through Bifrost's bot token (`BIFROST_DISCORD_BOT_TOKEN`), exits + 75 when it knows nothing was sent (retry with the same nonce). The owner id + is `DISCORD_OWNER_ID` in `persona-delivery.env`. +- `tools/watchdog-notice.mjs` is a one-shot reader that turns a typed store + (Idunn's `incidents.cc`, read unlocked, never written) into owner DMs with a + journal and receipts, exponential retry capped at 60 min, fixed error codes + that never echo input. The idunn-watchdog campaign established the owner + rule: the producer never delivers; Bifrost is the bridge. A Mind-notice + reader is the same program over Huginn (`query` for rulings with authority + `Mind`, runs ended, questions opened, since the journal's last ordinal) + instead of over `incidents.cc`. + +### B6. Scheduling today, and what the skill already derives + +- The skill's recipes (`campaign-state.md` "Recipes") already derive the + queue: specs with no report (minus those blocked by a question raised in + them), reports with no verdict, open findings by severity, open questions and + follow-ups, substrate gaps. The operator-facing Self reads them at rehydrate + and then takes direction from the operator and from prose handoffs + (`F:\Projects\HANDOFF-eureka-self-2026-10-01.md` is one). +- What the queue cannot say: (1) who is working a spec right now (two Selves + would both take the first unreported spec); (2) whether a holding verdict + landed (`gap-landing-untyped`); (3) which of fifteen follow-ups comes first + (no priority, no order); (4) how much the Mind may spend; (5) which Self + admitted a ruling. (1), (4) and (5) are what the `run` and `allowance` + kinds below carry. (2) stays its own gap. (3) is deliberately not a field: + the Mind's Self orders by the fixed rule in the rationale, and a follow-up + that must jump the queue becomes a cut spec. + +## The model page + +One row per persistent kind this design adds or changes. Rows for the +thirteen existing kinds are in `eureka-substrate-map.md`. + +| Kind | Identity (what names it) | Lifecycle (what happens over time) | Authority (who decides) | +| --- | --- | --- | --- | +| `ruling` with `authority: Mind` (new variant, no new kind) | Unchanged: `:ruling: