| Subject keys (`finding`, `follow_up`, `question`, `ruling`, `cut_spec`, `target`, `stewardship`) | `<root>:<kind>:<local>`, 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 | `<subject root>:resolution:<subject kind>.<subject local>.n<seq>`, 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/<full sha>`, `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
`HUGINN_ENDPOINT=rudp://10.77.0.1:17872`. No `mcp__eureka-state` entries in
`~/.claude/settings.json` permissions. That file has an `env` block holding
one key (`CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL`).
-`~/.cargo/bin` and `~/.local/bin` are on the Bash tool's PATH.
`~/.eureka/bin` is not.
-`py -3` is 3.13.15 on Starfire and `python3` is 3.13.5 on Yggdrasil.
- Yggdrasil has no eureka-state or huginn client binary installed. The daemon is
`/opt/gamecult/huginn/current/huginn-daemon`.
- Huginn: `crates/eureka-state` is `lib.rs` (HuginnClient, 210 lines, transport
only) plus `main.rs` (the MCP server, 349 lines) and tests (`client.rs` 511,
`mcp.rs` 564, `common` 185). At main, nothing else in the workspace depends on
the crate. `eureka-body/mind-launch` (3b15c39) adds
`eureka-state = { path = "../eureka-state" }` to mind-body and calls
`HuginnClient` in-process.
- Eureka: `tools/context-pack.py:115-201` hand-rolls an MCP stdio client that
reads the binding from `~/.claude.json`. `tools/test-tools.sh:346-381,509-513`
test it with `fixtures/context-pack/fake-eureka-state.py`. `agents/life.md:4`
is the only charter whose tool list names `mcp__eureka-state__*`.
- Wire: `huginn-mind` encodes requests and answers with
`rmp_serde::to_vec_named` (named maps), so an added field is skipped by an old
reader. The leaf has no `deny_unknown_fields`.
`107552dc..main` adds the read anchors (an additive field), `RulingAuthority::Mind`
(an added variant) and the run kind.
### Model page: the CLI (step 0b)
| Question | Answer | Why |
|---|---|---|
| Binary | `huginn` | It is the mind's door, named for the organ. No name clash: the daemon's binary is `huginn-daemon` and the operator CLI is `mind-control`. |
| Crate | `crates/eureka-state`, bin `src/bin/huginn.rs`. The crate is not renamed. | mind-launch already depends on the package by name. A rename is pure churn across two campaigns and protects no invariant. |
| Library | `HuginnClient` (`lib.rs`) stays the one client and stays transport-only. | The CLI and mind-launch are its two consumers. Env parsing lives in the CLI, not the lib, because mind-launch builds clients from its own config. |
| Install, Starfire | `C:\Users\Meta\.local\bin\huginn.exe` (already on PATH), with `.next` and `.prev` beside it per the runbook. Built at the merged sha with `HUGINN_BUILD_SHA` set. | No PATH or system edit is needed. |
| Install, Yggdrasil | None until the first Claude session runs there (the Self-run unit). Then it goes in the release dir beside the daemon, linked from `current`, so it flips with the daemon. | Nothing on the host calls the mind through a shell yet. mind-launch links the lib. Follow-up `huginn-cli-on-yggdrasil`. |
| Finding the mind | `EUREKA_INSTANCE` and `HUGINN_ENDPOINT` from the environment, exactly as the MCP server read them. On Starfire they are set in the `env` block of `~/.claude/settings.json`, which is operator-visible. On Yggdrasil they will go in the unit's `Environment=`. No `--instance` flag and no config file. | This keeps the MCP rule that the instance is the session's configuration, not a call argument. Env is how a systemd unit and a Claude session both configure a child. |
| Version | `huginn --version` prints the crate version and the build sha. `whoami` prints `client <sha>`. | Lets an operator see skew. It is display-only. |
| Lifecycle | Each call is a new process. Swapping the exe takes effect on the next call, with no session restart. | This removes runbook step 8's restart-every-session. |
| Skew rule | After every Committed or AlreadyAdmitted batch, the CLI reads the batch back with one `keys` query (projection document) and compares each stored document with what it sent. A difference exits 5 and names the ids and field paths. No version negotiation is added. | This is the end-to-end argument (Saltzer, Reed and Clark 1984): only the end check covers every intermediate drop, whether it came from an old leaf at the CultNet door, a serde default or a future skew. A whoami version field would need a daemon deploy (blocked on the upgrade rerun), would cover only the skew it was taught, and would leave 107552dc unprotected. Cost: one extra call per admit. Limit: the document is already committed, so the agent supersedes it. That is loud, not silent. |
| Authority | The CLI owns no state. It parses arguments, strictly decodes agent-written input into leaf types, names the recipe selections, renders text and derives the exit code. Admission and status stay Huginn's, and the transport stays HuginnClient's. | This is Brokkr's brokkr-command shape (A3, b451cdd): a caller whose exit code is derived from the answer and never from "the send succeeded". |
| Input door | Agent-written JSON (question `huginn-cli-document-format`), decoded strictly. A key path that is present with a non-empty value and absent from the typed re-serialisation is refused as exit 2, naming paths, never values. | Closes `gap-admit-drops-unknown-keys` at the door. The store decode stays lenient for rollback (map B15). |
| Recipes | Named subcommands in the CLI, each the exact selection campaign-state.md defines at Eureka 278b881. After cut skill-huginn-cli, the page names them and `--help` prints each selection. | One copy of the progress view, in code an agent runs, not JSON an agent retypes. |
| Exit codes | 0 answered. 1 the mind said no (Refused, Conflict, view absent). 2 the call is wrong (usage, InvalidInput, TooLarge, Misconfigured). 3 Unavailable (retry once). 4 no answer and a retry cannot help (Rejected, Unencodable, Internal). 5 committed but the read-back differs. | These map one to one onto the skill's organ rule. |