diff --git a/notes/eureka-pipeline-state-cut.md b/notes/eureka-pipeline-state-cut.md index 57937fb6..cc005315 100644 --- a/notes/eureka-pipeline-state-cut.md +++ b/notes/eureka-pipeline-state-cut.md @@ -3257,45 +3257,677 @@ Epiphany `ca7e230c` (lib.rs at `dddf9ede`), Huginn `4094e68`, CultLib `main` `4a ## Cut 9. `huginn-mind`: queries and derivations -- **Repo/branch:** Huginn, same branch. Depends on Cut 8. -- **Deletes first:** none. -- **Adds:** `src/query.rs` with the five functions and `PipelineDocumentView` - from D4. `semantic` is accepted in `PipelineQuery` but returns - `Unavailable { detail: "index not wired" }` until Cut 11 — a typed refusal, - never a silent empty result. +Imagination, 2026-09-16, refreshed against the Body after the Cut 6d Huginn +follow-up and its fix batch. Replaces the old Cut 9 section whole; the +paragraph "Carried in from the Q17 ruling and the Cut 6d follow-up" is kept +as requirements R-A to R-C below. -**Authority map.** Owner `huginn-mind`; the same forbidden writers as Cut 8. The -new statement is that **status is derived at read time, never stored**, so no -admission writes an `in_force` field and no client computes one. +### Pins -**Carried in from the Q17 ruling and the Cut 6d follow-up (2026-09-16), -before this cut is refreshed:** the in-force derivation is the recursive one -admission already uses (a withdrawn resolution stops counting; the naming -resolution must itself be in force), and a test that pins the -non-recursive form pins the defect. A subject's resolution history is a -first-class view, not only its in-force state: the `…..n` -prefix lists every resolution a subject ever had, withdrawn ones included -with their reasons, because that is the precedent an agent rehydrates from -(ruling 2). The same for a repo's stewardship history on a mind. The -`stored_at` stamp CultCache writes on every envelope is the store's, not -the organ's, and no derivation here may read it. +| Repo | Branch | HEAD | State | +|---|---|---|---| +| Epiphany | `codex/eureka-pipeline-state` | `94df3a8f` | tree clean; map only since `d5a36c2a`. | +| Epiphany leaf | `epiphany-pipeline/src/lib.rs` | `d5a36c2a` (2,366 lines) | the rev Huginn pins in `crates/huginn-mind/Cargo.toml`. | +| Huginn | `eureka/memory-organ` | `7b67730` | **an uncommitted Hands batch is in the tree** (`crates/huginn-mind/src/admission.rs`, `tools/eureka-cut8-mutations.psd1`: a subject-selection fix in `derive`, a dead arm deleted, four tests). Every anchor in this spec is a function, type or test **name**, never a line; Hands re-anchors by content after that batch lands, and this cut lands after it. | +| CultLib | `a0813c6` | `cultcache-rs` checkout `~/.cargo/git/checkouts/cultlib-7ab3069e1ba2db32/a0813c6` | read for `CultCacheEnvelope` only. | -**Verification.** +Body facts this spec rests on, each by source read of the files named: + +- `admission.rs` holds `Docs { image: Vec, batch: Vec }` and + every derivation: `from_image`, `push`, `in_batch`, `in_image`, `find`, + `of_kind`, `resolutions`, `stewardships`, `latest_resolution`, + `derived_resolution_sequence`, `derived_stewardship_sequence`, + `latest_stewardship`, `in_force`, `in_force_unless`, `stewardships_of`, + `later_in_force`, `stewardship_of`, and the free fn `later_than`. Nothing + else in the crate decides in force. +- `in_force_unless(kind, id, own)` is the recursive derivation of Cut 6d D4: + no resolution `R` naming `(kind, id)`, other than `own`, such that + `in_force(Resolution, key(R))`. It returns `bool` and does not say *which* + resolution closes the document; the views need that resolution. +- `Mind` exposes `envelopes()` (the image, sorted `(type, key)`), + `envelope(kind, id)`, `get(kind, id)` (one typed decode), `receipts()` + (every `HuginnCommitReceipt`, decoded from the image), `instance()`, + `is_empty()`; `raw_envelope` and `cache` are `pub(crate)`. +- A receipt names its `writes: Vec` by `(document_type, + document_key)` and its `strong_reads` separately; `committed_at` is the + `now` admission was passed, RFC3339 seconds UTC (`receipt::candidate`); a + strong read is re-inserted unchanged in the swap but recorded under + `strong_reads`, never `writes` (`receipt::commit`). A10 refuses a write + whose identity the image holds, so **exactly one receipt names any stored + document as a write**. +- `PipelineProvenance { faculty: Faculty, agent, session, tool }` and + `Faculty` derive `Serialize`, `Deserialize`, `JsonSchema` (`receipt.rs`). +- At `d5a36c2a` the leaf's `PipelineDocument` (adjacently tagged `{ kind, + value }`), `PipelineRef`, `PipelineKind`, `ResolutionOutcome`, every + value type and `PipelineRefusal` derive `Serialize`, `Deserialize` and + `JsonSchema`. So a view carrying a `PipelineDocument` derives `JsonSchema` + (Q13 A holds with no leaf change). +- `CultCacheEnvelope { key, type, payload, stored_at: String, schema_id }`. + `rg stored_at crates/huginn-mind/src` is empty today. +- Baseline (probe): detached worktree at `7b67730` under the scratchpad, + `CARGO_TARGET_DIR=C:\Users\Meta\.cargo-target-codex`, `cargo test -p + huginn-mind --lib`: **41 passed, 0 failed, 5.5 s wall warm**; target dir + 10,579 paths before and after; worktree removed; main tree untouched. + +### What the cut does + +The read side of a mind, in `huginn-mind`, testable over a `&Mind` +with no store file, daemon or index: + +- **Views.** `PipelineDocumentView { id, document, admission, status }`: the + document, the admission facts joined from the one receipt that wrote it + (`receipt_id`, `admitted_at`, `provenance` whole), and its status derived + at read time (`InForce`, or `Resolved` by a named in-force resolution). +- **Queries.** `Mind::view(&PipelineRef)`, `Mind::query(&PipelineQuery)` + (by campaign root, repo, cut, kinds, in-force, faculty, admission window; + capped at 200, ordered by `(admitted_at, id)`, with the match count so a + truncation is visible), `Mind::open_items(&campaign)`, and + `Mind::history(&HistoryScope)` — every resolution a subject ever had, or + every assignment of a repo on this mind, in sequence order, withdrawn ones + included with their status and reasons (R-B). +- **One owner for in force.** `Docs` and every derivation move whole from + `admission.rs` into `src/docs.rs`. The views read through the same + `Docs`; the one helper they need that admission lacks + (`closing_resolution_unless`, the resolution that closes a document) + becomes the body of `in_force_unless`, so admission calls it too. No + derivation reads `stored_at` (R-C). +- **`semantic` is accepted and refused typed** (`Unavailable`) until Cut 11. + +Requirements carried in, restated so Hands reads them here: + +- **R-A (Q17 B, Cut 6d follow-up).** Status is the recursive in-force + derivation admission uses; a test pinning the non-recursive form pins + the defect. The reopen case (n1, its withdrawal, n2) is in the status test. +- **R-B (Q17's two obligations).** A subject's resolution history is a + first-class view with withdrawn records and their reasons; the index + (Cut 11) gets a document-plus-admission source, which is `Mind::view` per + landed `PipelineRef`, so withdrawals are indexable documents with their + `reason` in the payload. +- **R-C (Soul, Cut 8 Huginn half).** `stored_at` is the store's stamp; + `admitted_at` is the receipt's `committed_at`. Pinned by a runtime test + that scrambles `stored_at` and by a negative grep. +- **Soul's standing findings the views must not hide:** history lands one + record per batch (Cut 12's import constraint), so two records of one + scope carry two receipts; the history test asserts distinct `receipt_id`s. + Replay refuses once a scope has advanced (ruling 20); the views do not + touch replay and add no path around it. + +### What changed against the old Cut 9 section + +1. **`Docs` moves; nothing is copied.** The old section had "Deletes first: + none" and a `src/query.rs` with its own derivations. That would be a + second in-force. The shared module is the cut's first commit. +2. **Five functions collapse to four, differently cut.** `get` becomes + `view` (a document with its facts and status); `rulings_in_force` and + `stewardship` are `query` presets (`kinds: [Ruling], in_force: true, + campaign` and `kinds: [Stewardship], in_force: true`) and are deleted + from this crate's surface; `history` is new (R-B); `open_items` stays. +3. **`PipelineQuery` sheds three fields.** `instance` (the mind is the + instance; D6's request carries it and the daemon routes on it), + `outcome` (`history` carries outcomes; a document's closing outcome is in + its `status`), `text_contains` (Q21 below; Cut 11 owns which fields are + text). `status` becomes `in_force: Option`. +4. **The view is four fields, not seven.** `receipt_id`, `admitted_at` and + `faculty` fold into `admission: AdmissionFacts` carrying the whole + provenance; `resolution: Option<(id, PipelineResolution)>` becomes + `status: PipelineStatus`. +5. **A query result says how many matched.** `PipelineQueryPage { items, + matched }`; the cap never truncates silently. +6. **A document with no receipt is refused, not shown.** The store is the + organ's alone; a row admission did not write is an integrity fault + (`Unavailable`), the same answer `Docs::from_image` gives a row that does + not decode. +7. **Tests 6 → 10, mutations 3 → 23**, in `tools/eureka-cut9-mutations.psd1`; + three cut-8 entries re-anchor to `docs.rs`. + +### Decisions, with the reasons + +#### D1. The shared derivation module is a move of `Docs`, not a new abstraction + +`Docs` already is the thing: "the image and the batch, the two places a rule +may look". The views look at the image alone, which is `Docs` with an empty +batch, built by the same `Docs::from_image(mind.envelopes())`. Moving the +struct and its `impl` into `src/docs.rs` (`pub(crate)`, fields `pub(crate)` +so H49's mutant in `check` still compiles) changes no derivation and no +call site: `admission.rs` keeps `stage`, `refuse_foreign_instance`, +`references`, `resolve`, `derive`, `check`, `revision_rule`, +`unique_labels`, `outcome_name`, `matrix`, `resolution_rule`, +`refuse_collisions`, `kind_of_id`, and every `docs.` call reads as before. +`later_than` moves because only `later_in_force` calls it. `kind_of_type` +moves with `from_image`. + +Rejected: a trait over "a document set" implemented by the image and by +image ∪ batch. It would be one implementor with a flag; `Docs` with an +empty batch is that already. + +#### D2. The one new derivation: which resolution closes a document + +`in_force_unless` answers "is any in-force resolution naming this document +other than `own`?" as a `bool`. The views need the resolution itself for +`PipelineStatus::Resolved`. So: + +``` +fn closing_resolution_unless(&self, kind, id, own) -> Option<(&str, &PipelineResolution)> + = self.resolutions().find(|(key, r)| r.subject.kind == kind && r.subject.id.0 == id + && !own(r) && self.in_force(Resolution, key)) +fn closing_resolution(&self, kind, id) -> Option<(&str, &PipelineResolution)> + = self.closing_resolution_unless(kind, id, |_| false) +fn in_force_unless(&self, kind, id, own) -> bool + = self.closing_resolution_unless(kind, id, own).is_none() +``` + +`find` is exact, not a tie-break: under `AlreadyResolved` (A8) and the Q19 +cap at most one resolution of a subject is in force. Admission's callers of +`in_force`/`in_force_unless` change nothing and now run through the same +`find` the views do; H41's mutant lands inside `closing_resolution_unless` +and kills admission's reopen test and the views' status test alike, which is +the proof that there is one owner (V1 below). + +Two small extractions for the history views, so that `history` shares +admission's scope selection rather than restating it: + +``` +fn resolutions_of(&self, subject: &PipelineRef) -> impl Iterator + = self.resolutions().filter(|(_, r)| r.subject == *subject) +fn assignments_of(&self, mind: &Slug, repo: &OrgRepo) -> impl Iterator + = self.stewardships().filter(|(_, s)| s.instance == *mind && s.repo == *repo) +``` + +`latest_resolution` becomes `resolutions_of(subject).filter(own != …).map(sequence).max()`; +`latest_stewardship` and `stewardships_of` (the in-force ones; name kept) +filter `assignments_of(mind, repo)`. Same results, one scope selector each. +H47 and H60 anchor lines inside these two and re-anchor (Per-file changes). + +The history is selected by the `subject` field, as `latest_resolution` +already does, not by the `…..n` key prefix. The leaf's test +`a_subjects_resolutions_share_a_prefix_no_other_key_has` pins that the two +select the same set; the prefix is the storage-level affordance (Studio, a +raw scan), the field is the organ's, and the views parse no key part for it. + +#### D3. Admission facts come from receipts, from `writes` only + +``` +pub struct AdmissionFacts { pub receipt_id: String, pub admitted_at: String, pub provenance: PipelineProvenance } +``` + +Built once per read as `AdmissionIndex` (`pub(crate)`, in `query.rs`): a +`BTreeMap<(String, String), AdmissionFacts>` over `mind.receipts()?`, +inserting every `receipt.writes[].identity()` with `admitted_at = +receipt.committed_at`. `strong_reads` are never read: a document cited by a +later batch keeps its first receipt. Two receipts naming one write, or a +pipeline document in the image that no receipt names, is +`Unavailable { detail: "document / has no commit receipt" | "… is written by two receipts" }`, +raised from `view` and `query` alike, never a silent skip (V4). The epoch +record and the receipts themselves appear in `writes` and are simply never +looked up. + +`admitted_at` is the caller's `now` as admission stored it (`committed_at`, +RFC3339 `Z`, seconds), so its string order is its time order; the crate +still reads no clock. `stored_at` is not consulted anywhere (R-C; V2). + +Rejected: `admission: Option` with `None` for an +unreceipted row. That is the "show it and let the reader guess" shape; the +store has one writer and the receipt is its proof. + +#### D4. The view is a struct of four fields + +A `(document, facts, status)` tuple serialises as a positional JSON array +and gives Cut 13's tool schema three unnamed slots, and it cannot carry the +id: a `PipelineDocument` does not carry its key, the client must not derive +keys (Cut 13's negative grep), and every follow-up call (`history`, +`view`) takes a `PipelineRef`. So: + +``` +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineDocumentView { + pub id: PipelineRef, + pub document: PipelineDocument, + pub admission: AdmissionFacts, + pub status: PipelineStatus, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineStatus { + InForce, + Resolved { resolution: PipelineRef, record: PipelineResolution }, +} +``` + +`Resolved.record` is the whole closing resolution (outcome, rationale, +`resolved_on`) because rehydration wants why, not only that. Kinds the +matrix makes unresolvable (campaign, cut_report, verdict, instance, +hand_off) are always `InForce`, by the same derivation and no special case. +A withdrawn resolution's own view is `Resolved { resolution: , record: { outcome: Withdrawn { reason }, .. } }`, which is how +R-B's "with their reasons" reaches an agent. + +#### D5. The query type + +``` +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineQuery { + pub campaign: Option, // the key's root: a campaign slug, or an instance for stewardships and hand-offs + pub repo: Option, + pub cut: Option