From b2d4f16d6764abe4b98cc2aa1df60247325fe0d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 20:59:07 +0200 Subject: [PATCH] Refresh Cut 9 against the landed organ The read side takes the derivations admission already owns by moving them whole into one module both call, so one mutant kills both suites. History is a first-class view in sequence order with withdrawn records and their reasons, which is what the Q17 ruling required. Two of the old five functions die as query presets. No text filter until the index exists. Co-Authored-By: Claude Fable 5.1 --- notes/eureka-pipeline-state-cut.md | 694 +++++++++++++++++++++++++++-- 1 file changed, 663 insertions(+), 31 deletions(-) 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