Merge origin/main (f003801) into eureka-body/mind-launch

Mechanical adaptation: testkit's PipelineCutSpec builder gains the new reads field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 06:50:46 +02:00
commit 35c5b7ac24
27 changed files with 1533 additions and 39 deletions

View file

@ -15,7 +15,8 @@ like the rest of the workspace.
Committed (runs in `cargo test -p eureka-pipeline`): `encoded_envelopes_match_the_committed_golden`
asserts key, cultcache type id, schema name and payload bytes of every kind
and every encoding-relevant variant against `golden/envelopes.txt`. A diff
there is a wire change; do not regenerate it to make the test pass.
there is a wire change; do not regenerate it to make the test pass. An additive
field adds golden lines and never changes an existing one.
Opt-in, against a real store: `stored_documents_read_back` decodes, validates
and keys every stored pipeline document and asserts that it re-encodes to the

View file

@ -9,6 +9,7 @@
# live mind store (eureka-body:verdict:cut-leaf-into-huginn.s1) and to
# Epiphany ef956865 modulo that rename (verdict ...s2). Never regenerate to make
# a failing test pass: a diff here is a wire change and needs its own ruling.
# cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed.
sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135
@ -40,3 +41,5 @@ finding.plausible-blocker-preexisting-kinds eureka-state:finding:cut-3a.s2.F4 ep
finding.severity-medium eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a64d656469756da5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a34c6f77a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64
cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131
finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564

View file

