diff --git a/AGENTS.md b/AGENTS.md index 8a5dbce..0f32062 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,12 @@ Studio's job, not Huginn's. it persists and admits an instance's mind, over `cultcache-rs` and `epiphany-pipeline` pinned by git rev. `huginn-daemon` and `eureka-state` are stubs with no dependencies; nothing here publishes or connects yet. -- Owned: an instance's memory documents, their admission, and their CultNet - publication. -- Depends on: Qdrant, directly. Unreachable Qdrant is a loud refusal, never a - fallback store. +- Owned: an instance's memory documents and their admission, in `huginn-mind`, + over a redb CultCache store at `/minds//mind.redb`. + Their CultNet publication is owned here and not built; `huginn-daemon` is a + stub. +- To depend on: Qdrant, directly, once retrieval exists. Unreachable Qdrant is + to be a loud refusal, never a fallback store. No crate connects to it yet. - Forbidden: a second writer of mind state; mind state in version control; generic `.cc` inspection, which is CultCache Studio's; renderer-owned truth. diff --git a/README.md b/README.md index b99689c..e942ff6 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,13 @@ Upstream: `https://github.com/GameCult/Huginn.git` - An instance owns its mind; Huginn owns the state. Huginn is the single writer of an instance's memory documents. No other service, script, or agent writes them. -- Memory documents are CultCache `.cc` state. Huginn persists them through - CultLib's Rust runtime and publishes them as typed documents over CultNet. -- Huginn depends on Qdrant directly for retrieval. When Qdrant is unreachable - Huginn refuses loudly; it does not fall back to a second store or a second - writer. +- Memory documents are CultCache state. `huginn-mind`, the one live crate, + persists them through CultLib's Rust CultCache into a redb store at + `/minds//mind.redb`. Publishing them over CultNet is + `huginn-daemon`'s, and that crate is a stub: nothing here publishes yet. +- Retrieval is to depend on Qdrant directly, refusing loudly when Qdrant is + unreachable rather than falling back to a second store or a second writer. + No crate opens that connection yet. - Mind state is not version-controlled. It lives in Huginn's store, not in any repository. diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 9c60ff2..8847d46 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -465,7 +465,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { match &staged.document { D::Campaign(campaign) => { if campaign.repos.is_empty() { - return Err(PipelineRefusal::FieldBound { field: "campaign.repos".into(), limit: 1, actual: 0 }.into()); + return Err(MindRefusal::EmptyRepos { campaign: key.into() }); } for repo in &campaign.repos { if docs.stewardship_of(mind, repo, None).is_none() { @@ -644,6 +644,12 @@ fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { /// The resolution matrix: which outcomes a subject kind admits, and of which /// kinds its referents must be. Admission's, never the leaf's. +/// +/// A subject resolves at most once, and a resolution is not itself +/// resolvable: `resolution` joins `campaign`, `cut_report`, `verdict`, +/// `instance` and `hand_off` in admitting no outcome at all. The key grammar +/// can still spell the resolution of a resolution; this is where it is +/// refused. (Q17, Self's default ruling pending the operator.) fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { use PipelineKind as K; use ResolutionOutcome as O; @@ -663,7 +669,6 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::FollowUp, O::Withdrawn { .. }) => true, (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), (K::Stewardship, O::Withdrawn { .. }) => true, - (K::Resolution, O::Withdrawn { .. }) => true, _ => false, }; fits @@ -1030,14 +1035,10 @@ mod tests { committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to: follow_up_ref.clone() })])); committed(admit(&mut world(), vec![resolution(follow_up_ref.clone(), fixed())])); committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())])); - let mut mind = world(); - committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); - let nested = r(K::Resolution, &id("resolution", "question.Q1")); - committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); // Refused, one per kind, and every non-resolvable kind. assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref, withdrawn())])), incompatible(K::Target, "Withdrawn")); - assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref, fixed())])), incompatible(K::Question, "Fixed")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref.clone(), fixed())])), incompatible(K::Question, "Fixed")); assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref, withdrawn())])), incompatible(K::Ruling, "Withdrawn")); assert_eq!( refusal(admit(&mut world(), vec![resolution(spec_ref.clone(), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), @@ -1062,8 +1063,16 @@ mod tests { refusal(admit(&mut world(), vec![resolution(stewardship_ref, ResolutionOutcome::Recorded { reason: "no".into() })])), incompatible(K::Stewardship, "Recorded") ); + // A resolution records that a subject resolved; it is not itself a + // subject. The nested key exists and is well formed, and admission + // refuses it anyway, whatever the outcome. let mut mind = world(); - committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); + let nested = r(K::Resolution, &id("resolution", "question.Q1")); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])), + incompatible(K::Resolution, "Withdrawn") + ); assert_eq!( refusal(admit(&mut mind, vec![resolution(nested.clone(), superseded(&[nested]))])), incompatible(K::Resolution, "Superseded") @@ -1273,6 +1282,15 @@ mod tests { MindRefusal::RepoNotStewarded { repo: REPO.into() } ); committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO])])); + // A campaign of no repos is the organ's refusal, not a leaf bound: + // "every repo is stewarded" is vacuous and the campaign steers + // nothing. + let D::Campaign(mut empty) = campaign(&[]) else { panic!() }; + empty.slug = slug("other"); + assert_eq!( + refusal(admit(&mut mind, vec![D::Campaign(empty)])), + MindRefusal::EmptyRepos { campaign: "other:campaign:self".into() } + ); let mut spec = cut_spec("1", 1); spec.repo = repo(OTHER_REPO); assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 926b92d..12f7961 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -185,20 +185,34 @@ fn refuse_foreign_types(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { Ok(()) } -/// Step 3: a non-empty store carries exactly one epoch record, at the epoch -/// this binary writes. +/// Step 3: a non-empty store carries exactly one epoch record, keyed by the +/// epoch it names, at the epoch this binary writes. A store with no epoch +/// record at all is `MissingIdentity`; every other defect is `ForeignEpoch`, +/// whose `found` names the defect: the stored epoch when the value is +/// foreign, the record's key when a record is keyed by anything else, and the +/// record count rendered as `" records"` when more than one is stored. The +/// count is decided before any value is read, so a current record beside a +/// foreign one refuses the same way whichever the store returns first. fn refuse_foreign_epoch(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { if raw.is_empty() { return Ok(()); } + let foreign = |found: String| MindRefusal::ForeignEpoch { found, expected: PIPELINE_SCHEMA_EPOCH.into() }; let mut records = raw.iter().filter(|envelope| envelope.r#type == HuginnMindEpoch::TYPE); let Some(record) = records.next() else { return Err(MindRefusal::MissingIdentity); }; + let extra = records.count(); + if extra > 0 { + return Err(foreign(format!("{} records", extra + 1))); + } + if record.key != PIPELINE_SCHEMA_EPOCH { + return Err(foreign(record.key.clone())); + } let record: HuginnMindEpoch = rmp_serde::from_slice(&record.payload) .map_err(|error| MindRefusal::Unavailable { detail: format!("epoch record does not decode: {error}") })?; if record.schema_epoch != PIPELINE_SCHEMA_EPOCH { - return Err(MindRefusal::ForeignEpoch { found: record.schema_epoch, expected: PIPELINE_SCHEMA_EPOCH.into() }); + return Err(foreign(record.schema_epoch)); } Ok(()) } @@ -260,10 +274,18 @@ mod tests { envelope } - fn foreign_epoch() -> CultCacheEnvelope { + const FOREIGN_EPOCH: &str = "epiphany.pipeline.epoch.v0"; + + /// An epoch record under an arbitrary key naming an arbitrary epoch, so a + /// key defect and a value defect can be planted apart. + fn epoch_record(key: &str, schema_epoch: &str) -> CultCacheEnvelope { let cache = schema_cache().unwrap(); - let record = HuginnMindEpoch { schema_epoch: "epiphany.pipeline.epoch.v0".into() }; - cache.prepare_entry_named("epiphany.pipeline.epoch.v0", &record).unwrap().0 + let record = HuginnMindEpoch { schema_epoch: schema_epoch.into() }; + cache.prepare_entry_named(key, &record).unwrap().0 + } + + fn foreign_epoch() -> CultCacheEnvelope { + epoch_record(FOREIGN_EPOCH, FOREIGN_EPOCH) } #[test] @@ -276,12 +298,27 @@ mod tests { MindRefusal::ForeignStore { r#type: "epiphany.runtime_spine.v47".into() }, ), // Step 3 before step 4: a foreign epoch with no identity at all. + // Its key is foreign too, which is what refuses it here. ( vec![foreign_epoch()], - MindRefusal::ForeignEpoch { - found: "epiphany.pipeline.epoch.v0".into(), - expected: PIPELINE_SCHEMA_EPOCH.into(), - }, + MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 3: the value, under the one key the record may have. + ( + vec![epoch_record(PIPELINE_SCHEMA_EPOCH, FOREIGN_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 3: the current epoch under any other key is not the epoch + // record, whatever it says about itself. + ( + vec![epoch_record("not-the-epoch", PIPELINE_SCHEMA_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: "not-the-epoch".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 3: two records are no record, counted before either value + // is read. + ( + vec![epoch(), epoch_record("second", PIPELINE_SCHEMA_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: "2 records".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, ), // Step 3: an identity without an epoch record. (vec![prepare(&instance(INSTANCE))], MindRefusal::MissingIdentity), @@ -301,6 +338,15 @@ mod tests { assert_eq!(store.pull_count(), 1, "{expected:?}: refused before attaching"); assert_eq!(store.rows(), before, "{expected:?}: bytes unchanged"); } + // A current record beside a foreign one is refused on the count, not + // on whichever of the two the store happens to return first. + let pair = (epoch(), foreign_epoch()); + for rows in [vec![pair.0.clone(), pair.1.clone()], vec![pair.1, pair.0]] { + assert_eq!( + refuse_foreign_epoch(&rows).err(), + Some(MindRefusal::ForeignEpoch { found: "2 records".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }) + ); + } // A store that passes every gate attaches, which is the second pull. let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); let mind = Mind::open_with(store.clone(), &yggdrasil).unwrap(); diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index cba00fa..571f1a2 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -35,6 +35,7 @@ pub enum MindRefusal { InvalidOptions { question: String }, InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, + EmptyRepos { campaign: String }, RepoNotStewarded { repo: String }, RepoNotInCampaign { repo: String }, CutReportWithoutSpec { report: String },