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:
parent
ca30d3eae5
commit
1cfa81d571
5 changed files with 96 additions and 27 deletions
10
AGENTS.md
10
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 `<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.
|
||||
|
||||
|
|
|
|||
12
README.md
12
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
|
||||
`<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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue