Merge eureka-substrate/readback-gate: one O(N) upgrade read-back gate in huginn-mind, pinned by CI fixtures (Soul s2 holds)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
2512c8a42f
5 changed files with 255 additions and 180 deletions
|
|
@ -18,13 +18,6 @@ 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. 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
|
||||
stored key, type and payload bytes. Take a copy of the store (the test copies
|
||||
the file again before opening it, so the copy may be read-only), never open the
|
||||
live one:
|
||||
|
||||
```sh
|
||||
sudo cp /var/lib/gamecult/huginn/minds/eureka/mind.redb "$SNAP/minds/eureka/mind.redb"
|
||||
HUGINN_MIND_SNAPSHOT="$SNAP" cargo test -p eureka-pipeline -- --ignored --exact tests::stored_documents_read_back --nocapture
|
||||
```
|
||||
The real-store gate is huginn-mind's `#[ignore]` `query::tests::stored_documents_read_back`,
|
||||
which checks these stored bytes and the read path together (the leaf cannot see the read
|
||||
path); the Huginn Upgrade runbook in gamecult-ops runs it.
|
||||
|
|
|
|||
|
|
@ -2747,79 +2747,6 @@ mod tests {
|
|||
assert!(!Sha("5f98228a".into()).names_same_commit(&Sha("5f98228b".into())), "equal length, differing");
|
||||
}
|
||||
|
||||
/// stored-documents-valid and stored-bytes-reproduce, on the real mind.
|
||||
/// `HUGINN_MIND_SNAPSHOT` is a copy of a state root holding
|
||||
/// `minds/eureka/mind.redb`; the test copies the store file into a scratch
|
||||
/// directory first (redb opens read-write), so the snapshot may be
|
||||
/// read-only. Read through cultcache-rs's own store. Each pipeline
|
||||
/// document must re-prepare to its stored envelope: key, type and payload
|
||||
/// bytes.
|
||||
#[test]
|
||||
#[ignore = "needs HUGINN_MIND_SNAPSHOT, a copy of a Huginn state root; see the crate README"]
|
||||
fn stored_documents_read_back() {
|
||||
use cultcache_rs::{CacheBackingStore, OwnedRedbMessagePackBackingStore};
|
||||
let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names the snapshot state root");
|
||||
let scratch = std::env::temp_dir().join(format!("eureka-pipeline-readback-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&scratch).expect("scratch directory");
|
||||
let working = scratch.join("mind.redb");
|
||||
std::fs::copy(Path::new(&root).join("minds").join("eureka").join("mind.redb"), &working).expect("the snapshot copies");
|
||||
let store = OwnedRedbMessagePackBackingStore::new(&working).expect("the snapshot opens");
|
||||
let cache = schema_cache().expect("the registrar registers");
|
||||
let envelopes = store.pull_all().expect("the snapshot reads");
|
||||
let mut stored = Vec::new();
|
||||
for envelope in &envelopes {
|
||||
match PipelineDocument::decode(envelope) {
|
||||
Ok(document) => {
|
||||
assert_eq!(document.validate(), Ok(()), "{}: valid", envelope.key);
|
||||
assert_eq!(pipeline_key(&document).as_deref(), Ok(envelope.key.as_str()), "{}: key derives byte for byte", envelope.key);
|
||||
let again = document.prepare(&cache).unwrap_or_else(|error| panic!("{}: does not re-prepare: {error}", envelope.key));
|
||||
assert_eq!(
|
||||
(&again.key, &again.r#type, &again.payload),
|
||||
(&envelope.key, &envelope.r#type, &envelope.payload),
|
||||
"{}: re-encodes to the stored bytes",
|
||||
envelope.key
|
||||
);
|
||||
stored.push((document.kind(), envelope.key.clone()));
|
||||
}
|
||||
Err(PipelineRefusal::ForeignStore { .. }) => {}
|
||||
Err(error) => panic!("{}: does not decode: {error}", envelope.key),
|
||||
}
|
||||
}
|
||||
assert!(stored.len() > 200, "{} pipeline documents read", stored.len());
|
||||
let mut subjects = 0;
|
||||
let resolvable = |kind: &PipelineKind| !matches!(kind, PipelineKind::Resolution | PipelineKind::Campaign | PipelineKind::Instance);
|
||||
for (kind, key) in stored.iter().filter(|(kind, _)| resolvable(kind)) {
|
||||
let one = resolved_at(*kind, key, 1).unwrap_or_else(|error| panic!("{key}: no resolution keys: {error}"));
|
||||
let two = resolved_at(PipelineKind::Resolution, &one, 1).unwrap_or_else(|error| panic!("{key}: no withdrawal keys: {error}"));
|
||||
for derived in [&one, &two] {
|
||||
pipeline_id("read_back", derived, PipelineKind::Resolution).unwrap_or_else(|error| panic!("{derived}: does not parse: {error}"));
|
||||
}
|
||||
subjects += 1;
|
||||
}
|
||||
for (_, key) in stored.iter().filter(|(kind, _)| *kind == PipelineKind::Resolution) {
|
||||
resolved_at(PipelineKind::Resolution, key, 1).unwrap_or_else(|error| panic!("{key}: cannot be withdrawn: {error}"));
|
||||
}
|
||||
// The six findings that could not be resolved before this cut.
|
||||
let stuck = [
|
||||
"cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed",
|
||||
"cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry",
|
||||
"cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff",
|
||||
"cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned",
|
||||
"cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick",
|
||||
"cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention",
|
||||
];
|
||||
for label in stuck {
|
||||
let found = stored
|
||||
.iter()
|
||||
.find(|(kind, key)| *kind == PipelineKind::Finding && key.ends_with(label))
|
||||
.unwrap_or_else(|| panic!("{label}: not in the snapshot"));
|
||||
let one = resolved_at(PipelineKind::Finding, &found.1, 1).unwrap_or_else(|error| panic!("{label}: still stuck: {error}"));
|
||||
assert!(one.split(':').nth(2).is_some_and(|local| local.len() > SUBJECT_LOCAL_MAX), "{label}: the old bound refused it");
|
||||
}
|
||||
eprintln!("read back {} pipeline documents, {subjects} subjects", stored.len());
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
}
|
||||
|
||||
/// The total bound, in the one place it lives: parts that are each a legal
|
||||
/// label compose a local wider than 64 bytes and are refused as a whole.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@ fn snapshot(cache: &CultCache) -> Vec<CultCacheEnvelope> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::admission::PipelineAdmissionOutcome;
|
||||
use crate::fixtures::{INSTANCE, epoch, instance, prepare, slug};
|
||||
use crate::store::test_stores::MemoryStore;
|
||||
use cultcache_rs::CacheBackingStore;
|
||||
|
|
@ -786,94 +785,4 @@ mod tests {
|
|||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/// stored-documents-valid and every-subject-resolvable, against the real
|
||||
/// mind: every stored document views back unchanged; the six findings the
|
||||
/// 64-byte bound left unresolvable take a resolution; and a stored
|
||||
/// resolution whose withdrawal key is over 64 bytes is withdrawn. Run it
|
||||
/// on a scratch copy of the state root only: it writes.
|
||||
#[test]
|
||||
#[ignore = "reads and writes the scratch copy of a live state root named by HUGINN_MIND_SNAPSHOT"]
|
||||
fn snapshot_admits_and_reads_back() {
|
||||
use crate::fixtures::{admit, r, resolution, withdrawn};
|
||||
use eureka_pipeline::{PipelineRef, ResolutionOutcome, Short, pipeline_key};
|
||||
|
||||
let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names a scratch copy of the state root");
|
||||
let mut mind = Mind::open(Path::new(&root), &slug("eureka")).unwrap();
|
||||
|
||||
// The census is taken apart from the loop that checks each document.
|
||||
let pipeline_envelopes = mind
|
||||
.envelopes()
|
||||
.iter()
|
||||
.filter(|envelope| PipelineKind::ALL.iter().any(|kind| kind.type_id() == envelope.r#type))
|
||||
.count();
|
||||
assert!(pipeline_envelopes > 0, "the snapshot held no pipeline document");
|
||||
|
||||
let mut viewed = 0;
|
||||
let mut resolutions = Vec::new();
|
||||
for envelope in mind.envelopes() {
|
||||
let Some(kind) = PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == envelope.r#type) else { continue };
|
||||
let stored = PipelineDocument::decode(envelope).unwrap();
|
||||
stored.validate().unwrap_or_else(|refusal| panic!("{} no longer validates: {refusal:?}", envelope.key));
|
||||
let rekeyed = pipeline_key(&stored).unwrap_or_else(|refusal| panic!("{} no longer keys: {refusal:?}", envelope.key));
|
||||
assert_eq!(rekeyed, envelope.key, "stored key differs from the key the leaf computes");
|
||||
let view = mind
|
||||
.view(&PipelineRef { kind, id: Short(envelope.key.clone()) })
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{} does not view", envelope.key));
|
||||
assert_eq!(view.document, stored, "{}", envelope.key);
|
||||
if let PipelineDocument::Resolution(held) = &stored
|
||||
&& !matches!(held.outcome, eureka_pipeline::ResolutionOutcome::Withdrawn { .. })
|
||||
&& !envelope.key.contains(":resolution:resolution.")
|
||||
{
|
||||
resolutions.push(envelope.key.clone());
|
||||
}
|
||||
viewed += 1;
|
||||
}
|
||||
assert_eq!(viewed, pipeline_envelopes, "every pipeline envelope validates, rekeys and views back");
|
||||
println!("validated, rekeyed and viewed {viewed} stored documents unchanged");
|
||||
|
||||
let stuck = [
|
||||
"cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed",
|
||||
"cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry",
|
||||
"cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff",
|
||||
"cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned",
|
||||
"cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick",
|
||||
"cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention",
|
||||
];
|
||||
let mut resolved = 0;
|
||||
for local in stuck {
|
||||
let subject = r(PipelineKind::Finding, &format!("idunn-watchdog:finding:{local}"));
|
||||
let outcome = admit(&mut mind, vec![resolution(subject, ResolutionOutcome::Recorded { reason: "snapshot read-back".into() })]);
|
||||
match outcome {
|
||||
PipelineAdmissionOutcome::Committed { writes, .. } => {
|
||||
assert_eq!(writes.len(), 1, "{local}");
|
||||
resolved += 1;
|
||||
}
|
||||
other => panic!("stuck finding {local} was not resolved: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(resolved, stuck.len(), "every stuck finding resolves");
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for key in resolutions {
|
||||
let withdrawal = resolution(r(PipelineKind::Resolution, &key), withdrawn());
|
||||
let withdrawal_key = pipeline_key(&withdrawal).unwrap_or_else(|refusal| panic!("withdrawal of {key} does not key: {refusal:?}"));
|
||||
if withdrawal_key.rsplit(':').next().unwrap().len() > 64 {
|
||||
candidates.push((key, withdrawal));
|
||||
}
|
||||
}
|
||||
assert!(!candidates.is_empty(), "no stored resolution had a withdrawal key over 64 bytes");
|
||||
let wanted = candidates.len();
|
||||
let mut withdrew = 0;
|
||||
for (key, withdrawal) in candidates {
|
||||
match admit(&mut mind, vec![withdrawal]) {
|
||||
PipelineAdmissionOutcome::Committed { .. } => withdrew += 1,
|
||||
other => panic!("withdrawal of {key} was not committed: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(withdrew, wanted, "every candidate withdraws");
|
||||
println!("withdrew {withdrew} resolutions whose withdrawal keys exceed 64 bytes");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,13 @@ use cultnet_rs::{
|
|||
use eureka_pipeline::{
|
||||
ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, OrgRepo,
|
||||
PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha,
|
||||
Short, Slug, Title,
|
||||
Short, Slug, Title, pipeline_key,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::docs::{CitationRole, Docs, Held, citations};
|
||||
use crate::mind::Mind;
|
||||
use crate::docs::{CitationRole, Docs, Held, citations, kind_of_type};
|
||||
use crate::mind::{Mind, schema_cache};
|
||||
use crate::receipt::{self, PipelineProvenance};
|
||||
use crate::refusal::MindRefusal;
|
||||
use crate::rows::{SelectionRow, Vocabulary, refuse_values};
|
||||
|
|
@ -394,6 +394,12 @@ impl Reader {
|
|||
self.docs.image.iter().find(|held| held.kind == kind && held.key == key)
|
||||
}
|
||||
|
||||
/// The lookup `Mind::view` answers through, so a caller holding one `Reader`
|
||||
/// views many documents from one snapshot.
|
||||
fn view(&self, id: &PipelineRef) -> Result<Option<PipelineDocumentView>, MindRefusal> {
|
||||
self.held(id.kind, &id.id.0).map(|held| self.view_of(held)).transpose()
|
||||
}
|
||||
|
||||
fn view_of(&self, held: &Held) -> Result<PipelineDocumentView, MindRefusal> {
|
||||
let admission = self.facts.of(held)?;
|
||||
let status = match self.docs.closing_resolution(held.kind, &held.key) {
|
||||
|
|
@ -455,8 +461,57 @@ impl<S: MindStore> Mind<S> {
|
|||
/// vocabulary for one rule.
|
||||
pub fn view(&self, id: &PipelineRef) -> Result<Option<PipelineDocumentView>, MindRefusal> {
|
||||
id.validate_ref()?;
|
||||
Reader::at(self, receipt::head(self)?)?.view(id)
|
||||
}
|
||||
|
||||
/// The upgrade gate (ruling readback-gate-and-materialized-image): every
|
||||
/// stored pipeline document decodes, validates, rekeys, re-prepares to its
|
||||
/// stored key, type and payload bytes, and views back unchanged through the
|
||||
/// read path `view` uses; every pipeline write a receipt names is stored,
|
||||
/// and every stored document has its receipt. Returns how many documents
|
||||
/// it read back; an empty store is a fault. One `Reader` serves every view,
|
||||
/// because `Reader::at` is the expensive build. Admits nothing. A fault
|
||||
/// names the document's key and what failed, never its content.
|
||||
pub fn read_back(&self) -> Result<usize, MindRefusal> {
|
||||
self.read_back_through(Reader::view)
|
||||
}
|
||||
|
||||
/// The gate's one body; `lookup` is the read path under test, which tests
|
||||
/// replace with a lying one to prove the view comparison refuses.
|
||||
fn read_back_through(
|
||||
&self,
|
||||
lookup: impl Fn(&Reader, &PipelineRef) -> Result<Option<PipelineDocumentView>, MindRefusal>,
|
||||
) -> Result<usize, MindRefusal> {
|
||||
let cache = schema_cache()?;
|
||||
let reader = Reader::at(self, receipt::head(self)?)?;
|
||||
reader.held(id.kind, &id.id.0).map(|held| reader.view_of(held)).transpose()
|
||||
let mut count = 0;
|
||||
for envelope in self.envelopes() {
|
||||
let Some(kind) = kind_of_type(&envelope.r#type) else { continue };
|
||||
let key = &envelope.key;
|
||||
let stored = PipelineDocument::decode(envelope).map_err(|_| integrity(&envelope.r#type, key, "does not decode"))?;
|
||||
stored.validate().map_err(|_| integrity(&envelope.r#type, key, "no longer validates"))?;
|
||||
if pipeline_key(&stored).map_err(|_| integrity(&envelope.r#type, key, "no longer keys"))? != *key {
|
||||
return Err(integrity(&envelope.r#type, key, "is stored under a key the leaf no longer computes"));
|
||||
}
|
||||
let again = stored.prepare(&cache).map_err(|_| integrity(&envelope.r#type, key, "does not re-prepare"))?;
|
||||
if (&again.key, &again.r#type, &again.payload) != (key, &envelope.r#type, &envelope.payload) {
|
||||
return Err(integrity(&envelope.r#type, key, "does not re-encode to its stored bytes"));
|
||||
}
|
||||
let viewed = lookup(&reader, &PipelineRef { kind, id: Short(key.clone()) })?;
|
||||
if viewed.map(|view| view.document).as_ref() != Some(&stored) {
|
||||
return Err(integrity(&envelope.r#type, key, "does not view back as stored"));
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
for (type_id, key) in reader.facts.0.keys() {
|
||||
if kind_of_type(type_id).is_some() && self.raw_envelope(type_id, key).is_none() {
|
||||
return Err(integrity(type_id, key, "is named by a receipt but not stored"));
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(MindRefusal::Unavailable { detail: "the store holds no pipeline document".into() });
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// One typed selection over the mind, as of one snapshot.
|
||||
|
|
@ -678,7 +733,6 @@ mod tests {
|
|||
use crate::fixtures::*;
|
||||
use crate::receipt::Faculty;
|
||||
use cultnet_rs::{Citation, FieldPredicate, Incoming, RecordRef};
|
||||
use crate::mind::schema_cache;
|
||||
use crate::store::test_stores::MemoryStore;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use eureka_pipeline::{
|
||||
|
|
@ -2118,4 +2172,196 @@ mod tests {
|
|||
assert_eq!(found.id, r(K::Question, &q1), "the id travels with the document");
|
||||
assert_eq!(Some(found.document), mind.get(K::Question, &q1).unwrap());
|
||||
}
|
||||
|
||||
/// A store of 135 pipeline documents: past any sampling cutoff, with one
|
||||
/// resolution so a view carries a closing status. Returns its rows.
|
||||
fn gate_rows() -> Vec<cultcache_rs::CultCacheEnvelope> {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = opened(store.clone(), INSTANCE);
|
||||
seed(&mut mind);
|
||||
for chunk in (1..=130).collect::<Vec<u32>>().chunks(10) {
|
||||
committed(admit(&mut mind, chunk.iter().map(|n| question_n(*n)).collect()));
|
||||
}
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())]));
|
||||
store.rows()
|
||||
}
|
||||
|
||||
fn read_back_of(rows: Vec<cultcache_rs::CultCacheEnvelope>) -> Result<usize, MindRefusal> {
|
||||
let store = MemoryStore::new();
|
||||
for row in rows {
|
||||
store.plant(row);
|
||||
}
|
||||
Mind::open_with(store, &slug(INSTANCE))?.read_back()
|
||||
}
|
||||
|
||||
/// The stored question the faults land on: the last question in identity
|
||||
/// order, with more than 100 pipeline documents before it, so a gate that
|
||||
/// stops early or samples never reaches it.
|
||||
fn last_question(rows: &[cultcache_rs::CultCacheEnvelope]) -> usize {
|
||||
let at = rows.iter().rposition(|row| row.r#type == K::Question.type_id()).unwrap();
|
||||
let before = rows[..at].iter().filter(|row| crate::docs::kind_of_type(&row.r#type).is_some()).count();
|
||||
assert!(before > 100, "the fixture is past any cutoff: {before}");
|
||||
at
|
||||
}
|
||||
|
||||
fn refused_with(rows: Vec<cultcache_rs::CultCacheEnvelope>, fragment: &str, target: &str) {
|
||||
match read_back_of(rows) {
|
||||
Err(MindRefusal::Unavailable { detail }) => {
|
||||
assert!(detail.contains(fragment), "refused for another reason: {detail}");
|
||||
assert!(detail.contains(target) || fragment == "holds no pipeline document", "the fault names the key: {detail}");
|
||||
}
|
||||
other => panic!("expected an Unavailable refusal containing {fragment:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate over a clean store reads back every pipeline document, and
|
||||
/// says how many: the exact count, so a gate that stops early is seen.
|
||||
#[test]
|
||||
fn read_back_accepts_a_clean_store_and_counts_every_document() {
|
||||
assert_eq!(read_back_of(gate_rows()).unwrap(), 135);
|
||||
}
|
||||
|
||||
fn faulted(fault: impl FnOnce(&mut Vec<cultcache_rs::CultCacheEnvelope>, usize)) -> (Vec<cultcache_rs::CultCacheEnvelope>, String) {
|
||||
let mut rows = gate_rows();
|
||||
let at = last_question(&rows);
|
||||
let key = rows[at].key.clone();
|
||||
fault(&mut rows, at);
|
||||
(rows, key)
|
||||
}
|
||||
|
||||
/// The stored question, decoded, for a test to bend.
|
||||
fn stored_question(row: &cultcache_rs::CultCacheEnvelope) -> eureka_pipeline::PipelineQuestion {
|
||||
match D::decode(row).unwrap() {
|
||||
D::Question(question) => question,
|
||||
other => panic!("not a question: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_an_undecodable_payload() {
|
||||
let (rows, _) = faulted(|rows, at| rows[at].payload = vec![0xc1]);
|
||||
assert!(matches!(read_back_of(rows), Err(MindRefusal::Document(_) | MindRefusal::Unavailable { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_a_field_past_its_bound() {
|
||||
let (rows, key) = faulted(|rows, at| {
|
||||
let mut question = stored_question(&rows[at]);
|
||||
question.title = "x".repeat(300).as_str().into();
|
||||
rows[at].payload = D::Question(question).prepare(&schema_cache().unwrap()).unwrap().payload;
|
||||
});
|
||||
refused_with(rows, "no longer validates", &key);
|
||||
}
|
||||
|
||||
/// A document stored under a key other than the one the leaf computes for
|
||||
/// it, with its receipt renamed to match, so only the re-key check can see it.
|
||||
#[test]
|
||||
fn read_back_refuses_a_changed_key() {
|
||||
let (rows, key) = faulted(|rows, at| {
|
||||
let old = rows[at].key.clone();
|
||||
rows[at].key.push('x');
|
||||
let mind = Mind::open_with(
|
||||
{
|
||||
let store = MemoryStore::new();
|
||||
rows.iter().for_each(|row| store.plant(row.clone()));
|
||||
store
|
||||
},
|
||||
&slug(INSTANCE),
|
||||
)
|
||||
.unwrap();
|
||||
let mut receipt =
|
||||
mind.receipts().unwrap().into_iter().find(|receipt| receipt.writes.iter().any(|write| write.document_key == old)).unwrap();
|
||||
receipt.writes.iter_mut().filter(|write| write.document_key == old).for_each(|write| write.document_key.push('x'));
|
||||
let renamed = schema_cache().unwrap().prepare_entry_named(&receipt.receipt_id, &receipt).unwrap().0;
|
||||
let at = rows.iter().position(|row| row.r#type == renamed.r#type && row.key == renamed.key).unwrap();
|
||||
rows[at] = renamed;
|
||||
});
|
||||
refused_with(rows, "key the leaf no longer computes", &key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_a_trailing_byte() {
|
||||
let (rows, key) = faulted(|rows, at| rows[at].payload.push(0x00));
|
||||
refused_with(rows, "re-encode", &key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_a_payload_that_decodes_but_was_encoded_differently() {
|
||||
let (rows, key) = faulted(|rows, at| {
|
||||
// The same array, its header in the wider array16 form: it decodes to the
|
||||
// same document and encodes to different bytes.
|
||||
let payload = &rows[at].payload;
|
||||
assert_eq!(payload[0] & 0xf0, 0x90, "the payload opens with a fixarray header");
|
||||
let mut wider = vec![0xdc, 0x00, payload[0] & 0x0f];
|
||||
wider.extend_from_slice(&payload[1..]);
|
||||
assert_eq!(D::decode(&cultcache_rs::CultCacheEnvelope { payload: wider.clone(), ..rows[at].clone() }).unwrap(), D::Question(stored_question(&rows[at])));
|
||||
rows[at].payload = wider;
|
||||
});
|
||||
refused_with(rows, "re-encode", &key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_a_foreign_type() {
|
||||
let mut rows = gate_rows();
|
||||
let at = last_question(&rows);
|
||||
rows[at].r#type = "epiphany.pipeline.finding.v9".into();
|
||||
assert!(matches!(read_back_of(rows), Err(MindRefusal::ForeignStore { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_a_document_stored_under_the_wrong_type() {
|
||||
let (rows, _) = faulted(|rows, at| rows[at].r#type = K::Verdict.type_id().into());
|
||||
assert!(matches!(read_back_of(rows), Err(MindRefusal::Document(_) | MindRefusal::Unavailable { .. })));
|
||||
}
|
||||
|
||||
/// The read path is what the gate compares the stored bytes against: a path
|
||||
/// that answers nothing, or another document, is refused.
|
||||
#[test]
|
||||
fn read_back_refuses_a_read_path_that_does_not_return_the_stored_document() {
|
||||
let mind = |rows: Vec<cultcache_rs::CultCacheEnvelope>| {
|
||||
let store = MemoryStore::new();
|
||||
rows.into_iter().for_each(|row| store.plant(row));
|
||||
Mind::open_with(store, &slug(INSTANCE)).unwrap()
|
||||
};
|
||||
let absent = mind(gate_rows()).read_back_through(|_, _| Ok(None));
|
||||
assert!(matches!(&absent, Err(MindRefusal::Unavailable { detail }) if detail.contains("does not view back")), "{absent:?}");
|
||||
let other = mind(gate_rows()).read_back_through(|reader, id| {
|
||||
let first = reader.docs.image.iter().find(|held| held.kind == id.kind).unwrap();
|
||||
reader.view_of(first).map(Some)
|
||||
});
|
||||
assert!(matches!(&other, Err(MindRefusal::Unavailable { detail }) if detail.contains("does not view back")), "{other:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_an_empty_store() {
|
||||
refused_with(Vec::new(), "holds no pipeline document", "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_a_receipted_document_that_is_not_stored() {
|
||||
let (rows, key) = faulted(|rows, at| {
|
||||
rows.remove(at);
|
||||
});
|
||||
refused_with(rows, "named by a receipt but not stored", &key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_back_refuses_a_stored_document_with_no_receipt() {
|
||||
let (rows, _) = faulted(|rows, _| rows.push(prepare(&question_n(900))));
|
||||
refused_with(rows, "has no commit receipt", "Q900");
|
||||
}
|
||||
|
||||
/// The upgrade gate on a real store (ruling readback-gate-and-materialized-image):
|
||||
/// the one function `Mind::read_back` over a scratch copy of a state root.
|
||||
/// Opens the store read-write, so run it on a copy, never the live file.
|
||||
/// Measured about 30 s in debug at 11,328 documents.
|
||||
#[test]
|
||||
#[ignore = "needs HUGINN_MIND_SNAPSHOT, a scratch copy of a Huginn state root"]
|
||||
fn stored_documents_read_back() {
|
||||
let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names a scratch copy of the state root");
|
||||
let started = std::time::Instant::now();
|
||||
let mind = Mind::open(std::path::Path::new(&root), &Slug("eureka".into())).unwrap();
|
||||
let count = mind.read_back().unwrap();
|
||||
println!("read back {count} stored documents in {:?}", started.elapsed());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`.
|
|||
| Kind | Identity (what names it) | Lifecycle (what happens over time) | Authority (who decides) |
|
||||
| --- | --- | --- | --- |
|
||||
| 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`). |
|
||||
| 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 huginn-mind's opt-in `query::tests::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 }`. 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). |
|
||||
|
|
|
|||
Loading…
Reference in a new issue