From 49cc6e3e623845667e3fa019cba1352ab924dcf3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 21:22:41 +0200 Subject: [PATCH] Read a mind: views, queries, open items and history `query.rs` is the read side, over a `&Mind` with no store file, no daemon and no index. A `PipelineDocumentView` is four fields: the id, because a `PipelineDocument` does not carry its key and a client must never derive one; the document; the facts of its admission; and its status. The facts come from the commit receipts and from their `writes` alone, so a document a later batch cited as a strong read keeps the receipt that wrote it, and `admitted_at` is that receipt's `committed_at` -- the `now` admission was passed -- never the store's `stored_at`. A pipeline document the image holds and no receipt wrote is an integrity fault and refuses; the store has one writer and the receipt is its proof. Status is derived per call through `docs::Docs`, the same owner the admission rules ask, so a withdrawn resolution reopens its subject for a reader exactly when it reopens it for a rule. `closing_resolution` is `in_force_unless`'s own body asked for the resolution rather than the bool, so there is no second derivation to drift. Four reads: `view` by id, `query` over the filters (root, repo, cut, kinds, in force, faculty, an admission window), `open_items` for one campaign's unanswered questions, standing findings, follow-ups, unreported specs and unjudged reports, and `history` for every resolution a subject ever had or every assignment of a repo to this mind, withdrawn ones included with their reasons. A query is ordered by `(admitted_at, id)`, capped at 200, and carries `matched`, the count before the cap, so a truncation is never a short page that reads like the whole answer. `semantic` is accepted by the type and refused typed until Cut 11 owns the index; no substring filter lives here to become a second answer to "the text of a document". `cargo test -p huginn-mind --lib`: 56 passed, 0 failed, 0 warnings (46 + the ten tests). `cargo check --workspace` clean. No dependency, no target, no kind, no schema, no epoch; `Cargo.toml` and `Cargo.lock` untouched. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 8 +- README.md | 11 +- crates/huginn-mind/src/docs.rs | 6 + crates/huginn-mind/src/fixtures.rs | 27 + crates/huginn-mind/src/lib.rs | 8 + crates/huginn-mind/src/query.rs | 922 +++++++++++++++++++++++++++++ 6 files changed, 976 insertions(+), 6 deletions(-) create mode 100644 crates/huginn-mind/src/query.rs diff --git a/AGENTS.md b/AGENTS.md index 0f32062..429897b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,12 @@ Studio's job, not Huginn's. - Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and `eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh runtimes come from `F:\Projects\CultLib\packages`. `huginn-mind` is live: - it persists and admits an instance's mind, over `cultcache-rs` and - `epiphany-pipeline` pinned by git rev. `huginn-daemon` and `eureka-state` + it persists, admits and reads back an instance's mind, queries and derived + status included, 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 and their admission, in `huginn-mind`, +- Owned: an instance's memory documents, their admission and the read side + that derives their status, 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. diff --git a/README.md b/README.md index 1eb2c3e..f04fafd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ edits `.cc` state. Huginn reads and writes minds. A Cargo workspace of three crates: -- `crates/huginn-mind`: storage, identity, and admission of memory documents. +- `crates/huginn-mind`: storage, identity, admission, and queries and derived + status over memory documents. - `crates/huginn-daemon`: a stub. It will carry the CultNet surface and serve loop; today it holds neither. - `crates/eureka-state`: a stub. It will carry typed state for the Eureka @@ -43,8 +44,12 @@ keys, then the organ's cross-document rules (references, in-force status, the resolution matrix, derived resolutions and stewardships), then one compare-and-swap that lands the batch whole with a receipt naming the exact bytes it read and wrote. An exact replay answers with the stored receipt. -Document shape and keys come from `epiphany-pipeline`; the store is CultLib's -Rust CultCache. +It reads them back the same way: a document with the facts of its admission +joined from that receipt and its status derived at read time, typed queries +over one mind, a campaign's open work, and a subject's or a repo's history. +Status is never stored, and the views derive it through the same rules +admission does. Document shape and keys come from `epiphany-pipeline`; the +store is CultLib's Rust CultCache. `huginn-daemon` and `eureka-state` are stubs; the campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns what each crate must do next. diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index c172047..5ac02ab 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -181,6 +181,12 @@ impl Docs { }) } + /// The resolution that closes the document, if any: the views' status, + /// derived where admission derives whether there is one at all. + pub(crate) fn closing_resolution(&self, kind: PipelineKind, id: &str) -> Option<(&str, &PipelineResolution)> { + self.closing_resolution_unless(kind, id, |_| false) + } + /// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal /// `hand_off_key` derives. The stewardship row holds one in force at a /// time, but that exclusion can put a second back in the list: the one a diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index a6ae3cb..69b8d97 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -330,10 +330,37 @@ pub(crate) fn opened(store: S, instance: &str) -> Mind { /// Admits as Hands at the fixed clock. pub(crate) fn admit(mind: &mut Mind, documents: Vec) -> PipelineAdmissionOutcome { + admit_at(mind, documents, now()) +} + +/// Admits as Hands at the clock the caller names: the views order by the +/// receipt's `committed_at`, which is this `now`. +pub(crate) fn admit_at( + mind: &mut Mind, + documents: Vec, + now: DateTime, +) -> PipelineAdmissionOutcome { let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(Faculty::Hands), documents }; + mind.admit(batch, now) +} + +/// Admits as another faculty at the fixed clock: attribution the views filter +/// on and no rule reads (ruling 18). +pub(crate) fn admit_as( + mind: &mut Mind, + faculty: Faculty, + documents: Vec, +) -> PipelineAdmissionOutcome { + let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(faculty), documents }; mind.admit(batch, now()) } +/// The `n`th plain question, for the tests that need more of them than they +/// need to tell apart. +pub(crate) fn question_n(n: u32) -> PipelineDocument { + question(&format!("Q{n}"), &["A", "B"], "A") +} + /// The first write into a fresh yggdrasil mind: its identity, stewardship of /// the campaign repo, the campaign and a first target with one invariant. pub(crate) fn seed(mind: &mut Mind) { diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index b3f188b..45a0606 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -10,6 +10,9 @@ //! cross-document rule, every derived write, and the replay check, in one //! ordered path that `admit`, the daemon's sink and the hand-off all share. //! +//! The read side derives status, joins the receipts and answers typed queries +//! through the same `docs` the rules use; nothing is stored for it. +//! //! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the //! organ registers, prepares, decodes and validates through the leaf's four //! doors and never re-derives a key. The store is CultLib's owned redb @@ -20,6 +23,7 @@ pub mod admission; mod docs; pub mod mind; +pub mod query; pub mod receipt; pub mod refusal; pub mod store; @@ -29,6 +33,10 @@ pub(crate) mod fixtures; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; pub use mind::{HuginnMindEpoch, Mind}; +pub use query::{ + AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage, + PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery, +}; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::MindRefusal; pub use store::MindStore; diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs new file mode 100644 index 0000000..7de29ed --- /dev/null +++ b/crates/huginn-mind/src/query.rs @@ -0,0 +1,922 @@ +//! The read side of a mind: a document with the facts of its admission and +//! its status, and the four queries over them. +//! +//! Nothing here is stored. Status is derived per call through the same +//! `docs::Docs` the admission rules use, so a withdrawn resolution reopens its +//! subject for a reader exactly when it reopens it for a rule. The facts come +//! from the commit receipts, from their `writes` and from nothing else: a +//! document's `admitted_at` is the `now` its batch was admitted at +//! (`committed_at`), never the store's `stored_at` stamp. A pipeline document +//! in the image that no receipt wrote is an integrity fault and refuses; the +//! store has one writer and the receipt is its proof. +//! +//! `semantic` is accepted by the type and refused typed until Cut 11 wires the +//! index. No clock, no store handle, no network. + +use std::collections::BTreeMap; + +use epiphany_pipeline::{ + Label, Line, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, Short, Slug, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::docs::{Docs, Held}; +use crate::mind::Mind; +use crate::receipt::{Faculty, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The most views one query answers with. A query that matched more says so in +/// `matched`; the cap never truncates silently. +pub const QUERY_LIMIT_MAX: usize = 200; + +/// What admission recorded about a document when it landed: the receipt that +/// wrote it, the `now` that receipt was taken at, and who asked. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AdmissionFacts { + pub receipt_id: String, + pub admitted_at: String, + pub provenance: PipelineProvenance, +} + +/// Whether a document still stands, and if not, what closed it. Derived at +/// read time; no document carries a status field and no admission writes one. +/// The whole closing record travels, not only its outcome, because a reader +/// rehydrating wants why. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineStatus { + InForce, + Resolved { resolution: PipelineRef, record: PipelineResolution }, +} + +/// One document as a reader gets it: its id, the document, the facts of its +/// admission and its status. The id travels with the document because a +/// `PipelineDocument` does not carry its key and a client must never derive +/// one. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineDocumentView { + pub id: PipelineRef, + pub document: PipelineDocument, + pub admission: AdmissionFacts, + pub status: PipelineStatus, +} + +/// A semantic query, accepted by the type and refused until Cut 11 owns the +/// index. Cut 11 also owns which fields of each kind are text, so no substring +/// filter lives here to become a second answer to that question. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticQuery { + pub text: Line, + pub top_k: u32, +} + +/// The filters over one mind. Each is one rule, and an unset filter matches +/// everything. The mind is the instance, so there is no `instance` field: the +/// daemon picks the mind before it calls here. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineQuery { + /// The key's root segment: a campaign slug, or an instance for + /// stewardships, hand-offs and the identity document. + pub campaign: Option, + pub repo: Option, + pub cut: Option