Close the epoch record, the minted leaf refusal and the nested resolution

Soul's Cut 8 pass found three rules the code only half held and one
document that promised more than the crate does.

The opener's step 3 said "exactly one epoch record at the current epoch"
and checked neither the count nor the key: a record keyed anything at all
was read as the epoch record, and a second one was never looked at, so a
current record beside a foreign one passed or refused on nothing but the
order the store returned them in. Step 3 now counts before it reads,
refuses a record under any key but the epoch, and names the defect in
`found`: the stored epoch, the offending key, or the count.

The campaign row minted `PipelineRefusal::FieldBound` for an empty
`repos`. The organ does not get to invent the leaf's refusals; an empty
`repos` is admission's own rule and is now `MindRefusal::EmptyRepos`.

The matrix admitted `Withdrawn` on a resolution, which made a subject
resolvable twice over: withdraw the resolution and the subject is open
again. A subject resolves at most once, and a resolution is not itself a
subject, so `resolution` joins `campaign`, `cut_report`, `verdict`,
`instance` and `hand_off` as not resolvable. The key grammar still spells
a resolution of a resolution; admission refuses it here. Self's default
ruling on Q17, standing until the operator rules.

`README.md` and `AGENTS.md` claimed CultNet publication and a Qdrant
connection in the present tense and called the store `.cc`. The daemon is
a stub, nothing connects to Qdrant, and the live crate writes
`mind.redb`. They say so now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 17:53:08 +02:00
commit 1cfa81d571
5 changed files with 96 additions and 27 deletions

View file

@ -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 `<state_root>/minds/<instance>/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.

View file

@ -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
`<state_root>/minds/<instance>/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.

View file

@ -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() });

View file

@ -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 `"<n> 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();

View file

@ -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 },