@ -27,7 +27,9 @@
//! cannot be declared without being validated. Format rules live in the field's
//! type (`Label`, `Slug`, `OrgRepo`, `Sha`, `Sha256Hex`, `Date`), never in a
//! hand-written impl that a later field can slip past. A `Vec` field must carry
//! a maximum, because `Vec<T>` has no `Bounded` impl of its own.
//! a maximum, because `Vec<T>` has no `Bounded` impl of its own. A field added
//! at the epoch carries the `= absent` marker, which defaults it on read and
//! omits it on write when empty.
//!
//! The wrappers are crate-private: outside code registers, prepares and
//! decodes them through `register_pipeline_document_types`,
@ -82,6 +84,24 @@ impl<T: Bounded> Bounded for Option<T> {
}
}
/// A value with an empty spelling. The `= absent` marker on a `value_types!`
/// field requires it, and the field is then omitted on write when it is absent.
pub(crate) trait Absent {
fn absent(&self) -> bool;
}
impl<T> Absent for Option<T> {
fn absent(&self) -> bool {
self.is_none()
}
}
impl<T> Absent for Vec<T> {
fn absent(&self) -> bool {
self.is_empty()
}
}
macro_rules! unbounded_scalars {
($($ty:ty),* $(,)?) => {$(
impl Bounded for $ty {
@ -238,6 +258,14 @@ fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
within(200)(field, value)
}
/// A symbol is 1 to 128 bytes with no whitespace and no control character.
fn symbol_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.is_empty() || value.chars().any(|character| character.is_whitespace() || character.is_control()) {
return Err(format_error(field, value));
}
within(128)(field, value)
}
bounded_text! {
/// Text of at most 200 UTF-8 bytes.
Short = 200, |field, value| within(200)(field, value);
@ -249,6 +277,11 @@ bounded_text! {
Title = 200, title_text;
/// Text of at most 1,000 UTF-8 bytes.
Line = 1000, |field, value| within(1000)(field, value);
/// A name the location's lines must contain as written there (an
/// identifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no
/// whitespace and no control character.
#[schemars(extend("minLength" = 1))]
Symbol = 128, symbol_text;
/// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`.
Para = 4000, |field, value| within(4000)(field, value);
/// A key label: `[A-Za-z0-9_-]{1,64}`.
@ -415,19 +448,25 @@ macro_rules! bounded_field {
/// The single field list: one invocation emits the value struct and its
/// `Bounded` impl, so a field can never be left out of validation.
macro_rules! value_types {
($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? } $(=> $extra:path)?)*) => {$(
($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])? $(= $marker:ident)?),* $(,)? } $(=> $extra:path)?)*) => {$(
$(#[$attr])*
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct $name {
$(
$(#[schemars(extend("maxItems" = $max))])?
// `= absent`: defaulted on read, omitted on write when absent.
// The `cfg_attr(any(), ..)` is a no-op that binds `$marker` to the group.
$(#[serde(default, skip_serializing_if = "Absent::absent")] #[cfg_attr(any(), $marker)])?
pub $field: $ty
),*
}
impl Bounded for $name {
fn validate(&self, at: &str) -> Result<(), PipelineRefusal> {
$(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)*
$(
$(let _: fn(&$ty) -> bool = <$ty as Absent>::$marker;)?
bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);
)*
$($extra(self, at)?;)?
Ok(())
}
@ -472,6 +511,33 @@ fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<
Ok(())
}
/// The most lines one read anchor may span.
const READ_WINDOW_MAX_LINES: u32 = 100;
/// A location starts at line 1 or later, ends no earlier than it starts, and a
/// read anchor spans at most `READ_WINDOW_MAX_LINES`. The bounds are on
/// `ReadAnchor` alone: `CodeLocation` elsewhere keeps its stored shape.
fn read_window_is_bounded(anchor: &ReadAnchor, at: &str) -> Result<(), PipelineRefusal> {
let location = &anchor.location;
if location.line < 1 {
return Err(format_error(&format!("{at}.location.line"), &location.line.to_string()));
}
if let Some(end_line) = location.end_line {
if end_line < location.line {
return Err(format_error(&format!("{at}.location.end_line"), &end_line.to_string()));
}
let span = end_line - location.line + 1;
if span > READ_WINDOW_MAX_LINES {
return Err(PipelineRefusal::FieldBound {
field: format!("{at}.location.end_line"),
limit: READ_WINDOW_MAX_LINES,
actual: span,
});
}
}
Ok(())
}
/// A run claims work, never runs, rulings or campaigns: its claims are cut
/// specs, findings and follow-ups, the three kinds a Self takes on. And its
/// budget is a canonical decimal (`0`, `12`, `7.5`: no sign, no leading or
@ -509,7 +575,7 @@ unit_enums! {
}
value_types! {
pub struct CodeLocation { path: Short, line: u32, end_line: Option<u32> }
pub struct CodeLocation { path: Short, line: u32, end_line: Option<u32>, symbol: Option<Symbol> = absent }
pub struct CommitRange { base: Sha, head: Sha }
pub struct Evidence { kind: EvidenceKind, locator: Line, result: Line }
/// A citation of a document in another repo's pipeline store (D6).
@ -519,6 +585,8 @@ value_types! {
pub struct QuestionOption { label: Label, text: Line }
pub struct CutDelete { path: Short, lines: u32, note: Line }
pub struct FileChange { location: CodeLocation, change: Line }
/// Code Hands must see but not change, in the spec's repo at the spec's base.
pub struct ReadAnchor { location: CodeLocation, why: Short } => read_window_is_bounded
pub struct AuthorityMap {
owner: Line, inputs: Vec<Line>[16], outputs: Vec<Line>[16], derived_state: Vec<Line>[16],
forbidden_writers: Vec<Line>[16], shared_paths: Vec<Line>[16], deletion_line: Line,
@ -572,7 +640,7 @@ value_types! {
pub struct PipelineCutSpec {
campaign: Slug, cut: Label, revision: u32, title: Title, repo: OrgRepo, branch: Short, base: Sha,
depends_on: Vec<Short>[8], first: Vec<Line>[16], deletes: Vec<CutDelete>[64], keeps_moves: Vec<Line>[64],
adds: Vec<Line>[64], file_changes: Vec<FileChange>[256], authority_map: Option<AuthorityMap>,
adds: Vec<Line>[64], file_changes: Vec<FileChange>[256], reads: Vec<ReadAnchor>[16] = absent, authority_map: Option<AuthorityMap>,
verification: CutVerification, estimate: StructuralDelta,
rulings: Vec<Short>[32], questions: Vec<Short>[16],
}
@ -737,6 +805,7 @@ macro_rules! pipeline_kinds {
/// shape, not a document's.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
#[allow(clippy::large_enum_variant, reason = "one value per request or per batch of at most 64; boxing a variant would buy no memory and change every construction site")]
pub enum PipelineDocument { $($variant($value)),* }
impl PipelineKind {
@ -967,7 +1036,8 @@ fn local_max(kind: PipelineKind) -> usize {
/// The schema epoch every pipeline store is written at, owned here with the
/// schemas it names. Evolution is additive and keeps it: a new named field
/// with a serde default, or a widened `PipelineKind`, since each reader ships
/// with the `value_types!` `= absent` marker (a replayed batch re-encodes byte
/// for byte, because an absent field is not written), or a widened `PipelineKind`, since each reader ships
/// with the variants it knows and refuses an unknown kind on the kind, not on
/// the epoch. A breaking change bumps it, so that a store written at the old
/// one can be refused by whoever opens it; this crate owns no store and
@ -1105,7 +1175,7 @@ pub fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> Result<
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
const CAMPAIGN: &str = "eureka-state";
@ -1144,7 +1214,7 @@ mod tests {
}
fn location() -> CodeLocation {
CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 1, end_line: Some(9) }
CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 1, end_line: Some(9), symbol: None }
}
fn evidence() -> Evidence {
@ -1196,6 +1266,7 @@ mod tests {
deletes: vec![CutDelete { path: s("old.rs"), lines: 3, note: "dead".into() }],
keeps_moves: vec!["commit owner".into()], adds: vec!["pipeline_documents.rs".into()],
file_changes: vec![FileChange { location: location(), change: "add".into() }],
reads: vec![],
authority_map: Some(AuthorityMap {
owner: "core".into(), inputs: vec!["typed documents".into()], outputs: vec!["envelopes".into()],
derived_state: vec!["derived keys".into()], forbidden_writers: vec!["MCP".into()],
@ -1425,6 +1496,16 @@ mod tests {
out.push((format!("finding.severity-{name}"), D::Finding(f)));
}
out.push(("ruling.authority-mind".into(), D::Ruling(mind_ruling())));
let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() };
spec.file_changes[0].location.symbol = Some("value_types".into());
spec.reads = vec![
ReadAnchor { location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 10, end_line: Some(20), symbol: Some("PipelineCutSpec".into()) }, why: "the document shape".into() },
ReadAnchor { location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 30, end_line: None, symbol: None }, why: "one line".into() },
];
out.push(("cut_spec.reads-and-symbol".into(), D::CutSpec(spec)));
let mut f = finding_sample();
f.locations[0].symbol = Some("&1153090498".into());
out.push(("finding.location-symbol".into(), D::Finding(f)));
out
}
@ -1827,7 +1908,7 @@ mod tests {
// refuses this one: the digits rule is satisfied either way.
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.1"));
refused(&format!("{CAMPAIGN}:CUT_SPEC:cut-3a.r1"));
refused(&format!("EUREKA-STATE:cut_spec:cut-3a.r1"));
refused("EUREKA-STATE:cut_spec:cut-3a.r1");
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.r1:"));
refused(&format!("{CAMPAIGN}::cut-3a.r1"));
refused("cut-3a.r1");
@ -2964,6 +3045,133 @@ mod tests {
);
}
fn read_anchor(line: u32, end_line: Option<u32>, symbol: Option<&str>) -> ReadAnchor {
ReadAnchor {
location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line, end_line, symbol: symbol.map(Symbol::from) },
why: "read".into(),
}
}
fn cut_spec_sample() -> PipelineCutSpec {
let PipelineDocument::CutSpec(spec) = samples().remove(4).0 else { unreachable!() };
spec
}
/// Every committed golden line decodes, from its committed payload bytes,
/// to the corpus document it was derived from. The pre-field lines carry no
/// `reads` or `symbol` key, so they decode to empty and `None` only
/// because both fields default on read.
#[test]
fn golden_payloads_decode_to_their_documents() -> Result<()> {
let cache = schema_cache()?;
let golden = include_str!("../golden/envelopes.txt");
let lines: BTreeMap<&str, &str> = golden
.lines()
.filter(|line| !line.starts_with('#') && !line.is_empty())
.filter_map(|line| Some((line.split(' ').next()?, line.rsplit(' ').next()?)))
.collect();
let corpus = golden_documents();
assert_eq!(lines.len(), corpus.len());
for (label, document) in corpus {
let hex = lines[label.as_str()];
let mut envelope = document.prepare(&cache)?;
envelope.payload = (0..hex.len()).step_by(2).map(|at| u8::from_str_radix(&hex[at..at + 2], 16).unwrap()).collect();
assert_eq!(PipelineDocument::decode(&envelope)?, document, "{label} decodes to its document");
}
Ok(())
}
/// The MCP door: JSON with no `reads` key and locations with no `symbol`
/// key is the pre-field shape, and it decodes.
#[test]
fn pre_field_json_decodes() -> Result<()> {
let json = serde_json::to_value(PipelineDocument::CutSpec(cut_spec_sample()))?;
assert!(json["value"].get("reads").is_none(), "an empty reads is not written");
assert!(json["value"]["file_changes"][0]["location"].get("symbol").is_none(), "an absent symbol is not written");
let PipelineDocument::CutSpec(spec) = serde_json::from_value::<PipelineDocument>(json)? else { unreachable!() };
assert!(spec.reads.is_empty());
assert_eq!(spec.file_changes[0].location.symbol, None);
Ok(())
}
#[test]
fn symbol_and_reads_round_trip() -> Result<()> {
let mut spec = cut_spec_sample();
spec.reads = (0..16).map(|index| read_anchor(index + 1, Some(index + 100), Some("value_types"))).collect();
let mut finding = finding_sample();
finding.locations[0].symbol = Some("&1153090498".into());
for document in [PipelineDocument::CutSpec(spec), PipelineDocument::Finding(finding)] {
document.validate()?;
let json = serde_json::to_value(&document)?;
assert_eq!(serde_json::from_value::<PipelineDocument>(json.clone())?, document);
let packed = rmp_serde::to_vec_named(&document)?;
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
assert!(packed.windows(6).any(|window| window == b"symbol"), "the symbol key is written");
assert_eq!(packed.windows(5).any(|window| window == b"reads"), matches!(document, PipelineDocument::CutSpec(_)));
assert!(json.to_string().contains("\"symbol\""));
}
Ok(())
}
#[test]
fn read_and_symbol_bounds() {
let with_reads = |reads: Vec<ReadAnchor>| {
let mut spec = cut_spec_sample();
spec.reads = reads;
PipelineDocument::CutSpec(spec).validate()
};
let with_symbol = |symbol: &str| {
let mut finding = finding_sample();
finding.locations[0].symbol = Some(symbol.into());
PipelineDocument::Finding(finding).validate()
};
assert_eq!(with_reads((0..16).map(|_| read_anchor(1, Some(100), None)).collect()), Ok(()));
assert_eq!(
with_reads((0..17).map(|_| read_anchor(1, Some(100), None)).collect()),
Err(PipelineRefusal::FieldBound { field: "cut_spec.reads".into(), limit: 16, actual: 17 })
);
assert_eq!(
with_reads(vec![read_anchor(5, Some(4), None)]),
Err(PipelineRefusal::InvalidFormat { field: "cut_spec.reads[0].location.end_line".into(), value: "4".into() })
);
assert_eq!(
with_reads(vec![read_anchor(0, None, None)]),
Err(PipelineRefusal::InvalidFormat { field: "cut_spec.reads[0].location.line".into(), value: "0".into() })
);
assert_eq!(with_reads(vec![read_anchor(7, Some(7), None)]), Ok(()), "a one-line span");
assert_eq!(with_reads(vec![read_anchor(10, Some(109), None)]), Ok(()), "a span of 100");
assert_eq!(
with_reads(vec![read_anchor(10, Some(110), None)]),
Err(PipelineRefusal::FieldBound { field: "cut_spec.reads[0].location.end_line".into(), limit: 100, actual: 101 })
);
assert_eq!(with_symbol(&"s".repeat(128)), Ok(()));
assert_eq!(
with_symbol(&"s".repeat(129)),
Err(PipelineRefusal::FieldBound { field: "finding.locations[0].symbol".into(), limit: 128, actual: 129 })
);
for bad in ["a b", "a\tb", "", "a\u{0}b", "a\u{3000}b"] {
assert_eq!(
with_symbol(bad),
Err(PipelineRefusal::InvalidFormat { field: "finding.locations[0].symbol".into(), value: bad.into() }),
"{bad:?}"
);
}
// A location outside a read anchor keeps its stored shape.
let mut finding = finding_sample();
finding.locations[0].line = 0;
finding.locations[0].end_line = Some(0);
assert_eq!(PipelineDocument::Finding(finding).validate(), Ok(()));
}
/// The marker needs an `Absent` spelling; the two impls say what absent is.
#[test]
fn absent_is_none_and_empty() {
assert!(Absent::absent(&None::<u32>));
assert!(!Absent::absent(&Some(0u32)));
assert!(Absent::absent(&Vec::<u32>::new()));
assert!(!Absent::absent(&vec![0u32]));
}
/// Cut 8: the leaf owns the serialisation of the shapes it owns, so a wire
/// carrying a document or a refusal reads back the same value rather than
/// a mirror definition kept in step by hand in the reader's repo. Both

View file

@ -56,10 +56,11 @@ fn cut_spec(cut: &str, file_changes: usize) -> PipelineDocument {
adds: lines(64),
file_changes: (0..file_changes)
.map(|n| FileChange {
location: CodeLocation { path: short(n), line: 1, end_line: Some(9) },
location: CodeLocation { path: short(n), line: 1, end_line: Some(9), symbol: None },
change: line(n),
})
.collect(),
reads: vec![],
authority_map: Some(AuthorityMap {
owner: line(0),
inputs: lines(16),

View file

@ -150,7 +150,7 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
HuginnMindResponse::Admit(outcome)
}
HuginnMindRequest::View { id, .. } => match self.mind.view(&id) {
Ok(view) => HuginnMindResponse::View(view),
Ok(view) => HuginnMindResponse::View(view.map(Box::new)),
Err(refusal) => HuginnMindResponse::Refused(refusal),
},
HuginnMindRequest::Query { selection, semantic: None, .. } => match self.mind.query(&selection) {
@ -353,11 +353,12 @@ pub(crate) mod tests {
/// roughly a megabyte of field content, which is what a real-sized answer
/// looks like at the leaf's bounds rather than at a fixture's convenience.
/// `depends_on`, `rulings` and `questions` stay empty because each is a
/// reference admission resolves.
/// reference admission resolves. `reads` and location symbols stay absent:
/// `FITTING_CHANGES` is calibrated to `MAX_RESPONSE_BYTES` without them.
pub(crate) fn cut_spec(cut: &str, file_changes: usize) -> PipelineDocument {
let lines = |count: usize| (0..count).map(line).collect::<Vec<_>>();
let shorts = |count: usize| (0..count).map(short).collect::<Vec<_>>();
let location = |n: usize| CodeLocation { path: short(n), line: 1, end_line: Some(9) };
let location = |n: usize| CodeLocation { path: short(n), line: 1, end_line: Some(9), symbol: None };
PipelineDocument::CutSpec(PipelineCutSpec {
campaign: slug(CAMPAIGN),
cut: cut.into(),
@ -372,6 +373,7 @@ pub(crate) mod tests {
keeps_moves: lines(64),
adds: lines(64),
file_changes: (0..file_changes).map(|n| FileChange { location: location(n), change: line(n) }).collect(),
reads: vec![],
authority_map: Some(AuthorityMap {
owner: line(0),
inputs: lines(16),
@ -493,9 +495,11 @@ pub(crate) mod tests {
Ok(Daemon::new(Mind::open(state_root, instance)?, NoIndex))
}
type Seen = Vec<(Vec<String>, Vec<bool>)>;
#[derive(Clone, Default)]
struct RecordingIndex {
seen: Arc<Mutex<Vec<(Vec<String>, Vec<bool>)>>>,
seen: Arc<Mutex<Seen>>,
}
impl<S: MindStore> IndexSink<S> for RecordingIndex {
@ -605,7 +609,7 @@ pub(crate) mod tests {
let PipelinePageItems::Documents(views) = &page.items else { panic!("the document projection is the view") };
assert_eq!(views[0].status, PipelineStatus::InForce);
let view = daemon.handle(HuginnMindRequest::View { instance: slug(INSTANCE), id: id.clone() }, now()).answered();
assert_eq!(view, HuginnMindResponse::View(Some(views[0].clone())));
assert_eq!(view, HuginnMindResponse::View(Some(Box::new(views[0].clone()))));
}
/// Ruling 14 across the transport, on both sides: the mind refuses a read

View file

@ -596,7 +596,7 @@ impl<E: Embedder, V: VectorIndex> Projector<E, V> {
let found = self.index.search(&self.collection, &vector, limit, budget.left()?)?;
let mut hits = Vec::with_capacity(found.len());
for hit in found {
match PipelineKind::ALL.into_iter().find(|kind| kind.name() == hit.kind) {
match PipelineKind::ALL.iter().find(|kind| kind.name() == hit.kind) {
Some(kind) => hits.push((PipelineRef { kind: *kind, id: Short(hit.doc_id) }, hit.score)),
None => eprintln!("huginn: the index returned {} under the unknown kind {:?}; it is left out", hit.doc_id, hit.kind),
}

View file

@ -19,10 +19,13 @@ use super::*;
use crate::daemon::Daemon;
use crate::daemon::tests::{INSTANCE, OTHER, batch, campaign_seed, now, question_and_ruling, slug};
type AfterAbsorb = Box<dyn FnOnce(&mut Shared)>;
type Edit<T> = (&'static str, Box<dyn Fn(&mut T)>);
thread_local! {
/// Runs once, in the worker's own turn, between the inbox being absorbed
/// and a search being taken, holding the guard `serve_search` holds.
static AFTER_ABSORB: std::cell::RefCell<Option<Box<dyn FnOnce(&mut Shared)>>> = const { std::cell::RefCell::new(None) };
static AFTER_ABSORB: std::cell::RefCell<Option<AfterAbsorb>> = const { std::cell::RefCell::new(None) };
}
/// The hook `serve_search` calls at the point where a second lock once left a
@ -167,7 +170,7 @@ fn reconcile_indexes_exactly_the_indexable_documents_the_collection_lacks() {
fn a_model_digest_change_rebuilds_the_collection() {
let (_root, mind) = mind_with_documents();
let entries = mind.index_entries(None).unwrap();
let changes: [(&str, Box<dyn Fn(&mut ModelIdentity)>); 3] = [
let changes: [Edit<ModelIdentity>; 3] = [
("digest", Box::new(|identity| identity.digest = "d2".into())),
("name", Box::new(|identity| identity.name = "other-model".into())),
("dimensions", Box::new(|identity| identity.dimensions = 8)),
@ -1352,7 +1355,7 @@ fn a_model_changed_refusal_names_the_state_and_the_pending_count() {
fn a_search_re_reads_the_collections_label() {
let (_root, mind) = mind_with_documents();
let entries = mind.index_entries(None).unwrap();
let cases: [(&str, Box<dyn Fn(&mut Stored)>); 3] = [
let cases: [Edit<Stored>; 3] = [
(
"another mind's label",
Box::new(|stored| {

View file

@ -1024,7 +1024,7 @@ mod tests {
let message_id = format!("{stem}{}", "0".repeat(zeros));
let at_probe = encoded_len(&reply_with_payload(&message_id, operation, runtime_id, "A".repeat(probe)));
let payload_len = (probe as i64 + (target as i64 - at_probe as i64)) as usize;
if payload_len % 4 == 0 {
if payload_len.is_multiple_of(4) {
let message = reply_with_payload(&message_id, operation, runtime_id, "A".repeat(payload_len));
assert_eq!(encoded_len(&message), target);
return (message_id, message);

View file

@ -608,7 +608,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
use PipelineKind as K;
use ResolutionOutcome as O;
let all = |by: &[PipelineRef], kind: K| by.iter().all(|supersessor| supersessor.kind == kind);
let fits = match (subject, outcome) {
match (subject, outcome) {
(K::Target, O::Superseded { by }) => all(by, K::Target),
(K::Question, O::Answered { by }) => by.kind == K::Ruling,
(K::Question, O::Withdrawn { .. }) => true,
@ -626,8 +626,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
(K::Run, O::Recorded { .. } | O::Withdrawn { .. }) => true,
(K::Resolution, O::Withdrawn { .. }) => true,
_ => false,
};
fits
}
}
/// The resolution row: the sequence, the in-force subject, the matrix, the
@ -869,6 +868,42 @@ mod tests {
assert_eq!(receipts[0].provenance.tool, s("admit tool"));
}
/// The read bounds are the leaf's: admission applies the leaf's validation
/// and adds none. Sixteen anchors of a hundred lines admit and read back;
/// a seventeenth refuses at `cut_spec.reads` and nothing is written.
#[test]
fn reads_admit_at_the_bound_and_refuse_over_it() {
let anchors = |count: usize| {
(0..count)
.map(|n| eureka_pipeline::ReadAnchor {
location: eureka_pipeline::CodeLocation {
path: s("crates/eureka-pipeline/src/lib.rs"),
line: 1,
end_line: Some(100),
symbol: Some(format!("symbol_{n}").as_str().into()),
},
why: s("read"),
})
.collect::<Vec<_>>()
};
let mut mind = seeded();
let mut spec = cut_spec("1", 1);
spec.reads = anchors(16);
committed(admit(&mut mind, vec![D::CutSpec(spec.clone())]));
let held = mind.view(&r(K::CutSpec, &id("cut_spec", "cut-1.r1"))).unwrap().expect("the spec is held");
assert_eq!(held.document, D::CutSpec(spec));
let mut over = cut_spec("2", 1);
over.reads = anchors(17);
let receipts = mind.receipts().unwrap().len();
assert_eq!(
refusal(admit(&mut mind, vec![D::CutSpec(over)])),
MindRefusal::Document(PipelineRefusal::FieldBound { field: "cut_spec.reads".into(), limit: 16, actual: 17 })
);
assert_eq!(mind.receipts().unwrap().len(), receipts);
assert_eq!(mind.view(&r(K::CutSpec, &id("cut_spec", "cut-2.r1"))).unwrap(), None, "nothing is written");
}
/// The leaf's `Title` rule is private, so this holds the provenance rule to
/// it: the two accept and refuse the same text.
#[test]
@ -1640,7 +1675,7 @@ mod tests {
let hand_off_ref = r(K::HandOff, &format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-huginn.2026-09-16"));
// Accepted, one per resolvable kind.
committed(admit(&mut world(), vec![target(2, &[INVARIANT]), resolution(target_ref.clone(), superseded(&[target_r2.clone()]))]));
committed(admit(&mut world(), vec![target(2, &[INVARIANT]), resolution(target_ref.clone(), superseded(std::slice::from_ref(&target_r2)))]));
committed(admit(&mut world(), vec![resolution(question_ref.clone(), withdrawn())]));
committed(admit(&mut world(), vec![D::Ruling(ruling("R2")), resolution(ruling_ref.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2"))]))]));
committed(admit(&mut world(), vec![resolution(spec_ref.clone(), withdrawn())]));
@ -1683,7 +1718,7 @@ mod tests {
incompatible(K::CutSpec, "Superseded")
);
assert_eq!(
refusal(admit(&mut world(), vec![resolution(finding_ref.clone(), superseded(&[finding_ref.clone()]))])),
refusal(admit(&mut world(), vec![resolution(finding_ref.clone(), superseded(std::slice::from_ref(&finding_ref)))])),
incompatible(K::Finding, "Superseded")
);
assert_eq!(
@ -2302,7 +2337,7 @@ mod tests {
committed(admit(&mut open(), vec![resolution(subject(), withdrawn())]));
let other_run = r(K::Run, &run_key("z"));
let refused = [
(superseded(&[other_run.clone()]), "Superseded"),
(superseded(std::slice::from_ref(&other_run)), "Superseded"),
(ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }, "Answered"),
(ResolutionOutcome::Fixed { commit: sha(), by: None }, "Fixed"),
(ResolutionOutcome::Deferred { to: other_run }, "Deferred"),

View file

@ -85,7 +85,7 @@ fn range() -> CommitRange {
}
fn location() -> CodeLocation {
CodeLocation { path: s("crates/huginn-mind/src/admission.rs"), line: 1, end_line: None }
CodeLocation { path: s("crates/huginn-mind/src/admission.rs"), line: 1, end_line: None, symbol: None }
}
pub(crate) fn evidence() -> Evidence {
@ -203,6 +203,7 @@ pub(crate) fn cut_spec(cut: &str, revision: u32) -> PipelineCutSpec {
keeps_moves: vec![],
adds: vec![],
file_changes: vec![],
reads: vec![],
authority_map: None::<AuthorityMap>,
verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] },
estimate: delta(),

View file

@ -155,6 +155,7 @@ impl HuginnCommitReceipt {
/// How a commit ended. An exact replay is decided by admission before the
/// commit is attempted, so it is not an exit of the primitive.
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::large_enum_variant, reason = "one outcome per commit, returned once and matched at once")]
pub(crate) enum CommitOutcome {
/// The batch and its receipt landed whole.
Committed(HuginnCommitReceipt),

View file

@ -105,7 +105,7 @@ impl HuginnMindRequest {
pub enum HuginnMindResponse {
Whoami(MindStatus),
Admit(PipelineAdmissionOutcome),
View(Option<PipelineDocumentView>),
View(Option<Box<PipelineDocumentView>>),
Query(PipelineSelectionPage),
Refused(MindRefusal),
/// The answer did not fit one send and is being held for the client to

View file

@ -244,6 +244,7 @@ pub(crate) fn spec(cut: &str) -> PipelineDocument {
keeps_moves: vec![],
adds: vec![],
file_changes: vec![],
reads: vec![],
authority_map: None,
verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] },
estimate: delta(),

View file

@ -491,13 +491,15 @@ mod unsafe_installs {
error.downcast::<UnsafeStore>().expect("the refusal is the typed UnsafeStore error")
}
type Case = (&'static str, u32, Option<u32>, Option<u32>, &'static str);
#[test]
fn a_group_or_world_writable_directory_lock_or_store_is_refused_and_nothing_is_written() {
if !running_as_root() {
eprintln!("SKIPPED: needs root to own the fixtures");
return;
}
let cases: [(&str, u32, Option<u32>, Option<u32>, &str); 5] = [
let cases: [Case; 5] = [
("world-writable directory", 0o777, None, None, ""),
("group-writable directory", 0o775, None, None, ""),
("world-writable lock", 0o755, Some(0o666), None, "control.cc.lock"),

View file

@ -21,6 +21,21 @@ mind-control-store and run-kind had merged. New facts are F18-F20, which name th
owner of the brake stop. The Brake, Units and run rows changed, and a rationale
section was added.
Revised again on 2026-10-09 by Imagination (`imagination-launch-r3`, session
`self-2026-10-09-eureka`) at the mind-launch head `8dee875`, after Soul's verdict
`eureka-body:verdict:cut-mind-launch.s1` falsified that cut with nine findings. New fact
F21. The run, Repetition breaker, Brake and Units rows changed, and the rationale gained
"Who decides one live run, and who ends a dead one" and "What the breaker counts".
Specs: `cut-run-live-admission.r1`, `cut-mind-launch.r3`, `cut-mind-wake.r3`,
`cut-brake-stop.r2`.
Revised again on 2026-10-10 by Imagination (`imagination-mind-units`, session
`self-2026-10-10-eureka`) at Huginn `0c6f6cb` and gamecult-ops `6b4a874`, for cut
`mind-units-install`. New fact F22. New section "The mind units: identity, lifecycle and
authority", written before the cut was mapped. Specs: `cut-mind-unit-gate.r1` (Huginn) and
`cut-mind-units-install.r1` (gamecult-ops). Questions: `mind-unit-names` and
`mind-unit-start-grant`.
## Pinned heads (fetched 2026-10-08 ~12:10 UTC)
| Repo | Ref | Commit | Note |
@ -179,6 +194,69 @@ Each line: what was run, where, result.
was not acted on in time, and the process was killed at the host (prior-art file
lines 323-325, secondary). Cut mind-wake r2 has the waker return `Held` before any query,
admission or unit call, and lists "the waker ... ending a live run" among its forbidden writers.
- **F21 What the mind can attribute a document to, at the mind-launch head `8dee875`.** Read
from a throwaway worktree on 2026-10-09 (Imagination `imagination-launch-r3`).
- A receipt's provenance is `{ faculty, agent, session, tool }`, all declared by the writer.
No selection alias reads `session`, so attributing by session means scanning headers.
`open_and_launch` admits a run with session equal to its run label
(`crates/mind-body/src/launch.rs:205`). Nothing makes a Self run's later admissions carry
that label: the Self organ is not built.
- Citations are typed content. A `cites` selection with no role over every schema is
supported and tested (`crates/huginn-mind/src/query.rs:1499`). A run's header facts carry
`turn`, `operated_by` and `claims` (query.rs:139).
- The breaker at 8dee875 scans every header in the mind, newest first, back to the oldest
counted run (`crates/mind-body/src/queue.rs:210`). Its cost grows with the whole mind.
- `Mind::admit` takes `&mut self` (`crates/huginn-mind/src/admission.rs:81`), so
admissions are serialized. The run rule is `run_claims_rule` (admission.rs:537-547). It is
applied when a run opens (admission.rs:526-529) and when a withdrawal reinstates a closed
run (admission.rs:685-694). It checks claims only.
- `MindRefusal` is huginn-mind's (`crates/huginn-mind/src/refusal.rs`), published in
`schemas/cultnet/huginn.mind_response.v1.schema.json` and pinned by
`published_wire_schemas_match_derivation` (wire.rs:276). The leaf's `PipelineRefusal` has
five variants (`crates/eureka-pipeline/src/lib.rs:59-65`) and is not where admission's rules
refuse.
- `Effective::Released { cadence, run_cap_usd }` is a public struct variant
(`crates/mind-body/src/control.rs:117-120`), so any caller can build one without reading the
store. `ControlState::effective` is called only by `read_effective` (control.rs:192-197).
- huginn-mind's `fixtures::run` builds an in-force SelfRun with `operated_by: Mind`
(fixtures.rs:331-342). The claim tests open several of them at once
(admission.rs:1987-2015).
- `Slug::validate_slug` and `Label::validate_label` are public
(crates/eureka-pipeline/src/lib.rs:287, 386). `RunTurn` and `RunOperator` spell their
variants through `Debug`, which is also their `NAMES` (lib.rs:436-452).
- **F22 What the mind units would land on, 2026-10-10.** Read-only `ssh yggdrasil` as
`gamecultadmin` and an Eyes pass over Idunn `6667f0f` (Imagination `imagination-mind-units`).
- Still no unit, user or store of hers: `systemctl list-unit-files 'mind-*'` lists 0;
`getent passwd` has no `mind-*` user; `/etc/gamecult/minds` is absent; no `mind-control`
on PATH. `/opt/gamecult/huginn/current` is `releases/107552dc...` and holds only
`huginn-daemon` (0755 root). The Release step builds only `-p huginn-daemon`
(gamecult-ops `runbooks/huginn-yggdrasil.md:57`).
- systemd 257 (257.13-1~deb13u1): `ExecCondition=` (since 243) exists. Per
systemd.service(5), an ExecCondition exit of 1-254 skips the unit (inactive, not failed)
and 255 or a signal fails it; the `+` prefix runs that command with full privileges.
- **polkit is not installed:** `polkitd` "Installed: (none)", candidate 126-2;
`/etc/polkit-1/rules.d` absent (`/usr/share/polkit-1/actions` exists, from systemd).
Without polkitd, systemd refuses an unprivileged caller's `StartUnit`, so no user of hers
can start a unit today. `/etc/sudoers.d` holds only `90-gamecultadmin` and README.
- systemd cannot derive a per-instance user from a `<instance>:<label>` instance: no
specifier splits `%i`, and dash-truncated drop-ins truncate the unit prefix (the part
before `@`) and re-attach the whole instance (systemd v257 `src/shared/dropin.c`,
`unit_file_find_dirs`: `unit_name_to_prefix`, `strrchr(prefix, '-')`,
`unit_name_build_from_type(prefix, instance, ...)`). So `User=mind-%i` works only when
the instance is the mind's slug alone.
- `SystemdLauncher::alive` counts `active`, `activating` and `reloading` as alive
(mind-launch head `origin/eureka-body/mind-launch`, launch.rs:96-99), and starts with
`systemctl start --no-block`, calling `systemctl` directly (no sudo).
- Idunn has no target, binding or reference for huginn, mind-control or `mind-*`
(`/etc/gamecult/idunn/bindings/` holds ghostlight, heimdall, odin, raven-muninn and two
streampixels). Idunn supervises only workloads it admitted and launched itself as
transient `systemd-run` units (`Type=exec`, `Restart=no`, `DynamicUser=yes`; Idunn
`src/drivers.rs:3628-3650`); its continuity loop restarts an admitted workload that is not
running, up to 6 times an hour, unless its lifecycle brake is engaged
(`src/control_plane.rs` ~5880-6190, 7932-7956). It has no one-shot, timer or template
workload (`docs/watchdog-reporting-map.md` row C9) and installs no unit file, polkit rule,
sudoers file or system user (`src/drivers.rs:3620-3720`). A unit Idunn did not launch is
never observed or restarted by it.
## The model page (step 0b)
@ -189,9 +267,9 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`.
| --- | --- | --- | --- |
| Pipeline wire ids | `epiphany.pipeline.<kind>.v2`, epoch `epiphany.pipeline.epoch.v2`, defined by the leaf, the Huginn crate `crates/eureka-pipeline` (lib `eureka_pipeline`), ids unchanged. | Renamed once to `eureka.pipeline.<kind>.v3` by one `mind.redb` migration, at the next breaking leaf change or before the first hosted mind (ruling `wire-names-rename-before-hosting`). Adding the `run` kind or the `Mind` authority variant is additive (old stored documents decode; no migration), so it does not trigger the rename; a field added with the leaf's `= absent` marker is additive too (leaf-read-anchors r2: old bytes decode, empty fields encode byte-identically, `golden/envelopes.txt` gains lines and loses none); a field added any other way would. | Huginn owns the leaf. The migration is a live-mind upgrade, which is the operator's (`mind-rules-which-forks`). |
| Leaf crate and schema catalogue | Huginn `crates/eureka-pipeline`, `schemas/cultnet/` (13 pipeline schemas, `gamecult.persona_state.v0`, index, README). `epiphany.work_organ_state.v0` dies: no reader outside Epiphany (grep, F1/F3). | Landed at Huginn `afc35f9` (leaf-into-huginn). Wire pinned by `crates/eureka-pipeline/golden/envelopes.txt` and the opt-in `stored_documents_read_back`. Huginn is AGPL-3.0-only (ruling `eureka-body:ruling:leaf-relicensed-agpl`). | Huginn. Forbidden: any Cargo source resolving into GameCult/Epiphany (`nothing-runs-from-epiphany`). |
| `run` | `<instance>:run:<label>` (cut-run-kind r2). Label `mind-<YYYYMMDD>T<HHMMSS><mmm>Z` from mind-launch's `run_label`; two launches in one millisecond produce the same key with different content, which admission answers `Conflict`, so the name stays injective. Order is admission ordinal (F15), not label. | Each wake opens a PersonaTurn run with no claims (ruling `eureka-substrate:ruling:every-wake-a-run`); the Persona tool opens SelfRun runs with claims; both go through `open_and_launch`, and the unit starts only after the run commits (the run is the grant). The Self in a unit admits under its label and ends it `Recorded { reason }`. The waker withdraws a run whose unit is not alive past a 120 s grace (replaces the 24 h rule). Cadence comes from the newest PersonaTurn run's `admitted_at`, found through the `turn` and `operated_by` aliases, not `started_on` (a date cannot carry seconds). | Opening: `open_and_launch`, called by the waker for PersonaTurn and by the Persona tool for SelfRun. Ending: the Self that worked it; the waker only withdraws dead ones. Concurrency of live runs: at most one in-force SelfRun of hers per instance; `open_and_launch` answers `Busy` to a second (ruling `one-live-self-run`, cut mind-launch r2). A unit stopped by a brake hold leaves its run in force until the waker's first sweep after release. Forbidden: Hands, Soul, Imagination, Life. |
| Repetition breaker | No state of its own (rulings `self-waker`, `merge-gate-own-soul`). Trip condition derived from the item's ended SelfRun runs: last 3 empty, or 10 of the last 50. A run is empty when no non-run document citing the item has an ordinal in its window (its admission up to the next run's, or now). | Evaluated in `open_and_launch` when a Self run claims the item, not by the waker. When tripped, it admits one `question` labelled `breaker-<item local>` under the item's campaign, `raised_in` the item, and launches nothing; the existing blocked-spec recipe then removes the item from the queue. Answered by a ruling or withdrawn; either reopens the item. | `open_and_launch` admits the question; any Self may answer it (substrate-internal, `mind-rules-which-forks`). |
| Brake | One document per instance in a root-owned CultCache store `/etc/gamecult/minds/<instance>/control.cc`, type `eureka.control.brake.v1 { released: bool, set_at, set_by }`. | Replaced whole by the operator's CLI; never appended. Absent or undecodable reads as held. `mind-control brake hold` writes it held and then, in the same command, stops her running units: `systemctl stop` (blocking) over `mind-self@<i>:*` and `mind-persona@<i>:*`, through `SystemdLauncher::stop_all` (cut brake-stop; ruling `self-run-actuator`; F18-F20). | Operator only (ruling `brake-dial-root-store-now`); her units read it, and the file is not writable by any user she runs as. The hold is the one stopper of her units. |
| `run` | `<instance>:run:<label>` (cut-run-kind r2). Label `mind-<YYYYMMDD>T<HHMMSS><mmm>Z` from mind-launch's `run_label`; two launches in one millisecond produce the same key with different content, which admission answers `Conflict`, so the name stays injective. Order is admission ordinal (F15), not label. | Each wake opens a PersonaTurn run with no claims (ruling `eureka-substrate:ruling:every-wake-a-run`); the Persona tool opens SelfRun runs with claims; both go through `open_and_launch`, and the unit starts only after the run commits (the run is the grant). The Self in a unit admits under its label and ends it `Recorded { reason }`. A run whose unit is not alive past a 120 s start grace is closed `Recorded { reason: "unit not active" }` by the next `open_and_launch` of the same turn, before it checks anything else. It is Recorded, not Withdrawn, so a unit that keeps dying counts as an empty ended run and trips the breaker. Only a unit whose start failed in the launch itself is Withdrawn. Cadence comes from the newest PersonaTurn run's `admitted_at` (any status), not `started_on` (a date cannot carry seconds). | Opening: `open_and_launch`, called by the waker for PersonaTurn and by the Persona tool for SelfRun, each with a `Grant` that only `read_effective` constructs. One live run of hers per turn and instance: huginn-mind admission refuses a second in-force run with `operated_by: Mind` of the same turn and instance as `AlreadyLive { run }` (ruling `one-live-self-run`, option `one-live-per-turn`; cut run-live-admission). `open_and_launch` answers `Busy` from that refusal, or earlier, when the holder it has just read is alive. Operator runs never count. `operated_by` is declared by the writer (follow-up `eureka-substrate:follow_up:operator-authority-needs-operator-credential`). Ending: the Self that worked it; a dead one is closed by the next launch of its turn (cut mind-launch r3). No sweep exists elsewhere. Forbidden: Hands, Soul, Imagination, Life. |
| Repetition breaker | No state of its own (rulings `self-waker`, `merge-gate-own-soul`). Trip condition derived from one selection: the documents citing the item, in any role. Counted runs are the item's ended SelfRun runs of hers (`operated_by: Mind`, not withdrawn) admitted after the newest question `raised_in` the item. Trip when the last 3 counted runs are empty, or 10 of the last 50. A run is empty when no citing document other than a run has an ordinal in its window (its admission up to the next run's, or now). Documents that cite something else never count, wherever they were admitted (F21). | Evaluated in `open_and_launch` when a Self run claims the item, not by the waker. When tripped, it admits one `question` labelled `breaker-<run label>` under the item's campaign, `raised_in` the item, and launches nothing. The run label is the label the refused launch would have used, so every trip has its own key. The existing blocked-spec recipe then removes the item from the queue. Answered by a ruling or withdrawn: either reopens the item, and that question is the boundary, so a reopened item starts a fresh count. | `open_and_launch` admits the question; any Self may answer it (substrate-internal, `mind-rules-which-forks`). |
| Brake | One document per instance in a root-owned CultCache store `/etc/gamecult/minds/<instance>/control.cc`, type `eureka.control.brake.v1 { released: bool, set_at, set_by }`. | Replaced whole by the operator's CLI; never appended. Absent or undecodable reads as held. `mind-control brake hold` writes it held and then, in the same command, stops her running units: `systemctl stop` (blocking) over `mind-self@<i>:*` and `mind-persona@<i>:*`, through `SystemdLauncher::stop_all` (cut brake-stop; ruling `self-run-actuator`; F18-F20). | Operator only (ruling `brake-dial-root-store-now`); her units read it, and the file is not writable by any user she runs as. The hold is the one stopper of her units. `read_effective` is the one reader. Its `Released` answer carries a `Grant` (cadence and run cap, private fields, not `Clone`), which `open_and_launch` takes by value, so no run opens without a released reading (cut mind-launch r3). |
| Burn-rate dial | Same store, `eureka.control.burn_rate.v1 { heat, base_cooldown_s, base_run_usd, set_at, set_by }`; `heat` in 0.05..=2.0. | Replaced whole by the CLI. Absent or out of bounds reads as heat 0, which launches nothing (fail closed, LiteLLM scar rider of ruling `connector-owner`). Schedules and her own rest arrive later as follow-up `burn-schedule-and-rest`. | Operator. Derived (never stored): wake cadence `base_cooldown_s / heat`, per-run cap `--max-budget-usd = base_run_usd × heat`, per-request `max_cost_usd`. |
| Connector request and transcript items | Request: `(caller_runtime_id, request_id)`, the replay key the daemon already keeps (`replay.cc`). Items: UserText, AssistantText, ToolCall, ToolResult, plus `ReasoningState { provider, model, opaque }` (ruling `connector-opaque-state-item`). Contract ids become provider-neutral `gamecult.model.*.v3` (follow-up `connector-contract-untyped`); the connector serves Ghostlight too, so not `eureka.*`. | Invocation expires at `expires_at_unix_ms`; replay record per key; restart-era `Indeterminate`. The caller stores the transcript and replays it verbatim; foreign-tagged reasoning state inside an in-flight tool exchange is refused typed; completed-turn foreign state is settled per provider in the connector cut (follow-up `connector-reasoning-state-cheap-providers`). | The connector produces, reads or refuses reasoning bytes; callers never branch on provider. |
| Connector spend ledger | `gamecult.model.caller_spend.v1` keyed by caller runtime id and window start, in each daemon's state root. | Rolled per window; refusal `Budget` at the cap; durable, and a missing ledger refuses the request. | Each daemon writes its own; the root caller config sets the cap. |
@ -199,11 +277,35 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`.
| Persona state | `gamecult.persona_state.v0`, one per mind, `personaId` = instance (cut-huginn-persona-state r2, eureka-substrate). | Replaced whole under CAS with a receipt; she may rewrite any field (`identity-write-all-visible`). | The Persona organ is the only writer. |
| Identity diffs | The pair of receipts of two consecutive Persona puts whose identity fields (publicName, publicDescription, presentation, values) differ. Derived, not stored. | Composed by the Persona organ at put time and sent as one delivery request through the crossing; a failed post is retried by Bifrost's delivery journal, not by her. | Persona organ composes; Bifrost posts under the permit. No rate limit. |
| Discord body surfaces | Outbound: crossing ids `epiphany.persona_discord_delivery_request.v0` and `..._permit.v0`, agent `epiphany.Persona`, runtime `epiphany-starfire` (F6), renamed to `eureka.*` and `eureka-yggdrasil` in the mouth rebinding (`mouth-crossing-on-yggdrasil`). Inbound: Bifrost persona-feedback deliveries for target `epiphany` (F6). Persona-session tools reach both (`body-includes-discord`). | Request, permit, post, receipt (Bifrost journal). Inbound deliveries: **unprobed** who records that a delivery was answered; `run-is-the-grant` forbids her organ from holding that fact. Blocks the Persona cuts only, not the cuts admitted today. | Bifrost owns posting and delivery records; her permit issuer grants while the brake is released. |
| Units and instance templates | `mind-wake@<instance>.timer/.service`, `mind-self@<instance>:<label>.service`, `mind-persona@<instance>:<label>.service` (one-shot per Persona turn; `RuntimeMaxSec` set at install bounds a hung unit), `mind-permit@<instance>`; users `mind-<instance>` (organs) and `mind-<instance>-self` (Self runs); state `/var/lib/gamecult/minds/<instance>/`; control `/etc/gamecult/minds/<instance>/`. Instance `eureka`. | Hand-installed by gamecult-ops now; Idunn targets declared before the first hosted member, which deletes the hand install (`mind-units-templated-interim`). | gamecult-ops installs; Idunn later. Starting: `open_and_launch` through `SystemdLauncher::start`, as her users, who are granted start only. Stopping: only the operator's `brake hold`, as root (cut brake-stop). The unit-level brake gate and the start-only grant are follow-up `mind-units-brake-gate`. None of this exists on Yggdrasil yet (F19). |
| Units and instance templates | `mind-wake@<instance>.timer/.service`, `mind-self@<instance>:<label>.service`, `mind-persona@<instance>:<label>.service` (one-shot per Persona turn; `RuntimeMaxSec` set at install bounds a hung unit), `mind-permit@<instance>`; users `mind-<instance>` (organs) and `mind-<instance>-self` (Self runs); state `/var/lib/gamecult/minds/<instance>/`; control `/etc/gamecult/minds/<instance>/`. Instance `eureka`. | Hand-installed by gamecult-ops now; Idunn targets declared before the first hosted member, which deletes the hand install (`mind-units-templated-interim`). | gamecult-ops installs; Idunn later. Starting: `open_and_launch` through `SystemdLauncher::start`, as her users, who are granted start only. The instance in a unit name is always the mind's (`MindPort::instance`), passed to every `Launcher` call; `SystemdLauncher` holds none. The instance and label are validated by the leaf's grammar before any `systemctl` argv is built. Stopping: only the operator's `brake hold`, as root (cut brake-stop). The unit-level brake gate and the start-only grant are follow-up `mind-units-brake-gate`. None of this exists on Yggdrasil yet (F19, F22). Each piece's identity, lifecycle and authority: the section "The mind units" below (cut mind-units-install; unit names pending question `mind-unit-names`). |
| Self-run credential | `CLAUDE_CODE_OAUTH_TOKEN` in a root-0600 EnvironmentFile per instance. | Created by `claude setup-token`; rotated by the operator. | **Open: question `self-run-token-reach`.** The Self run's `claude` process can read its own environment, so `token-out-of-her-reach` holds only if the operator names this unit (F4 of the prior-art file, follow-up `token-reach-two-units`). |
| Wake frame | Fixed prompt frame (provenance, jurisdiction, brake obedience) in `/etc/gamecult/minds/<instance>/wake-frame.md`, root-owned. Composed by the Persona organ from host config; the waker passes the unit only the instance and the run label. | Changed only by an install. | Host config (ruling `self-waker` rider 2); never a file her runs merge into. |
| Epiphany residue | F5, F8, F11, as they stood before the teardown. | Teardown landed (gamecult-ops `9c12adb`); what it did not name is follow-up `eureka-body:follow_up:epiphany-body-leftovers`; the repo is archived last. | gamecult-ops for host and repo tooling; Idunn for its legacy target list (follow-up). |
## The mind units: identity, lifecycle and authority
Written 2026-10-10 before cut `mind-units-install` was mapped (operator direction: settle
ownership first). `<i>` is the mind's slug (`eureka` now). Unit names follow the
recommended option of question `mind-unit-names` (`instance-only`): the instance is the slug
alone, one unit per instance and turn, because admission already allows only one live run of
hers per turn (`one-live-self-run`) and only then can `User=` name a per-instance user (F22).
"Root" means the operator through `sudo`, or an agent under her deploy instruction.
| Thing | Identity | Lifecycle | Who may start, stop, enable or change it |
| --- | --- | --- | --- |
| Waker template | `/etc/systemd/system/mind-wake@.service` and `mind-wake@.timer`, sources `systemd/` in gamecult-ops. Instance unit `mind-wake@<i>.service`, `User=mind-%i`. | Installed and replaced by `scripts/install-mind-units-yggdrasil.sh`. The timer is a fixed poll (1 min after the last cycle ends); it decides nothing: the cadence is the dial's, read by the waker from the newest Persona run (mind-wake r3). Schedules and rest (follow-up `burn-schedule-and-rest`) belong in the dial, so they need no unit change. Each tick runs one cycle, `Type=oneshot`, `TimeoutStartSec=5min`, `Restart=no`. Not gated at the unit: the waker reads the brake itself and returns `Held` before any consequence, and a second reading would protect nothing. | Install, change: root via the script. Enable or disable `mind-wake@<i>.timer`: root only, by hand in the runbook. The script never enables it, so installing starts nothing. Start the service: the timer (systemd as root). Her users have no grant on `mind-wake@`. |
| Persona template | `/etc/systemd/system/mind-persona@.service`. Instance unit `mind-persona@<i>.service`, `User=mind-%i`, `Type=exec`, no `[Install]`, so it cannot be enabled. | Inactive until started for one Persona turn. Ends by its own exit, at `RuntimeMaxSec=30min`, or by the brake hold's stop (`TimeoutStopSec=30s`, then SIGKILL to the whole cgroup). `Restart=no`, and nothing else restarts it. Every start first runs the gate (below); while the brake is not Released the unit is skipped and ends inactive. `ExecStart` names the Persona organ's binary in the Huginn release; that cut ships the binary and owns the argv. Until then a released start fails at exec (203/EXEC), and the closing launch counts it as a dead run. | Start: `mind-<i>` only, through `open_and_launch` (the waker), by polkit's verb `start` on this one unit name. Stop, kill, restart: root only, which in practice means `mind-control brake hold` (cut brake-stop). Change: root via the script. Enable: nobody. |
| Self template | `/etc/systemd/system/mind-self@.service`. Instance unit `mind-self@<i>.service`, `User=mind-%i-self`, `Type=exec`, no `[Install]`. | As the Persona unit, with `RuntimeMaxSec=4h`. `ExecStart` names the Self-run actuator's binary in the Huginn release; that cut adds the token `EnvironmentFile` (ruling `self-run-token-reach`), the state directory and the argv. | Start: `mind-<i>` only (the Persona's start-Self-run tool through `open_and_launch`), by polkit, verb `start`. Stop, kill, restart: root only. Change: root via the script. Enable: nobody. |
| Organ user | `mind-<i>`: system user and group, shell `/usr/sbin/nologin`, home `/nonexistent`. | Created by the install script if absent and never modified by it after that. Deleted by root at the mind's retirement. Runs the waker and Persona units. | Root creates and deletes it. It may start `mind-persona@<i>` and `mind-self@<i>`, and nothing else. It cannot write the control store (root 0644, dir 0755) or stop a unit. It can signal its own processes with `kill(2)`, as any Unix user can. That only ends spend, and the ended run is closed Recorded by the next launch, so it adds no second stopper of her units. |
| Self user | `mind-<i>-self`: system user and group, nologin, home `/nonexistent`. The install script refuses an instance slug ending in `-self`, so this name never collides with another mind's organ user. | As the organ user. | Root creates and deletes it. It starts no unit. Polkit refuses it every verb, and the rule refuses any subject whose name ends in `-self`. |
| `mind-control` | `/opt/gamecult/huginn/releases/<sha>/mind-control`, root:root 0700, reached by the `current` symlink; `/usr/local/sbin/mind-control` links to `/opt/gamecult/huginn/current/mind-control`. | Built and installed with each Huginn release by the Release step of `runbooks/huginn-yggdrasil.md`. Replaced when `current` flips. The previous release is kept until the new one verifies. | Install, replace: root via the Release step. Run: root only (mode 0700), which covers the operator's verbs and the gate, which systemd runs as root through the `+` prefix. No user of hers can execute it. |
| `mind-wake` | `/opt/gamecult/huginn/releases/<sha>/mind-wake`, root:root 0755. | Same release step and lifecycle as `mind-control`. | Install, replace: root. Run: the waker unit as `mind-<i>`. |
| The gate | `ExecCondition=+/opt/gamecult/huginn/current/mind-control --instance %i check` in both spending templates. The verb is cut mind-unit-gate. | Runs at every start of a Persona or Self unit. It exits 0 only when `read_effective` is `Released`; held, absent, undecodable or out of bounds exit 2 (systemd skips on 1-254). It holds no state and writes nothing. | Owner: `read_effective`, the one derivation. Change: a Huginn release (the verb) or the script (the line). It closes the window in which a launch that read Released before a hold starts its unit after the hold's stop (follow-up `mind-units-brake-gate`). |
| Polkit rule | `/etc/polkit-1/rules.d/60-mind-units.rules`, root 0644, source `polkit/60-mind-units.rules` in gamecult-ops. Needs package `polkitd` (absent today, F22; question `mind-unit-start-grant`). | Installed and replaced by the script. Instance-generic: one file serves every mind. | Root changes it. It grants exactly one thing: action `org.freedesktop.systemd1.manage-units`, verb `start`, unit `mind-persona@<i>.service` or `mind-self@<i>.service`, to subject `mind-<i>`. Every other request falls through to systemd's default, `auth_admin`, which an unattended caller cannot satisfy. |
| Control store | `/etc/gamecult/minds/<i>/control.cc`, root:root 0644 in a 0755 dir (cut mind-control-store). | The install script writes `brake hold` once, if no store exists, so a fresh install reads `held` with a name and time rather than `absent`. Afterwards only the operator's verbs change it. | Operator only (`brake-dial-root-store-now`). |
| A unit on a brake hold | The instance unit above. | `brake hold` writes the brake held, then stops both units of the instance and waits; the unit ends inactive, its run stays in force. A start after the hold is skipped by the gate and ends inactive (dead), not failed. The next `open_and_launch` of that turn after a release closes the run Recorded "unit not active" (mind-launch r3 step 0). Nothing restarts a stopped unit: `Restart=no`, the waker does not launch without a Grant, and Idunn does not know the unit. | Stopper: the hold only. Restarter: none. |
| Idunn's view | No binding, no target. | Idunn observes and restarts only workloads it launched itself, and every Idunn workload is a resident service that continuity restarts. It has no one-shot, timer or template kind (F22). So today it never touches these units. The deletion line of the hand install (ruling `mind-units-templated-interim`: Idunn targets before the first hosted member) needs an Idunn job workload that is never continuity-restarted. That is follow-up `idunn-job-workload`. | Idunn: none until that follow-up lands. Its owner then may install and change the templates and users, and must never start or restart a spending unit. |
## Rationale
### Why the order is leaf, teardown, control, wake
@ -254,16 +356,78 @@ over the instance's `mind-self@` and `mind-persona@` units (cut brake-stop).
cannot reach it. The stop patterns come from the same templates as start, so the units
stopped are exactly the ones started.
- **Not a mind client.** A Self run killed by a hold cannot record its own end. Its run stays in
force until the waker's sweep after release withdraws it, so the operator's CLI never talks
to the mind.
- **One race the CLI cannot close.** A launch can read `Released`, then the hold writes and stops,
then the launch starts its unit. The unit's own `ExecCondition=` on the brake closes that, so
force until the next launch of its turn after release closes it as `Recorded` "unit not
active" (mind-launch r3). The operator's CLI never talks to the mind.
- **One race the CLI cannot close.** A launch can read `Released` (its `Grant`), then the hold
writes and stops, then the launch starts its unit. The unit's own `ExecCondition=` on the brake closes that, so
the gate belongs with the units install (follow-up `mind-units-brake-gate`), not in a second
stopper.
- **Idunn.** Her Self and Persona units are one-shot jobs. Idunn's continuity restarts installed
daemons, so stopping one of these jobs is not a continuity decision. When Idunn targets
replace the hand install, Idunn must not restart a unit the hold stopped.
### Who decides one live run, and who ends a dead one
Soul's verdict `cut-mind-launch.s1` found the one-live rule decided in two places, neither
of them sound. `open_and_launch` read for a live run and then admitted later, so two
interleaved launches both passed. The MCP admit door checked nothing, so a second live run
committed directly. Mind-launch r2 had accepted the race, on the grounds that only a Persona
turn starts Self runs. Neither the waker nor the Persona tool exists, so nothing enforced that.
- **Admission owns the rule.** Batches are serialized (`&mut self`, F21), and admission already
owns the neighbouring exclusivity, `AlreadyClaimed`. The rule is "at most one in-force run
with `operated_by: Mind` per instance and turn". That is the ruled option's own text: "one
run cap in flight per turn kind", with Persona turns one at a time in every option. So the
waker's own PersonaTurn Busy check goes too. The refusal is a new `MindRefusal` variant,
`AlreadyLive { run }`. `PipelineRefusal` is the leaf's document-shape vocabulary and stays at
five variants (F21).
- **The primitive owns liveness.** Admission cannot see units. A run whose unit died, never
started, or lost its admit answer would otherwise hold the turn's slot forever. Before it
checks anything else, `open_and_launch` reads the turn's live holders of hers. It closes each
one whose unit is not alive and whose admission is older than the 120 s start grace, and
answers `Busy` for any that remain. That early `Busy` only refuses, so it cannot make a
second live run; admission stays the authority for races and for direct admits. The waker's
sweep in mind-wake r2 was a second owner of the same judgement, and r3 deletes it.
- **Recorded, not Withdrawn.** The breaker treats a withdrawn run as one that never ran. A Self
unit that crashes every time would then never trip, and would loop at the dial's cadence.
Closing a dead run as `Recorded` makes a crash an empty ended run, so three in a row raise a
question. Prior art does the same: a Kubernetes Job counts a failed pod against
`backoffLimit`, and systemd's `StartLimitBurst` counts failed starts. A start that fails
inside the launch, before any unit ran, is still Withdrawn.
- **Lease prior art, and why there is no lease.** A Kubernetes Lease or an etcd lease makes
liveness a renewal the holder must keep sending. Here the host's unit table already is that
signal (`systemctl is-active`), so a renewal would be a second owner of the fact
(`run-is-the-grant`). The cost is that a dead run is only noticed when the slot is next
wanted. That is also the only moment its being in force matters, apart from the queue's
claimed-by view. That view is wrong for at most one launch attempt, because the next Self
launch closes the dead holder first.
- **Writer-declared `operated_by`.** A writer that declares `Operator` escapes the rule. This is
the gap already owned by follow-up `operator-authority-needs-operator-credential`, which must
land before autonomous Self runs. No second guard is added here.
### What the breaker counts
The ruling's rider says a run is empty when it "admitted nothing but their own run". The
built breaker counted every document admitted anywhere in the mind during a run's window.
Under `every-wake-a-run` a PersonaTurn run always lands in that window, so the breaker could
never trip (finding `breaker-counts-every-document`). Two attributions are available (F21):
- **By receipt session.** It is declared by the writer. Nothing makes a Self run's subagents use
the run label yet. No alias reads it, so the whole-mind scan stays.
- **By citation of the item (chosen).** Citations are typed content, and one role-less `cites`
selection on the item returns the runs that claim it, the questions raised in it, and every
report, resolution, follow-up or deferral that names it. That replaces the whole-mind scan
with a bounded one. A productive run on a cut spec admits a report, a question, a superseding
revision or a follow-up. Each of those cites the item, and most also take the item out of the
queue. Work a Self run does that names another item is not progress on this one.
- **The reset is a question, not a ruling.** A ruling that answers the breaker question cites the
question, not the item. Counting only runs admitted after the newest question raised in the
item makes the trip itself the boundary. A reopened item then starts a fresh count, and the
10-of-50 rule cannot trip again after one more run. Every trip gets its own key,
`breaker-<run label>`, so a second trip raises a second question.
- **Counted runs are hers.** Only `operated_by: Mind` runs count, because the breaker guards her
loop (`merge-gate-own-soul`), not the operator's sessions.
### Why the kit keeps its Codex backend
Idunn's migration doc says Codex is dead (F9). The kit is lifted out of CodexConnector,

View file

@ -262,3 +262,122 @@ 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.
## huginn CLI replaces the eureka-state MCP server (Imagination, 2026-10-10)
Follow-up `eureka-substrate:follow_up:huginn-cli-replaces-mcp`. Bases: Huginn
180b08a, Eureka 278b881, gamecult-ops 1e95674. Live daemon 107552dc.
### Body facts (probes, 2026-10-10, Starfire)
- `~/.claude.json` top-level `mcpServers.eureka-state`: stdio,
`C:\Users\Meta\.eureka\bin\eureka-state.exe`, env `EUREKA_INSTANCE=eureka`,
`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. |
### Cut order
huginn-cli (after clippy-pin) -> skill-huginn-cli, context-pack-huginn-cli,
ops-huginn-cli (in parallel) -> delete-eureka-state-mcp.
The deletion comes last, and it is the only cut that removes a door. Sessions
in flight hold MCP server processes, and both doors reach one daemon, so the
order lets every consumer move before the old door goes.
Life's no-admit rule loses its tool-list guard, because Life keeps Bash. Its
`mcp__eureka-state__*` read-only tool list was already soft: Bash could drive
the stdio server, as context-pack.py does. That fork is question
`life-mind-write-guard`.
Rejected: renaming the crate to `huginn-client`, for the churn above. A whoami
version field, for the reasons in the skew rule row. A config file for the
endpoint, because env already serves both hosts. TOML or YAML as the input
door: see the question.
## A campaign's repo set changes by target revision (Imagination, 2026-10-10)
Felt gap: `eureka-body:follow_up:body-campaign-repos`. eureka-body must change
GameCult/Idunn and the connector kit repo (ruling
`eureka-body:ruling:connector-kit-home`, not yet created), but `repos` lives on
`<c>:campaign:self`, a fixed key with no revision and no resolution, and
admission refuses a cut_spec whose repo is outside it (`admission.rs:405-415`,
`RepoNotInCampaign`). The only workarounds were a sibling campaign (done once:
`eureka-substrate:follow_up:epiphany-cuts-need-a-campaign`, closed by opening
eureka-body) or a cross-campaign `first` line. Pinned at Huginn a7cc2ff.
### Model page: the repo set (step 0b)
| Question | Answer | Why |
|---|---|---|
| What names it | `target.repos: Vec<OrgRepo>[16] = absent` on `<c>:target:r<n>`. Empty means "no target in this chain has named repos": the campaign's own `repos` stands. Non-empty means this revision's list is the whole set. | Reuses the one revisable campaign-scoped record. The target already carries scope (`not_in_scope`, `canonical_implementations`); which repos a campaign may cut is scope. No new kind, key grammar, alias or refusal variant. |
| Who owns it | The in-force target when it names repos; otherwise the campaign document. `campaign.repos` is demoted to the opening set: it decides only until the first target revision that names repos. | One owner at any moment, derived in one function (`Docs::repos_in_force`), read by the one rule that consumes it. |
| Add or remove | Self admits target `r<n+1>` with the new whole list and the `Superseded` resolution of `r<n>`, in one batch (the existing revision rule). The target doc cited by `doc` says why. | Whole-list replacement, as Kubernetes replaces a whole spec per generation; no per-member add/remove events to fold. |
| Silent revert | A target revision with empty `repos` whose predecessor named repos is refused `EmptyRepos { campaign: <target id> }`. | Otherwise omitting the field on an unrelated invariant edit would revert to the opening set. Reuses the existing variant: an empty repo set where one is required. |
| Bound | 16 on the target; the campaign's 8 is unchanged. | eureka-body reaches 8 with the two repos it needs. 16 matches `canonical_implementations`. The campaign bound need not widen, since later sets live on targets. |
| Specs on a removed repo | Membership is checked when a cut_spec (new or a revision) is admitted, against the set in force then. A spec already in force stays in force; its reports admit (they check the spec, not the campaign); it can be Withdrawn or Superseded as before. A new revision on the removed repo is refused `RepoNotInCampaign`. | Admission rules apply to new batches only (target invariant `stored-documents-valid`). Refusing the removal while specs name the repo would need a landed/open distinction the mind does not have (`gap-landing-untyped`), and would force withdrawing landed specs, which misstates them. Question `campaign-repo-removal`. |
| Rename or transfer | A new `Org/Repo` spelling is a new repo: Self revises the target replacing the name. Old specs keep the old name as history; a spec revision may name the new repo (the revision rule does not compare `repo`). Case changes are no-ops (`OrgRepo` equality is by identity). The host (forge or GitHub, `body-repos-on-forge`) is not in `OrgRepo` and stays out of scope. | |
| Who decides | Self admits targets. Admission checks the revision rule, distinctness by identity (the campaign's hook, shared), the bound, and the revert rule. It checks no stewardship (ruling `stewardship-rule`). | |
| Epoch | Stays `epiphany.pipeline.epoch.v2`. The field is `= absent`: stored targets decode with it empty and re-encode byte for byte; one golden line is appended. | Precedent: cut leaf-read-anchors (`reads`, `symbol`). An old daemon reading a target that names repos drops them (serde ignores unknown keys), so after a repo revision is admitted a rollback narrows the set to the opening one; it does not fail to read. This lands with the upgrade rerun waiting on `snapshot-test-stale-and-quadratic`, daemon-first (`huginn-upgrade-daemon-first`). |
### Authority map
- Owner: Huginn admission (`check`, CutSpec arm) through `Docs::repos_in_force`.
- Inputs: the campaign document; the campaign's targets and their resolutions (image and batch).
- Outputs: `RepoNotInCampaign` or acceptance for a cut_spec; `EmptyRepos` for a reverting target.
- Derived state: `campaign.repos` after a repo-naming target is display-only history (the campaign header's `repos` fact and its `repo` alias answer "opened with"). The skill's recipe reads the set in force from the target.
- Forbidden writers: any second membership check (a `campaign.repos.contains` outside `repos_in_force`); editing or re-admitting `campaign:self`.
- Shared paths: MCP admit, the CultNet wire and the stored payload decode `repos` through the same derived impls.
- Deletion line: the direct `campaign.repos.contains(&spec.repo)` at `admission.rs:413`.
### Rejected
- A per-repo record kind (`<c>:campaign_repo:<repo>.n<seq>`, Withdrawn to remove), the stewardship pattern: a new kind, schema, key grammar, alias and refusals, and its key would inherit the opaque repo encoding of `gap-stewardship-id-opaque`.
- A campaign revision (`<c>:campaign:r<n>`): re-keys every stored campaign or adds a second key grammar for one kind, and every `source` citing `<c>:campaign:self` would have to follow it.
- Status quo (sibling campaigns): splits one target's work across two targets and two invariant sets.
### Cut order
campaign-repos (Huginn; independent of clippy-pin; textual overlaps with
mind-launch in admission.rs, docs.rs and the response schema, so whichever
merges second rebases and regenerates the schema) -> skill-campaign-repos
(Eureka campaign-state.md). huginn-cli's campaigns recipe, if it prints repos,
prints the opening set until it reads the target too.

View file

@ -0,0 +1,470 @@
# Prose knowledge: target
Serves `eureka-body:follow_up:prose-knowledge-inventory` under operator ruling
`eureka-body:ruling:prose-knowledge-into-mind` (2026-10-10). The operator's
words are in the ruling; read them there. Written 2026-10-10 by Imagination
(`imagination-prose-knowledge`, session `self-2026-10-10-eureka`).
Inputs, read this pass:
- Census: `gamecult-ops/docs/research/prose-knowledge-census-2026-10-10.md`
(82 of 2,872 in-scope files opened; cited below as "census §n").
- Prior art: `gamecult-ops/docs/research/agent-knowledge-store-prior-art.md`
(cited as "PA §n" or "PA failure n"; its [V] and [M] tags are carried, and
every [M] claim this target relies on is flagged where it is used).
- The mind's kinds: `~/.claude/skills/eureka/references/campaign-state.md`.
This is a target and a backlog. It is not a migration plan and changes no code.
## 1. Pre-implementation statement
- **Objective.** Rulings, questions, owed work and ideas that live in prose
become findable by any agent of the collective mind, and nothing that decides
a campaign lives in two places.
- **Current mechanism.** The mind holds 13 kinds of typed pipeline documents
(3,344 documents at `whoami` this pass). Everything else is `.md` files in
three places with different reach: committed repo docs, which voidbot indexes
(§4); loose files at `F:\Projects\` root and in worktrees, which nothing
indexes (census §3.1); and Claude Code memory directories, which load only
into sessions whose working directory matches (census §1).
- **Invariants.**
- `one-home`: a ruling, question, owed item or fact lives in exactly one
place. Every other place carries a pointer (a mind id or a path and line),
never a restatement.
- `operator-words-typed`: an operator ruling that steers work is a `ruling`
document carrying the operator's words. Self admits it; no other faculty
admits rulings.
- `prose-is-retrievable`: prose that stays prose is reachable by one search
path that returns the repo, path and line range.
- `no-bulk-extraction`: no unattended extraction pass writes into the mind.
Each item is read, checked against the mind and admitted by a faculty.
- **Intended change.** Ownership becomes simpler: typed kinds own decisions and
owed work; prose owns explanation. Root files and memory files stop being
owners.
- **Cut line.** Root `F:\Projects\*.md` files as a place where knowledge lives;
handoff next-action lists as a store of owed work; memory files as a store of
operator rulings.
- **Subtraction budget.** No new index, store, service or daemon. One new kind
only, the one already owed (`observation`, §2.6). Retrieval reuses voidbot's
existing index; coverage is bought by moving files into indexed repos, not by
indexing more places.
## 2. The model page
One row per class the census found. "Kind" names an existing pipeline kind, a
kind earned under the doctrine's bar (owner, live consumer, invariant, liability
replaced), or "prose, retrievable".
| Class | Identity | Lifecycle | Authority | Home |
|---|---|---|---|---|
| Operator rulings and decisions | `<campaign>:ruling:<label>` | admitted once; superseded, never edited | Self admits; the operator's words in `operator_quote` | `ruling` (exists) |
| Open questions | `<campaign>:question:<label>` | open until a ruling answers it or it is withdrawn | Imagination raises; operator rules | `question` (exists) |
| Follow-ups, TODOs, owed work | `<campaign>:follow_up:<label>` | open until Fixed, Superseded or Withdrawn | Imagination or Self admits; the closer resolves | `follow_up` (exists) |
| Plans and roadmaps | campaign slug | a campaign's target and its cut specs | Self opens; Imagination maps | `campaign`, `target`, `cut_spec` (exist); repo roadmaps without a campaign stay prose |
| Ideas and design sketches | path | edited freely | the repo | prose, retrievable; a `follow_up` points at it once someone directs the work |
| Body facts and probe results | `<campaign>:observation:<label>` (proposed) | admitted with an as-of; superseded by a newer observation | Eyes admits | `observation`, earned (§2.6) |
| Rationale and history | path | postmortems and maps are history once written | the repo | prose, retrievable |
| Reference and how-to | path | maintained with the code it describes | the repo | prose, retrievable |
| Handoffs | path at `F:\Projects\` | one per Self rotation, superseded by the next | Self | a pointer to queries (§2.9); not a kind |
| Memory files | path under `~/.claude/projects/*/memory/` | edited by Life | Life | split four ways (§2.10) |
### 2.1 Operator rulings and decisions
`ruling` fits, including operator directions that answer no question
(`answers: null`, `authority: Operator`). Prose holds rulings in three shapes:
1. **Prose campaigns that finished in prose.** On 2026-09-30 the operator ruled
that campaigns then in flight finish in their prose maps ("how are we going
to move to Huginn mid-campaign? Methinks we don't.",
`~/.claude/projects/F--Projects/memory/eureka-typed-vs-prose-campaigns.md:11-12`).
Their rulings stay in the cut maps (census §4.1 lists the ten largest:
Ghostlight play-agent 65 mentions, CultNet selection 42, fire control 39,
and so on). They are history, and they are retrievable: every one of those
repos is in voidbot's index (census §2). They are not migrated. That
operator ruling is itself only in memory; it is in the list for Self (§6).
2. **Rulings restated in prose beside a typed campaign.** Examples:
`Aetheria/docs/aetheria-release-map.md` names rulings by label in "Demo
scope", and `eureka-site/BRIEF.md:12` lists operator rulings. Where the prose
cites a ruling id it is a pointer and conforms to `one-home`. Where it
restates the ruling's substance without an id, it is a second copy.
3. **Rulings only in memory files or loose doc heads** (census §4.1). These go
to Self to admit with the operator's words (§6). Once a ruling is typed, the
memory file keeps its "how to apply" text and cites the id instead of
restating the ruling. `aetheria-ship-the-game.md:9` already does this.
### 2.2 Open questions
`question` fits when the question is a real fork in a campaign. Prose "Open
questions" sections come in two kinds that look alike:
- **Forks** (an operator choice with options): Asura Q10
(`Asura/docs/cut-map.md:1671`), Fensalir Q-F1
(`Fensalir/docs/planet-work-assimilation.md:392`), Hermodr
(`Odin/docs/hermodr-extraction-plan.md:296`). In a campaign's repo they are
admitted as questions. In a repo with no campaign they wait in prose until a
campaign exists (§5, question `prose-campaign-coverage`).
- **Inquiry** (research questions with no decision attached):
`Ghostlight/docs/research/things-to-steal.md:323`, the `Mimir/research/*`
"Open Questions" sections, the prior-art "Gaps" sections. These stay prose.
A question with no options and no decider is not the `question` kind.
### 2.3 Follow-ups, TODOs, owed work
`follow_up` fits. The prose shapes are postmortem "Open follow-ups" tables,
handoff next-action lists, runbook "follow-up" lines and Soul or Eyes passes
that say "Self admits these" (census §4.3). This is the TODO-list anti-pattern:
owed work in a list no tracker reads and no one closes. The documented
practice is to harvest it into the tracker and replace the text with a
reference (PA §5, [M]: todo-to-issue harvesters; Beads replacing markdown plan
files, [M]). Here the tracker is the mind. §7 is the first harvest.
A postmortem written after this target lists its open follow-ups by id and
does not describe them again. Eureka's `references/postmortem-template.md`
should say so. That is an edit to the Eureka skill, and it rides with
`cut-skill-huginn-cli` or the next skill cut.
### 2.4 Plans and roadmaps
No `plan` kind is earned. A plan with an owner is a campaign: its target holds
the invariants, its cut specs hold the steps, and its prose map holds rationale.
That is already the rule (`campaign-state.md`, "The prose map"). Roadmaps in
repos with no campaign (`Mimir/docs/implementation-plan.md:440`,
`Fensalir/docs/perfect-fensalir-machine-roadmap.md:512`,
`StreamPixels/docs/implementation-plan.md:110` and others, census §4.4) stay
prose and retrievable until a campaign opens. Their next steps then become cut
specs or follow-ups.
`state/map.yaml` and `state/evidence.jsonl` (in 15 and 11 repos; census §4.9)
are Epiphany-era repo state. They are not migrated. They are history, and
`ghostlight-play-agent-postmortem.md:226` records that one of them is not even
parseable YAML.
### 2.5 Ideas and design sketches
These stay prose, retrievable, with no `idea` kind. The bar fails on owner and
consumer. An idea nobody has directed has no owner who would close it and no
consumer who reads it on a schedule. A typed list of ideas would be the
free-text memory block with no status, owner or supersession (PA failure 17,
[M]) with a schema on top. Explanation is the Diataxis kind that holds
rationale and ideas (PA §5, [M]).
An idea crosses into the mind when someone directs work on it. That happens in
one of three ways: the operator says so (`Aetheria/docs/faction-territory-target.md:20-30`,
"slot it in right after fire control"), a campaign's map adopts it, or a ruling
points at it. Then a `follow_up` cites the doc by path and line, and the doc
stays the idea's home.
### 2.6 Body facts and probe results: `observation`, earned
The open follow-up `eureka-body:follow_up:eyes-observation-kind` (operator
2026-10-01: "Shouldn't eyes have write access to typed memory for its
findings?") already proposes this kind. This target confirms it meets the bar
and narrows it:
- **Owner.** `huginn-mind` admission. The leaf now lives in Huginn
(`leaf-into-huginn` landed; verdict `cut-leaf-into-huginn.s2` exists).
- **Admitted by.** Eyes. Today no kind can hold an Eyes report: `finding`
needs a Soul verdict and `follow_up` needs owed work.
- **Shape.** claim (`Line`), scope (repo, path or host), as-of (commit or UTC
time), evidence (`Evidence[]`, the type findings already use), supersedes
(an observation ref), and an optional `DocRef` to the committed report for
anything longer than a `Para`.
- **Live consumers.** Imagination and Soul already cite Eyes facts. Cut specs
cite them by `DocRef` today, which requires the report to be committed in a
repo (`campaign-state.md`, prose map: "Body facts").
- **Invariant.** A fact carries the time it was true and is superseded rather
than edited. This is the bi-temporal invalidation in Zep/Graphiti, where an
older fact is marked invalid and kept, not deleted (PA §2, [V]). It is also
the supersession rule the mind already applies to rulings.
- **Liability replaced.** The stand-in Eyes files at `F:\Projects\` root:
`EYES-*.md`, 17 `aetheria-*` files plus 10 `aetheria-map-addenda-*`, 9
`eureka-*` and others (census §4.6). None of them is version-controlled or
indexed (census §3.1).
The follow-up's owner field still names "cut leaf-into-huginn", which has
landed without the kind. Self should re-own it: an eureka-body Imagination cut
in Huginn, after `huginn-cli` lands so the kind is added once, behind the new
door.
What can happen now, before the kind exists: Eyes writes its reports into the
owning repo's `docs/research/` (or `gamecult-ops/docs/research/` when no repo
owns the topic) and commits them there, never at `F:\Projects\` root. That
alone makes them retrievable (§4). Follow-up `prose-root-files-into-repos`
moves the existing root files.
### 2.7 Rationale and history
These stay prose, retrievable: campaign map Rationale sections, postmortems,
incident logs, superseded cut maps. Retirement uses the supersession rule of
ADRs, PEPs and RFCs: the old document keeps its text and gains a pointer to its
successor (PA §6, [M]). Where a superseded doc would mislead (for example
`Epiphany/notes/fresh-workspace-handoff.md`, "paused", census §5), the fix is a
one-line status header naming the successor, not a rewrite.
Duplicates are the retrieval hazard here. A search over several copies returns
old and new versions together unless the index can filter them (PA failure 18,
[M]). The census found six large cut documents duplicated between
`Epiphany/notes/` and `Huginn/docs/history/`, and `.voidbot/birth/` copies in
CultLib and Fensalir (census §4.6). Follow-up `indexed-duplicate-copies` owns
this.
### 2.8 Reference and how-to
These stay prose: runbooks, READMEs, AGENTS.md, contracts, prompts. Reference
is the one prose kind that tracks code mechanically (Diataxis, PA §5, [M]). It
stays correct only where a generator or test checks it (PA §6, [M]: generated
docs plus a CI diff check). Nothing in this target adds such checks; they
belong to each repo's own campaign.
### 2.9 Handoffs
No kind. The `hand_off` kind exists, but it records a stewardship transfer
between minds (parked, Cut 12), and a Self rotation is not that. A rotation
handoff should hold only what the mind cannot answer:
- the area and the campaigns this Self owns;
- the session label convention;
- pointers to the recipes to run at re-entry.
Owed work goes into the mind before rotation, as follow-ups. Memory
`handoff-is-a-pointer.md` already says to query the mind rather than trust a
handoff's lists. The 2026-10-10 handoff still carries a "Host loose ends"
section and cast-art plans that were in no document (§7 admits them).
When `run-kind` lands (`epiphany.pipeline.run.v2`, eureka-substrate), "who is
working on what" becomes derivable from runs, and the handoff shrinks again.
### 2.10 Memory files
Memory files (100 of them, census §1) hold four different things, and each has
a different home:
| Content | Example | Home |
|---|---|---|
| Operator rulings | `foss-games-monetization.md`, `cultcache-stores-outside-assets.md` | `ruling`, admitted by Self (§6); the memory keeps a pointer and its how-to-apply text |
| Operator context, relationship, lessons and scars that steer judgment | `operator-rapport.md`, `probe-process-spawning.md` | Eureka's Persona state (`gamecult.persona_state.v0` in Huginn) once `cut-huginn-persona-state.r3` lands. The standard puts "hard-won operating lessons, rakes stepped on, relationship context" there (`gamecult-ops/docs/persona-state-standard.md:22-25`) |
| Reference facts about the body | `cultlib-ci-harness.md`, `mcp-server-registration.md` | the owning repo's docs or runbooks (prose, retrievable); the memory keeps a pointer |
| Cross-project pointers and the index | `MEMORY.md` | stays, as Claude Code's harness loads it |
The second row hides a fork. Claude Code auto-loads `MEMORY.md`. Huginn's
Persona state is loaded by nothing in a Claude Code session. If both hold
lessons, they are two copies. The question `memory-persona-projection` (§8)
puts the options. The recommendation is that Persona state is canonical and the
memory index becomes a generated projection, marked `provenance.authority:
projection` as the cut spec already requires of file copies. That follows the
generated-file pattern (PA §6, [M]) and must wait for the persona-state cut.
## 3. Keeping prose and typed state from becoming second copies
**The rule.** When a class is typed, the prose item becomes a pointer: the
mind id, plus at most a one-line gloss. The prose is never deleted when it
holds rationale, and never left restating the decision. This is the
single-source rule as ADR, PEP and RFC indexes practise it: one record, other
places link to it (PA §6, [M]).
**Retirement order.** First admit, then repoint the prose in the same session,
citing the admission receipt. A prose item that is done (§7 found several:
items 4, 14, 33, 39) gets nothing new. It is left as history, with a status
line if it would mislead a reader.
**Who enforces it, and how.**
- **Life** owns memory files and the doctrine surfaces. At phase boundaries it
checks that memory rulings it finds are typed, and repoints them.
- **Soul**, when a cut touches a campaign's prose map, checks it against
`campaign-state.md`'s existing rule ("no progress section, no rulings and no
ledger").
- **No regex tribunal.** Whether a paragraph restates a ruling is a question of
meaning. Published drift detectors reach 72% accuracy or 0.72 precision with
0.48 recall, and their benchmarks contain mislabelled data (PA §6 and
failure 21, [V]). Detection is therefore advisory: an agent reads, proposes,
and a faculty confirms. This is the propose-then-confirm pattern of published
tooling (PA §4, [M]).
**Why no bulk extraction.** Single-prompt extraction over a long document
under-extracts, recovering one or two decisions from an hour of discussion
(PA §4, [V]). Extraction has two silent failures, omission and invention
(PA failure 13, [V]). Constrained decoding guarantees shape, not truth (PA
failure 14, [M]). LLM-driven memory updates overwrite without review (PA
failure 16). So each item is triaged by an agent that reads the cited section,
checks the mind by semantic query (dedupe), checks the file still says it
(staleness), and admits with `locations` citing file and line. §7 is that
procedure run once over the census list.
## 4. Retrieval for prose that stays prose
**One path: voidbot's existing source index. No second index.**
What voidbot is, verified this pass:
- The service is `voidbot-retrieval.service` on Yggdrasil. Its MCP container
serves streamable HTTP on `127.0.0.1:17875/mcp`, with the tool allowlist
`list_indexed_repos, search_history, get_message_context, search_sources,
get_source_context, get_exact_source_document`
(`gamecult-ops/compose/voidbot-retrieval.yggdrasil.yaml:11-27`).
- `search_sources` takes `query`, `limit`, `repoName`, `pathPrefix` and
`language` (`VoidBot/apps/worker/src/mcp-server-shared.ts:93-99`). It
returns `repoName`, `path`, `chunkIndex`, `lineStart`, `lineEnd` and
`lastModifiedAt` for each hit (`mcp-server-shared.ts:309-321`), so an answer
can cite a path and line range. It returns no commit.
- `voidbot-source-refresh.timer` runs hourly. It mirrors the **public GameCult
GitHub catalog** into `/srv/voidbot/source-repos` and re-embeds changed
chunks (`gamecult-ops/runbooks/voidbot-retrieval-recovery-yggdrasil.md:109-121`;
the catalog URL is `scripts/refresh-voidbot-sources.sh:82`).
- Collection `voidbot_repository_source_chunks` lives in Qdrant on
`127.0.0.1:6333`. Huginn's own index uses the same Qdrant and the same
embedder and model, `qwen3-embedding:0.6b` on Raven
(`gamecult-ops/runbooks/huginn-yggdrasil.md:18-27`). The two indexes already
share their substrate. They differ in what they hold: Huginn indexes only
typed documents' named fields (`Huginn/crates/huginn-mind/src/index.rs:42`).
Who can reach it:
- On Starfire, sessions reach it through the Yggdrasil tunnel
(`gamecult-ops/scripts/start-yggdrasil-tunnel.ps1:23`,
`runbooks/yggdrasil-ssh-tunnel.md:88`). It is registered in
`~/.claude.json` as the `voidbot` HTTP MCP server.
- On Yggdrasil, a Self-run unit or the Huginn daemon reaches it on loopback.
What it does not reach, and the fix for each:
| Not indexed | Why | Fix |
|---|---|---|
| `F:\Projects\` root files (56) and non-git dirs | not in any repo | move them into the owning repo's docs and commit (`prose-root-files-into-repos`) |
| memory dirs | outside repos | the §2.10 split: rulings to the mind, reference to repo docs |
| worktrees | duplicates of indexed repos | nothing; excluding them is correct |
| private or forge-only repos, and repos missing from the catalog (`Aetheria-legacy`, `Blueberrichu`) | the catalog is public GitHub only | out of scope until a repo with live work is affected |
Rejected: a second prose index in Huginn. It would duplicate an hourly-refreshed
index over the same Qdrant and embedder, and give the corpus two owners.
Graph and tree indexes (GraphRAG, RAPTOR) were also rejected. They cost an LLM
pass over the corpus at index time and an update strategy on every change (PA
failure 11, [M]), and their reported gains fall under independent audit (PA
failure 10, [V]). Plain retrieval with citations is the baseline this corpus
needs. Letta's report that a filesystem plus grep competes with specialised
memory (PA §2, [M]) points the same way.
Two gaps in the path itself:
- **No commit in the hit.** A `DocRef` needs a commit. The fix is VoidBot's
own: add the indexed commit to the source metadata (follow-up
`voidbot-hit-commit`).
- **Which door.** The operator's direction to replace MCP with plain CLIs is
recorded against eureka-state (`eureka-substrate:follow_up:huginn-cli-replaces-mcp`).
Whether voidbot's door follows is a separate fork, put in question
`prose-retrieval-door` (§8). The recommendation is to keep voidbot's door
now; if it changes, the CLI is VoidBot's own and Huginn never proxies it.
## 5. Sequencing
| Now, with existing kinds | Waits on |
|---|---|
| §7 triage: follow-ups and a question admitted under owning campaigns | none |
| §6: Self admits the memory-only rulings with the operator's words | none |
| Eyes reports committed in repos, not at `F:\Projects\` root; root files moved (`prose-root-files-into-repos`) | none |
| Handoffs as pointers; loose ends admitted before rotation | none |
| Postmortem template lists follow-ups by id | rides the next Eureka skill cut (`cut-skill-huginn-cli`) |
| `observation` kind; then the root Eyes facts are admitted | `cut-huginn-cli` (one door to add the kind behind), then an eureka-body Imagination cut re-owning `eyes-observation-kind` |
| Memory lessons and rapport into Eureka's Persona state | `cut-huginn-persona-state.r3`, then question `memory-persona-projection` |
| Work in repos with no campaign (Kalsa, Fensalir, Mimir, AquaSynth, Odin, Muninn, StreamPixels and others) | question `prose-campaign-coverage`; a campaign spanning existing campaigns' repos waits on `eureka-body:follow_up:body-campaign-repos` (campaigns cannot gain repos) |
| Handoff lists derived from runs | `run-kind` (eureka-substrate) |
| `DocRef` from a voidbot hit | `voidbot-hit-commit` |
## 6. Rulings found only in prose or memory: for Self to admit
Self admits these with the operator's words. Imagination does not type rulings.
Each was checked against the mind by semantic query this pass; "near" names
the nearest typed ruling, which does not carry the same decision.
| Ruling (paraphrase) | Operator's words at | In the mind? |
|---|---|---|
| FOSS games are monetized as games-as-a-service, microtransactions included; the fork is the check | `~/.claude/projects/F--Projects/memory/foss-games-monetization.md:11-14` (the decision is stated in the memory's voice; the correction is narrated at `:21-22`; no verbatim quote) | no; near `eureka-body:ruling:always-open-source` |
| CultCache stores never live under Unity `Assets/`; no Unity serialization for game data | `F--Projects/memory/cultcache-stores-outside-assets.md:10` (verbatim, 2026-09-14), plus the Studio goal at `:19` | no; near `aetheria-release:ruling:ship-data-all-in-cc` |
| Spotiverse and Stonks are wanted small daemons, not corpses | `F--Projects/memory/wanted-small-daemons.md:11-18` (2026-09-06; partly quoted: "a real simple CultMesh bridge") | no |
| Having no consumer does not make code dead: park it with a tag and a restore note | `F--Projects/memory/parked-not-dead.md:11-14`, intent quote `:23` (2026-09-13) | only per instance: `ghostlight-verse:ruling:park-pre-rebuild-tree` |
| The operator's original CultCache.cs is the style bar | `F--Projects/memory/original-cultcache-style-bar.md:10` (verbatim, 2026-09-13) | not checked by query; no near ruling surfaced in the CultCache queries |
| Not going back to Codex/OpenAI as inference supplier, for alignment reasons | `F--Projects/memory/codex-retirement.md:20-23` (verbatim, 2026-09-05) | no; near `eureka-body:ruling:connector-owner` |
| AetheriaEve is preserved for taxidermy | `F--Projects/memory/aetheriaeve-taxidermy.md:10` (verbatim, 2026-09-13) | covered in substance by `variants:ruling:aetheriaeve-not-consumer` (2026-09-30); Self decides whether the quote is worth a ruling |
| Aetheria mods are compiled plugins; no scripting runtime or DSL for modders | `F--Projects-Aetheria/memory/aetheria-mods-are-compiled.md:16-18` (verbatim, 2026-10-03) | no; near `aetheria-release:ruling:modders-share-our-editor` |
| Aetheria's release bar: more ships and gear, UX polish and audio feedback before announcing | `F--Projects-Aetheria/memory/aetheria-release-bar.md:9-11` (verbatim, 2026-10-02) | no |
| The built-in pipeline is chosen; owning shaders is the point | `F--Projects-Aetheria/memory/built-in-pipeline-owned-shaders.md:10` (verbatim, 2026-10-10) | in substance `aetheria-release:ruling:shaders-own-every-pixel` (2026-10-03); the 2026-10-10 words add the reason |
| Campaigns in flight on 2026-09-30 finish in their prose maps | `F--Projects/memory/eureka-typed-vs-prose-campaigns.md:11-12` (verbatim) | no |
| Embedding moves off Nightwing because its availability "is more sporadic nowadays" | `F--Projects/memory/nightwing-embedding-migration-2026-09-26.md:12-16` | not checked |
| Faction territory: "slot it in right after fire control" | `Aetheria/docs/faction-territory-target.md:20-30` (2026-09-19) | no; the follow-up in §7 points at it |
| Negent weapons concept direction | `Aetheria/docs/negent-weapons-concept.md` head (2026-09-18) | no; the follow-up in §7 points at it |
| Asura: rulings on the cut map's questions | `Asura/docs/target.md:150` (2026-09-25) | the prose-campaign rule applies (§2.1); not migrated |
## 7. Triage of the census list (census §6)
Each item was checked in two ways. The file was read at the cited line on the
working tree (Ghostlight on `origin/main`, because local checkouts lag). The
mind was searched by semantic query. Admitted ids are under the campaign named.
"Stays" means no document was admitted, and why.
| # | Item | Result |
|---|---|---|
| 1 | Heimdall secrets exposed; rotate and move to `secret_files` | admitted `eureka-body:follow_up:heimdall-binding-secrets` |
| 2 | Backups unencrypted; key owner | admitted `eureka-body:follow_up:state-backup-encryption` (the weekly tier landed, `3d091c5`; encryption is still open, runbook `gamecult-state-backup.md:103`) |
| 3 | Raven reboot test, ESP, `reagentc`, inventory entry | admitted `eureka-body:follow_up:raven-reboot-esp-inventory` |
| 4 | Stale Raven LAN IP in inventory | done: `inventory.md:56-59` carries the 2026-09-09 IP and marks the old one superseded |
| 5 | Brush-tree campaign | no campaign repo (Brokkr, GameCult.Geometry.Csg); a proposal card exists; in question `prose-campaign-coverage` |
| 6 | Cast art: Flux portraits, shortlist | admitted `site-masthead:follow_up:cast-art-flux-portraits` |
| 7 | Faction territory generative names | admitted `aetheria-release:follow_up:faction-territory-target` |
| 8 | Negent weapons concept | admitted `aetheria-release:follow_up:negent-weapons-concept` |
| 9 | Content batch one §9 forks | admitted `aetheria-release:follow_up:content-batch-one-reconcile` (F1 resolved in the doc; the doc predates the demo rulings) |
| 10 | Asura Q10 switch probabilities | admitted question `asura:question:q10-eroded-aeolian-rate` |
| 11 | Asura dual contouring | stays: a deferred design in the target, rationale |
| 12 | Asura 4b-ii remaining terms | admitted `asura:follow_up:terrain-4b-ii-remaining-terms` |
| 13 | CultMath tape open items | admitted `cultmath-tapes:follow_up:tape-unpark-open-items` |
| 14 | Variants open forks | done: ruled in prose, `CultLib/docs/document-variants-cut.md:77-100` (2026-09-17 and 09-29) |
| 15 | TS schema id literal vs C# fingerprint | superseded by `variants:ruling:wire-id-is-version` and `variants:follow_up:wire-id-cuts` |
| 16 | Mutation testing scope | admitted `cultlib-gaps:follow_up:mutation-testing-scope` |
| 17 | CultNet distributed database next slice | admitted `cultlib-gaps:follow_up:cultnet-distdb-next-slice` (related: `variants:follow_up:golden-path-write-api`) |
| 18 | Muninn has two bodies | no campaign repo; in question `prose-campaign-coverage` |
| 19 | Muninn records out of `odin-core` | no campaign repo; same question |
| 20 | Hermodr extraction and its operator questions | no campaign repo; same question |
| 21 | Mimir next | no campaign repo; same question |
| 22 | Eve remaining work | admitted `thing:follow_up:eve-roadmap-remaining-work` |
| 23 | Heimdall gaps | no campaign repo; same question |
| 24 | Gjallar next questions | no campaign repo; same question |
| 25 | Fensalir Q-F1 | no campaign repo; same question |
| 26 | AquaSynth next pull | no campaign repo; same question |
| 27 | StreamPixels M1 | no campaign repo; same question |
| 28 | Ghostlight play-agent postmortem open follow-ups | admitted `ghostlight-verse:follow_up:play-agent-postmortem-open-items` |
| 29 | Ghostlight stock lenses L1.f1, f13, f14 | admitted `ghostlight-verse:follow_up:stock-lenses-open-items` |
| 30 | Ghostlight multiplayer social policy | stays: `ghostlight-dungeon-multiplayer-intention.md` is gone from `origin/main` (parked by `ghostlight-verse:ruling:park-pre-rebuild-tree`) |
| 31 | Ghostlight research questions | stays: inquiry, not a fork (§2.2) |
| 32 | Soul pass owed, 13 survivors | typed: `ghostlight-verse:follow_up:owed-soul-unpinned-survivors` |
| 33 | Aetheria design review | done: swept by `aetheria-release:follow_up:release-target-is-a-demo` (Fixed, `228f241e`) and `vault-docs-sweep` |
| 34 | Merge checklist open questions | Locomotion Q4 is answered by `aetheria-release:ruling:facing-separate-from-aim`; the rest is admitted as `aetheria-release:follow_up:merge-checklist-residue` |
| 35 | Vault Cleanup Q-08..Q-15 | admitted `aetheria-release:follow_up:vault-cleanup-accepted-queue` |
| 36 | Kalsa deepening queue | no campaign repo; in question `prose-campaign-coverage` |
| 37 | Zyphos next action | no campaign repo; same question |
| 38 | weksa roadmap | no campaign repo; same question (see also `eureka-body:follow_up:weksa-mind`) |
| 39 | Eureka brief lessons | done: all six landed in `references/briefs.md` (`:113`, `:369`, `:401`, `:409`, `:81`) and `SKILL.md:658` |
| 40 | Doctrine de-shout and publish scrub | de-shout landed (Eureka `352165d`); the scrub's application was not checked; both rulings are typed (`doctrine-explains-not-shouts`, `doctrine-publish-scrub`) |
| 41 | Idunn watchdog open findings | stays: the postmortem restates mind documents |
| 42 | Substrate leftovers on Yggdrasil | admitted `eureka-substrate:follow_up:huginn-107552dc-leftovers` |
| 43 | Bifrost first vertical slice | admitted `eureka-body:follow_up:bifrost-context-next-target` |
| 44 | Pomba Branca off Wix | no campaign repo; in question `prose-campaign-coverage` |
| 45 | Notaris brief questions | stays: questions for an outside notary, not forks in the mind |
| 46 | Prior-art gaps sections | stays: Eyes gaps; `observation` will hold them (§2.6) |
| 47 | Repixelizer hygiene | no campaign repo; in question `prose-campaign-coverage` |
| 48 | Spotiverse and Stonks | the ruling is in §6; the work is in question `prose-campaign-coverage` |
| 49 | Memory-only rulings | §6, for Self |
Added from this target's own mechanism, with the source set to the follow-up:
`eureka-body:follow_up:prose-root-files-into-repos`,
`eureka-body:follow_up:indexed-duplicate-copies` and
`eureka-body:follow_up:voidbot-hit-commit`.
## 8. Questions raised
- `eureka-body:question:prose-campaign-coverage`: which repos with no campaign
get one.
- `eureka-body:question:memory-persona-projection`: Persona state or
`MEMORY.md` as the home of lessons.
- `eureka-body:question:prose-retrieval-door`: whether voidbot's MCP door
changes.
Each carries options and a recommendation in the mind.

View file

@ -0,0 +1,83 @@
# Remnants of the old personas and swarm
Eyes pass eyes-persona-remnants, 2026-10-10, for the swarm revival target (docs/swarm-revival-target.md). Operator: "Let us send off eyes to gather what is left of the old personas and imagination to figure out how to bring the swarm back to life, with Eureka leading". Names, locations, formats and sizes only: no persona memory or message contents, no secrets. Facts tagged FETCHED, SEARCH and KNOWLEDGE.
Note added by Self: Raven offsite backups older than 2026-10-07 were wiped 2026-10-09 (E: reformatted), so any June offsite copy of Void or native Persona state on Raven is gone.
# Eyes facts: what is left of the old Personas and the swarm (2026-10-10)
Read-only. No private message content quoted. No credential read or printed. Dates are file mtimes unless stated.
Prior pass to build on: `F:\Projects\EYES-voidbot-rumination-2026-10-01.md` (why the swarm failed, what was lost).
## A. Where persona state can exist (map)
1. Repo Face state, in git, per repo: `F:\Projects\<repo>\.voidbot\state\<face>.cc` + `.voidbot\voice\identity.json`. Format: VoidBot typed CultCache "Face organ state" (legacy), NOT gamecult.persona_state.v0. No service reads it today. VoidBot `scripts/export-persona-friendly-input.ts` projects it to `gamecult.persona_intake.v0` yaml (header: "projection ... not the canonical state owner"); `scripts/migrate-portable-persona-state.ts` (npm `persona:migrate-portable`, dry-run default, `--write`, `--projection-import`). VoidBot `node_modules` is present on Starfire; I did not run either script.
2. Native Persona state (Tengu, Muninn, Sleipnir, Hermodr, Metame, and Void's own) lived at `<VoidBot>/.voidbot/private/personas/<id>/*.cc` and `.voidbot/private/void-self-state.cc`. Searched `F:` (`*private/personas*`) and `C:\Users\Meta` (tengu*): NOT FOUND. `F:\Projects\VoidBot\.voidbot` does not exist. Only avatars/notes survive (section C).
3. Epiphany native Persona Mind (`EpiphanyMindPersonaMemoryDocument`, `epiphany-persona-service`): inside the Epiphany body state. Live copy removed from Yggdrasil in the teardown (`/srv/epiphany` and `/var/lib/gamecult/epiphany` absent 2026-10-10). Sole whole copy: the backup tarball in section D.
4. Standalone Persona state file: `F:\Projects\Odin\personas\gjallar.persona_state.cc` (41,010 B, 2026-09-05; identical copy in `Odin-bump\personas\`, 2026-09-29). Format not decoded by me (Vili README line 66 calls it "Branding Persona state"). Brokkr and Ymir carry `tools\persona-state-read.ps1` (1.4 KB each; Brokkr 2026-09-05, Ymir 2026-07-22).
5. Discord archive (voidbot MCP `search_history`): human messages through about 2026-06-02; bot/webhook persona posts largely absent (2026-05-21 audit, 10-01 note).
6. Huginn `eureka` mind (eureka-state MCP; 3223 documents, 1108 receipts at query time): holds rulings and cuts about Personas, no old persona memories.
## B. Repo Faces (swarm members that were repo Personas)
State files git-tracked in each repo; identity.json beside each. Bytes = state `.cc`.
| Face | Repo | file | bytes | mtime |
| Nibu | AetheriaLore | .voidbot/state/nibu.cc | 112,242 | 2026-07-22 (content spans 2026-05-18..07-09 per 10-01 note); also in 2 worktree copies |
| Mimir | Mimir | mimir.cc | 111,322 | 2026-07-22 |
| Eve | Eve | eve.cc | 80,839 | 2026-07-22 |
| Libby | CultLib | libby.cc | 72,566 | 2026-07-22 (+ birth baseline.cc 121,528, projection.cc 121,528, msgpacks ~124 KB) |
| Kiko | StreamPixels | kiko.cc | 53,761 | 2026-09-04 |
| Weksa | weksa | weksa.cc | 52,124 | 2026-07-22 |
| Heimdall | Heimdall | heimdall.cc | 44,537 | 2026-07-22 (no identity.json) |
| Huginn | Huginn | huginn.cc | 35,297 | 2026-09-05 |
| Fensalir | Fensalir | fensalir.cc | 29,189 | 2026-07-22 (+ birth baseline/projection ~109 KB) |
| Druzkai | Zyphos | druzkai.cc | 28,491 | 2026-07-22 |
| Norn | Norn | norn.cc | 15,853 | 2026-08-26 |
| Sai | Sai | sai.cc | 15,653 | 2026-09-05 |
| Brokkr | Brokkr | brokkr.cc | 9,881 | 2026-09-05 |
| Aetheria | Aetheria (+catalog/stats/tiers worktrees) | aetheria.cc | 4,238 main (2026-09-26); 8,361 in 3 worktrees | birth/terrain/baseline.msgpack 541,653 |
| Ymir | Ymir | ymir.cc | 4,241 | 2026-09-29 |
15 Face state files (10-01 note: 436 KB). Odin, Bifrost, Hermodr, Muninn repos: no `.voidbot` found, although VoidBot `state/map.yaml` says `odin` is a repo Face at `Odin/.voidbot/`.
Named but no state file found: Epiphany (avatar; also a native Epiphany Persona), Odin (avatar), Muninn (concept avatar), Moobs (intake only), AquariumSynthHost (example registry entry).
Avatars: `F:\Projects\VoidBot\assets\repo-faces\` (epiphany, huginn, kiko, mimir, muninn-concept, odin, weksa, ymir; contact sheet 2026-05-22; avatars-128, selected, source-art); `Odin\assets\personas\` (gjallar x2, sleipnir).
Load-bearing vs residue (from VoidBot docs, not from opening files): the state packet carries private notes, values, activation vectors, affect needs, social bonds, status reads, mood dimensions, durable memories, short-term residue, agency pressures, incubating thoughts, recent speech residue (`persona-state-packet-projector.ts`; VoidBot map line 175). Identity-bearing: values, drives, wounds, fascinations, taste, bonds, hard-won lessons. Residue: short-term residue, speech residue, receipts, pending mentions, room weather. Per-file split not measured.
## C. Native Personas (not repo-bound)
| Persona | What | State | Readable today |
| Tengu | native; the only one migrated to canonical gamecult.persona_state.v0 (VoidBot notes/fresh-workspace-handoff.md, 2026-08-30) | .cc under .voidbot/private/personas: not found | No. Avatar `VoidBot/assets/native-personas/tengu.png`. Legacy stress records preserved under `anchoredThought.extensions.legacyStressResponse` per that note |
| Muninn | native (also a repo, concept avatar) | "typed non-canonical import" per same note | No state found |
| Sleipnir | native; input mirroring / virtual HID speech (state/map.yaml ~line 130); avatar in Odin | typed non-canonical import | No state found |
| Hermodr | native; browser-lowering / typed-command-route speech | typed non-canonical import | No state found |
| Metame (メタめ) | native, projected from the operator's public history; grants discussion, rumination, repo read/propose, discord text, aquarium embodiment | typed Void state, never canonical | Projection only: `VoidBot/docs/persona-intake/metame.persona-intake.yaml` 107,533 B, projectedAt 2026-06-11 |
| Moobs | native | intake projection `moobs.persona-intake.yaml` 8,039 B | yaml readable |
| Void | VoidBot's built-in Discord moderator mind (never a scheduler participant) | void-self-state.cc | LOST (10-01 note: not on Starfire or Yggdrasil; possible Raven offsite 2026-06-03 whose script does not cover .cc) |
| Gjallar | Odin branding Persona | `Odin/personas/gjallar.persona_state.cc` 41,010 B | file exists; format/read path unverified; VoidBot map calls the Gjallar/Eir pair stale |
Also `sai.persona-intake.yaml` 36,712 B (projection of Sai's Face, 2026-06-11).
## D. Epiphany Persona and Epiphany swarm
- Repo `F:\Projects\Epiphany`: `apps/` holds only a README; local `state/` holds `agent-heartbeats.msgpack` 57,750 B (2026-07-22), `ledgers.msgpack` 740,162 B, `runtime-spine.msgpack` 15,303 B, `map.yaml` 121,401 B, `persona-discord.toml` 1,310 B, `persona-reddit.toml` 1,020 B; `agents/` empty (agents.msgpack not on Starfire). `.epiphany-run/` sandbox has active-cognition* dirs. GitHub not yet archived (Huginn map line 37).
- Whole-body backup on Yggdrasil: `/var/backups/epiphany-body-2026-10-09/epiphany-body-state.tar.zst`, 2,932,941,639 B, written 2026-10-09 12:16 UTC, `SHA256SUMS` and `backup.log` beside it (root-only dir). I listed names only (101,261 entries); nothing extracted, no credential file read. Persona-relevant members: `var/lib/gamecult/epiphany/workspace/state/agents.msgpack` (26,963 B), `.../state/agent-heartbeats.msgpack` (57,750 B), `persona-discord.toml`, `persona-reddit.toml`, notes `persona-*-authority-map.md`, `repo-personality-projection-layer-plan.md`, `schemas/heartbeat-state-schema.md`, `schemas/repo-personality-birth-projection.md`, schemas `epiphany.persona-chat`, `epiphany.surface.persona`, `epiphany.heartbeat_initiative_heat.v0`, `gamecult.persona_state.v0`; `var/lib/gamecult/epiphany/local-verse.cc` 42,277,981 B; capstone `runtime.cc` up to 104,433,252 B; ~24 release copies of `epiphany-persona-service` (~35.9 MB each). 1,161 entries match "persona". Another agent is scanning it for secrets.
- Persona-service runtime shape (Epiphany `notes/persona-conversation-authority-map.md`): Bifrost-observed Discord event -> signed delivery -> durable pending mention in the heartbeat -> native loop Projector (Imagination) -> Persona (natural speech) -> Interpreter (Mind converts to typed effects) -> optional Persona self-state mutation (Mind admits) -> signed speech request -> Bifrost posts under a permit; terminal receipt starts heartbeat cooldown. Single bound Persona: agent `epiphany.Persona`, runtime `epiphany-starfire`, Aquarium channel. Stated gap: durable mention obligations unbuilt at head (follow-up `eureka-substrate:follow_up:epiphany-inbound-mentions`).
- Yggdrasil units `epiphany`, `epiphany-swarm`, `epiphany-heartbeat` were inactive before the teardown (Huginn map F5). Nothing Epiphany runs on Starfire (F7).
- Aquarium repos indexed: Aquarium, EpiphanyAquarium (+Bevy, Web): the GUI that showed swarms (operator Discord 2026-05-06).
## E. VoidBot swarm: runtime shape when it last ran
- Pulse: Windows scheduled task "GameCult Local Orchestrator", once a minute (`run-gamecult-orchestrator.ps1`) -> repo Face CTB heartbeats (also Bifrost dispatch, Void mood drift, moderation rumination, watchdog). Disabled 2026-05-21; swarm restarted ~2026-05-22 ("Meet Kiko, Huginn and Weksa", Discord); later replaced by resident daemon `apps/persona-scheduler`. Paused 2026-07-16 (`VoidBot/state/agent-swarm-paused.json`) after stale semantic pressure authorised a model turn every one-minute pulse.
- Turn-taking (`initiative-engine.ts`, 596 lines): serialized wall-clock tick; per participant initiativeSpeed (0.75-1.3, hash of id), operator heat (0.05-20), dynamicHeat, baseRecoveryMinutes, nextTurnAt; recovery = base*(1+0.75*load)/effectiveSpeed; wall clock advances initiative, heat only scales recovery. Ready = active, load<1, not done this tick, not napping (unless mentioned), nextTurnAt <= clock. Order: pending mentions, then nextTurnAt, reactionBias, speed. At most one unprompted turn per tick, only if responsePressure >= interruptThreshold (0.45-0.8) with unconsumed evidence and the global next-unprompted time elapsed.
- Pressure/waker: `responsePressure` = cosine of embedded Face identity-card projection vs recent human messages (age-decayed, noisy-OR); awakens at most 3 Faces; may pull readiness to now once per message (VoidBot map line 195). Mentions (role mention, display-name token, reply to a webhook message) queue pending obligations. Void physiology on its own 5-min cadence.
- Turn execution: assemble prompt (identity doctrine, rooms, Bifrost digest, projected memory/affect prose, home-repo activity, social affordances, raw transcript, curiosity graph) -> approved `repo-face-rumination` worker job -> LLM child writes in character -> cheaper Interpreter emits STATE NOTE / SAY blocks -> worker side effects via Bifrost bridge; receipts into Face state. About 25 hand-written `VoidBot/prompts/repo-face-*.prompt.md`.
- Failure causes recorded: 10-01 note (state rot 2026-05-17, robotic voice, saturated thoughts never gated, no inter-Face bonds, work leaked into social posts, stale scheduler state, "talking to summaries instead of people" 2026-07-16). Survivor plans: `VoidBot/notes/swarm-survivor-repo-agent-plans-2026-07-06.md`; teardown plan/audit: `notes/agent-swarm-teardown-plan-2026-05-21.md`, `agent-swarm-shutdown-audit-2026-05-21.md`.
- Today: Yggdrasil `voidbot.service` runs only the typed `voidbot.swarm` Eve publisher (59,829 B `voidbot-swarm-state.cc`, 2026-09-29) and `voidbot-retrieval.service` (read-only corpus). Discord bot, worker/job loop, persona scheduler, Postgres, resident cognition state "not restored" (VoidBot map line 9). `/srv/voidbot/swarm/state` totals 140 KB, no persona state.
- Operator in Discord (channel 113786069023064068), 2026-06-02: wanted "a swarm of canonical Personas who don't care about repo maintenance" and a representative per Aetheria faction; 2026-05-28: wanted "more voices in the room to steer them". Search hits only, not an exhaustive read.
## F. Eureka's Persona design as ruled (eureka-state)
- `eureka-substrate:ruling:operator-archive-epiphany` (2026-10-01): archive Epiphany; Eureka absorbs what lives.
- `eureka-substrate:ruling:operator-persona-in-mind` (2026-10-01): Persona state belongs to the Mind; Mind's agency takes the shape of VoidBot's Persona machinery; she speaks on Discord and acts through her Self.
- `eureka-body:ruling:wake-target` (2026-10-08): waker starts a Persona turn; Persona decides whether to start a Self run. `eureka-substrate:ruling:every-wake-a-run` (2026-10-09). `eureka-body:ruling:persona-per-mind` (2026-10-09): one Persona per mind; a domain needing an advocate gets its own Huginn instance with its own dial share and waker turns; minds cooperate by ForeignRef and HandOff; proportional-share scheduling cited. Open follow-up `eureka-body:follow_up:weksa-mind`.
- Cuts: `cut-mind-wake.r3` (stateless one-shot waker, cadence since newest Persona turn), `cut-huginn-persona-state.r3`, `cut-doctrine-persona-in-huginn.r3`. Huginn map: Persona state = `gamecult.persona_state.v0`, one per mind; Persona trace store empty (question `persona-trace-retention`, needs the Discord training-policy verification, F14); units `mind-wake@`, `mind-self@`, `mind-persona@` not yet on Yggdrasil.
- Huginn has no persona_state reader, migration path or CultMesh publication; the standard keeps VoidBot as owner of that path. Related, not read by me: `F:\Projects\gamecult-ops\docs\research\multi-persona-prior-art.md` (10,930 B).
## G. Gaps and unverified
- No `.cc` opened or decoded; sizes and mtimes only. Face `.cc` format is from VoidBot docs.
- Native Persona `.cc` (Tengu, Muninn, Sleipnir, Hermodr, Metame) and Void self-state not located on Starfire (searched F: and user profile) or in Yggdrasil `/srv/voidbot`. Raven offsite from 2026-06-03 not checked. No E: drive on Starfire (old docs use E:).
- Epiphany swarm member roster exists only inside `agents.msgpack` in the tarball; not enumerated.
- Qdrant collections of Face memory under `/srv/voidbot/qdrant/collections`: not counted.
- Yggdrasil `/srv/retired/retired-2026-09-30` holds only bifrost compose and a manifest; no persona content.
- I left no files on Yggdrasil (a temp name list in /tmp there was deleted).

View file

@ -0,0 +1,229 @@
# Swarm revival: target shape (draft)
Status: draft target, Imagination (`imagination-swarm`), session
`self-2026-10-09-eureka`, 2026-10-10, written at Huginn `441b1fc`. Campaign
`eureka-body` in Eureka's mind (instance `eureka`). This is a step-0b pass
(identity, lifecycle and authority before any cut is mapped). It admits no cut
specs. The forks it raises are typed questions in the mind; read them by id.
Self commits this page.
Operator, verbatim (2026-10-09): "Let's send off eyes to gather what's left of
the old personas and imagination to figure out how to bring the swarm back to
life, with Eureka leading".
## Rationale
A swarm here is several minds that share one wallet and one host. Each mind
has its own Persona and its own record, and the minds cooperate by citation
and hand-off. Three rulings already fix most of the shape:
- `eureka-body:ruling:persona-per-mind`. One Persona per mind. A domain that
needs an advocate gets its own Huginn instance.
- `eureka-body:ruling:mind-scheduling-ctb`. Minds share the swarm on a
conditional-turn timeline. After each turn, the mind's virtual time grows by
the turn's cost divided by its share. The lowest mind acts next.
- `eureka-body:ruling:brake-dial-root-store-now`. She owns her body, and the
operator owns the wallet.
What is left open is what "Eureka leading" means. The answer this target
recommends: **leading is a role, not a rank.** Eureka maintains the shared
substrate, as steward of its repos. She routes work that arrives without a
mind's name on it. She convenes forks between minds and recommends a ruling.
Leading does not let her set another mind's share, sleep it, rewrite its
Persona, or rule inside its domain. Those powers are either the operator's
(the wallet, and opening or retiring a mind) or the other mind's own (its
body and its rulings).
Two reasons, one from the record and one from prior art:
- **The record.** `she-owns-her-body` applies to each mind, so no mind owns
another's body. The wallet is the operator's. `operator-means-shape-ends`
makes the Minds' governance the first instance of GameCult's governance. Its
red lines are no permanent aristocracy, no punishment for dissent and no
lock-in, and it adds decay against whoever showed up first
(`mind-standing-decay`). A lead who could starve a peer is the aristocracy
those rules forbid.
- **Prior art.** Systems that last separate coordinating from commanding. A
Raft leader orders the log but cannot rewrite a follower's state. Debian's
Project Leader coordinates and delegates, while its Technical Committee
arbitrates and no developer is required to do anything. Holacracy's lead link
assigns roles but does not direct how they are filled. In multi-agent LLM
frameworks that put a manager agent over the workers (CrewAI hierarchical,
orchestrator-worker), MAST attributes most failures to system design and
inter-agent misalignment rather than to the model alone. That is an argument
for narrow, explicit authority between agents.
This shape gives the operator a dial ("Eureka gets more share for routing") and
never a supervisor. Within the rulings in force, nothing a mind does can lower
another mind's share.
## Names
| Term | What it names | Key |
| --- | --- | --- |
| Mind | One Huginn instance: one store (`mind.redb`), one identity document, one record of campaigns and rulings. | `<instance>:instance:self`, instance a `Slug` (`eureka`, `weksa`) |
| Persona | The mind's one `gamecult.persona_state.v0` document, held in the mind and replaced whole under CAS (cut `huginn-persona-state` r3). | `personaId` = instance |
| Instance runtime | The units, users, endpoint, Qdrant collection and control store templated by instance. | `huginn@<i>`, `mind-*@<i>:<label>`, `mind-<i>`, `/etc/gamecult/minds/<i>/` |
| Swarm | The minds on one host that share one wallet: their shares and the timeline. Not a mind and not a persona. It has no record of its own. | host-scoped: `/etc/gamecult/swarm/`, `/var/lib/gamecult/swarm/` |
| Lead | A role held by one mind of the swarm (Eureka), defined by the authority table below. It is not a separate identity. | Recorded as a Standing ruling naming the instance, so it can move. |
## Identity, lifecycle, authority
| Kind | Identity (what names it) | Lifecycle (what happens over time) | Authority (who decides) |
| --- | --- | --- | --- |
| Mind | `<i>:instance:self` in its own store. The identity is in the state, not the path (ruling 14). | Born, sleeping, awake or retired. See the lifecycle table below. | Opening and retiring a mind: question `swarm-membership` (recommended: the operator rules on a proposal from any mind). The mind owns its record. |
| Persona | `gamecult.persona_state.v0`, `personaId` = instance, one per mind. | Born by import or fresh (see the remnant import row below). Rewritten by its own Persona turns (`identity-write-all-visible`), and drifting by design (`persona-drift-is-steered`). | The mind's Persona organ is its only writer. No other mind writes it, the lead included. |
| Remnant import | An old persona's state in whatever format it was kept, converted to v0. The source is named in `provenance` (the Persona State Standard: canonical, projection or import). | One-shot at birth. The first CAS put is canonical and carries `provenance` naming the source and that it was imported. Legacy extension fields do not become v0 authority. The bounded memories worth keeping go in, and the rest stays in the source archive, cited. The mind's first Persona turn is told it was imported. | Self converts. The operator approves the birth (`swarm-membership`), and the mind may rewrite anything afterwards. |
| Instance runtime | Templates by instance: a daemon `huginn@<i>` (its own `--instance`, `--bind` and state root, and a Qdrant collection named for the mind); units `mind-persona@<i>:<label>` and `mind-self@<i>:<label>`; users `mind-<i>` and `mind-<i>-self`; the brake in `/etc/gamecult/minds/<i>/control.cc`. | Installed at birth (hand-installed now, Idunn targets before the first hosted member: `mind-units-templated-interim`). Disabled at retirement. | gamecult-ops installs, and Idunn later. The brake is the operator's (`brake-dial-root-store-now`). |
| Share and haste | `eureka.control.share.v1 { instance, share, haste?: { multiplier, until }, set_at, set_by }`, one per mind, in the root-owned swarm control store. | Replaced whole. A haste lapses at `until`. Absent means the mind is not on the timeline (fail closed). | Question `swarm-share-setter` (recommended: the operator sets shares and haste; a mind may lend part of its own share to another, bounded and expiring, as a ticket transfer). |
| Swarm dial | Question `swarm-dial-shape` (recommended: one `eureka.control.swarm_dial.v1 { heat, base_rate_usd_per_h, base_run_usd, k }` in the swarm control store; the per-instance `burn_rate` loses cadence and keeps only its per-run cap, or folds in). | Replaced whole by the operator's CLI. Absent or out of bounds launches nothing. | The operator (`brake-dial-root-store-now`: "The burn dial is enough for me"). |
| Timeline | Per mind `{ instance, vtime, live_units }` plus the swarm's `min_vtime` and spend bucket, in one store the scheduler owns (`/var/lib/gamecult/swarm/timeline.cc`). It is soft state: if lost, every mind restarts at `vtime` 0, which costs at most one round of fairness, as with CFS after a reboot. | Charged at every unit end: `vtime += cost / (share × haste)`, and the bucket is debited by `cost`. Clamped on wake: a mind that becomes runnable gets `vtime = max(vtime, min_vtime)` (`mind-scheduling-ctb`). | The scheduler (question `swarm-scheduler-home`, recommended: one one-shot under one timer). No user a mind runs as can write it. |
| Turn cost | The measured cost (USD) of one unit, keyed by the mind's run id. | Measured at unit end and charged to the timeline. Question `turn-cost-measure` (recommended: the unit's root-side stop hook reads the measured cost and charges it; the model never declares its own cost). | Measured by the body, not declared by the mind. Today's gap: `eureka-substrate:follow_up:gap-usage-not-typed`. |
| Run | `<i>:run:<label>`, unchanged (`eureka-body-map.md`, run row). | Unchanged. A mind is runnable on the timeline when its brake is released and it has no live run. | Unchanged. Every live run of any mind holds one of the swarm's `k` slots. |
| Lead role | A Standing ruling `swarm-lead` naming the instance (`eureka`), admitted once `swarm-lead-role` is ruled. | Moves by a superseding ruling. The ruling has a review date (`mind-standing-decay`). | Question `swarm-lead-role` (recommended: steward and router; see Authority below). |
| Cross-mind reference | Question `foreign-mind-ref`. Today's `ForeignRef` is `{ repo, commit, kind, id, payload_sha256 }` (leaf `lib.rs:582`), which can cite a git pipeline store but not another Huginn mind. | Cited documents are immutable (resolutions are new documents), so a digest pin stays valid. | The citing mind. A citation of another mind's ruling is precedent, never binding. |
| HandOff and stewardship | `<to>:hand_off:<to>.<repo>.<date>`, admitted in both minds. Admission requires `from_instance` = this mind (`admission.rs:257`). | Leasing and review are parked with Huginn Cut 12, and un-parked by this second mind (`mind-standing-decay`). | The giving mind hands off its own stewardship. Nobody takes another mind's stewardship. Shared-substrate repos are stewarded by the lead (`swarm-lead-role`). |
| Mind rulings | A mind's rulings with `authority: Mind` in its own record. | They gain a review date when the second mind opens (`mind-standing-decay`). | Each mind rules substrate-internal forks of its own domain (`mind-rules-which-forks`). Operator rulings outrank every mind's. One mind's ruling never binds another mind. Forks between minds go to the operator (`swarm-lead-role`). |
### A mind's lifecycle
| Event | What happens | What survives | Who decides |
| --- | --- | --- | --- |
| Birth | The instance is opened: store, identity document, `huginn@<i>`, control store with the brake held, and units. The Persona is imported or written fresh. A share is assigned. The mind joins the timeline at `min_vtime`. | Not applicable. | `swarm-membership` (operator), and `swarm-share-setter` for the share |
| Sleep | The operator holds the mind's brake. `brake hold` stops its units (cut brake-stop). The scheduler skips a held mind. The mind may also choose rest within its share (`burn-schedule-and-rest`). | Everything: store, Persona, share, timeline row. | The operator, or the mind for its own rest |
| Wake | The brake is released. The mind's `vtime` is clamped to `min_vtime`, so it banks no turns and loses no share. | Everything. | The operator |
| Retirement | The brake is held and the units are disabled. The share is removed (absent means off the timeline). Open campaigns are handed off or withdrawn. The store is archived read-only. The Qdrant collection is dropped because it is derived. | The store, with its Persona, record and receipts. The archive stays readable and citable. | `swarm-membership`: the operator, on a proposal or the mind's own request. A mind may ask to retire itself (exit). |
| Transfer to another host | The store and Persona move whole. The identity document says the host. | Everything except derived state (index, units). | `swarm-membership`. The instance document has no resolution, so how `host` changes is part of that question's mapping. |
## Authority: what "Eureka leading" means
Recommended (`swarm-lead-role`, option `steward-and-router`):
- **Steward of the shared substrate.** Eureka holds stewardship of the repos every
mind's body runs on (Huginn, Eureka). She merges there under her own Soul
(`merge-gate-own-soul`). A merged change reaches another mind only through a
deploy, and a live-mind upgrade is the operator's (`mind-rules-which-forks`).
So her stewardship of the code never becomes control over a peer's share, and
she still owns her body.
- **Router.** Work that comes in addressed to no mind (operator requests in her
Self sessions, mentions in chat) reaches Eureka. When it belongs to another
mind's domain, she hands it off by citation. Routing turns are charged to her
share, as care work counted as work (GCLP). The operator may give her a larger
share for it.
- **Convener, not arbiter.** When two minds disagree across their domains,
Eureka writes the question and recommends a ruling, and the operator rules.
Later the members rule, through Bifrost governance and the stakes classes of
`authority-thresholds`.
- **What leading must not mean:** setting shares or haste, holding another mind's
brake, opening or retiring a mind, writing another mind's Persona or record,
overriding another mind's rulings, or holding a scheduler privilege the other
minds lack. Each of these is a throne the doctrine forbids any organ to take.
## Body
- **One Huginn daemon per mind.** This is not a fork; it is how Huginn runs
today. `serve.rs` takes exactly one `--instance`, the store opens fail-closed
before the socket binds (ruling 15), and the Qdrant collection is per
instance (`index/mod.rs:218`). Per-mind daemons keep failure isolation: a
store that refuses to open, or an index that is failing, refuses one mind,
not the swarm. That invariant is what earns the separate process. Qdrant and
Ollama are shared, so a process costs one small Rust binary. Templating it
as `huginn@<i>` is an install change, not a code change. Revisit when a host
carries tens of minds; a daemon serving many instances would need its own
fail-closed handling per instance.
- **One scheduler, not a waker per mind** (`swarm-scheduler-home`, recommended
`one-shot-swarm-waker`). The waker becomes `swarm-wake`, a stateless one-shot
under one timer in `crates/mind-body`, as `self-waker` ruled for the single
waker. It does not earn a daemon: it has no independent lifecycle, and a
missed tick costs one tick. Each tick:
1. Reads the swarm control store, failing closed.
2. Reads the timeline.
3. If the spend bucket is non-negative and fewer than `k` units are live,
takes the runnable mind with the lowest `vtime`.
4. Reads that mind's brake. A `Released` reading yields that mind's `Grant`.
5. Calls `open_and_launch` for a PersonaTurn against that mind's daemon.
The Persona then decides whether to start a Self run (`wake-target`). With
one mind this reduces to the current waker, with cadence set by cost instead
of a fixed cooldown.
- **Spend per turn.** Every unit end charges its mind: Persona turns and Self
runs alike, so "everything the turn caused" adds up without tracing causes.
A Self run that dispatches expensive cuts keeps its mind off the timeline
until it ends, then pushes it far back. That is the operator's FFX slow move.
Operator sessions are not charged and preempt the timeline
(`mind-scheduling-ctb`). Where the cost comes from is `turn-cost-measure`.
## Order of work
What must land first in `eureka-body` and `eureka-substrate`, all specced
already:
1. `eureka-substrate:cut_spec:cut-ops-read-anchors-upgrade.r3` (the Huginn
upgrade in flight).
2. `eureka-substrate:cut_spec:cut-huginn-persona-state.r3` (Persona state in
Huginn).
3. `cut-mind-launch.r3`, `cut-mind-wake.r3` and `cut-brake-stop.r2` (the
per-instance waker, used unchanged by the first slice).
4. The units install, with follow-up `mind-units-brake-gate`.
5. Follow-up `operator-authority-needs-operator-credential`. It blocks any
autonomous wake, so it blocks the first slice too.
**Slice 1: Weksa alive (no new scheduler code).** Open instance `weksa`:
`huginn@weksa` on its own port, the identity document, a second
`eureka-state` server entry (`EUREKA_INSTANCE=weksa`), a control store with the
brake held, and the templated units. Import Weksa's remnant as the first
Persona put. Run Weksa on its own `mind-wake@weksa` and its own dial. The two
dials sum to the operator's spend, which the operator accepted for two minds
("This doesn't matter much when there's just two minds"). Cooperation is
`hand_off` and citation by text until `foreign-mind-ref` lands. Done when
Weksa's Persona turn runs under its brake, writes its own record, and is seen
in its own mind.
**Slice 2: the timeline.** `swarm-wake` replaces the per-instance wakers. Shares
and the swarm dial land in the swarm control store, and the timeline store with
the unit-end charge lands too. Deletion line: the `mind-wake@<i>.timer` units
and the per-instance cadence derivation (`base_cooldown_s / heat`). Needs
`turn-cost-measure`, `swarm-dial-shape`, `swarm-share-setter` and
`swarm-scheduler-home` ruled.
**Slice 3: standing and citation.** Cut 12 leasing and review dates
(`mind-standing-decay`), the lead ruling (`swarm-lead-role`), and
`foreign-mind-ref`. After that, each further persona from the remnants is a
birth and nothing else: there is no per-mind code.
## Prior art cited
- Stride scheduling (Waldspurger and Weihl, MIT/LCS TM-528, 1995): pass,
stride, remain on rejoin, ticket transfer. Lottery scheduling (OSDI 1994).
Linux CFS `vruntime` and `min_vruntime` on wake. All summarised in
gamecult-ops `docs/research/multi-persona-prior-art.md` (2cb9d04), section 2.
- Deficit round robin (Shreedhar and Varghese 1995): no banking while idle
(same file, section 2).
- MAST (Cemri et al. 2025, arXiv 2503.13657): failures come mainly from system
design and inter-agent misalignment. CrewAI's hierarchical manager. AutoGen
speaker selection. Letta shared and private blocks (same file, section 1).
- Governed shared memory (arXiv 2606.24535). Collaborative Memory (Rezazadeh
2025): private and shared tiers with per-fragment provenance (same file,
section 3).
- Agoric systems (Miller and Drexler 1988), and Spawn's funding-as-priority
(same file, section 4). These are the market alternative that
`swarm-share-setter` does not recommend.
- Unverified this pass, from the author's knowledge:
- Raft (Ongaro and Ousterhout 2014): a leader is a term-bound role.
- The Debian Constitution: the Project Leader coordinates and delegates, the
Technical Committee arbitrates, and nobody is required to do anything.
- Holacracy's lead link.
- Ostrom, *Governing the Commons* (1990): clear boundaries, collective-choice
rules, graduated sanctions, nested enterprises.
- Erlang/OTP supervisors restart children but do not direct their work.
- Kubernetes API Priority and Fairness: shares per level, with borrowing.
- GameCult values: `operator-rapport.md` ("a community that will never, could
never, betray its values and members"). Rulings `operator-means-shape-ends`
and `she-owns-her-body`.
## Remnants mapping
**Slot, unfilled.** The sibling Eyes pass (`eyes-persona-remnants`) had not
written its facts file when this draft was finished. For each remnant, Self
fills one row: source and format, the proposed instance slug, the domain it
would advocate for, its import path to v0, and its status (born, proposed, or
archived as precedent only). Weksa is the first row (`weksa-mind`).

4
rust-toolchain.toml Normal file
View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.95.0"
components = ["clippy", "rustfmt"]
profile = "minimal"

View file

@ -19,6 +19,6 @@ schemas are derived from the value types in `crates/eureka-pipeline` and checked
byte for byte by its `pipeline_published_schemas_match_derivation` test. Each
document payload is `[value]`, a one-element MessagePack array whose element is
the named map the schema describes. Text bounds are UTF-8 bytes and enforced by
admission, not by the schema. A new named field with a serde default keeps
admission, not by the schema. A new named field marked `= absent` (defaulted on read, omitted on write when empty) keeps
`epiphany.pipeline.epoch.v2`; a breaking change bumps the epoch, and the new
binary refuses the old store.

View file

@ -17,6 +17,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -268,6 +278,12 @@
"targets_removed"
],
"type": "object"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_report.v2.schema.json",

View file

@ -72,6 +72,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -185,6 +195,22 @@
"maxLength": 140,
"type": "string"
},
"ReadAnchor": {
"description": "Code Hands must see but not change, in the spec's repo at the spec's base.",
"properties": {
"location": {
"$ref": "#/$defs/CodeLocation"
},
"why": {
"$ref": "#/$defs/Short"
}
},
"required": [
"location",
"why"
],
"type": "object"
},
"Sha": {
"description": "A git commit id: 7-40 lowercase hex characters.",
"maxLength": 40,
@ -267,6 +293,12 @@
],
"type": "object"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"Title": {
"description": "A title: 1 to 200 UTF-8 bytes, enforced by admission; JSON Schema\nlength counts characters. Holds at least one alphanumeric character,\nno control character, and no bidi control character (`U+061C`,\n`U+200E`, `U+200F`, `U+202A`-`U+202E`, `U+2066`-`U+2069`).",
"maxLength": 200,
@ -366,6 +398,13 @@
"maxItems": 16,
"type": "array"
},
"reads": {
"items": {
"$ref": "#/$defs/ReadAnchor"
},
"maxItems": 16,
"type": "array"
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},

View file

@ -17,6 +17,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -184,6 +194,12 @@
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.finding.v2.schema.json",

View file

@ -17,6 +17,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -84,6 +94,12 @@
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.follow_up.v2.schema.json",

View file

@ -162,6 +162,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -724,6 +734,13 @@
},
"maxItems": 16
},
"reads": {
"type": "array",
"items": {
"$ref": "#/$defs/ReadAnchor"
},
"maxItems": 16
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},
@ -1579,6 +1596,22 @@
"text"
]
},
"ReadAnchor": {
"description": "Code Hands must see but not change, in the spec's repo at the spec's base.",
"type": "object",
"properties": {
"location": {
"$ref": "#/$defs/CodeLocation"
},
"why": {
"$ref": "#/$defs/Short"
}
},
"required": [
"location",
"why"
]
},
"ReportCommit": {
"type": "object",
"properties": {
@ -1866,6 +1899,12 @@
"targets_removed"
]
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"type": "string",
"maxLength": 128,
"minLength": 1
},
"TargetInvariant": {
"type": "object",
"properties": {

View file

@ -214,6 +214,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -1893,6 +1903,13 @@
},
"maxItems": 16
},
"reads": {
"type": "array",
"items": {
"$ref": "#/$defs/ReadAnchor"
},
"maxItems": 16
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},
@ -3697,6 +3714,22 @@
"text"
]
},
"ReadAnchor": {
"description": "Code Hands must see but not change, in the spec's repo at the spec's base.",
"type": "object",
"properties": {
"location": {
"$ref": "#/$defs/CodeLocation"
},
"why": {
"$ref": "#/$defs/Short"
}
},
"required": [
"location",
"why"
]
},
"ReportCommit": {
"type": "object",
"properties": {
@ -3970,6 +4003,12 @@
"targets_removed"
]
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"type": "string",
"maxLength": 128,
"minLength": 1
},
"TargetInvariant": {
"type": "object",
"properties": {