diff --git a/docs/vrs/.delta/DELTA-006-delivery-state-v1-arm.md b/docs/vrs/.delta/DELTA-006-delivery-state-v1-arm.md index 938d8ff0..4ef760b1 100644 --- a/docs/vrs/.delta/DELTA-006-delivery-state-v1-arm.md +++ b/docs/vrs/.delta/DELTA-006-delivery-state-v1-arm.md @@ -41,16 +41,23 @@ point, the ownership filter, the legacy filename), `codex_v1.rs`, and labels — Codex's `accepted` was a typed in-turn receipt and grades to `consumed`, OpenCode's was a storage read-back and grades to `persisted`). -Canonical code gains one version-free concept, `Attestation{Observed, -Asserted}` on `Entry`: a phase this build graded versus a phase another -authority asserted. An assertion bounds what already happened, so it suppresses -a duplicate; it is not an observation, so it authorizes no transport until this -build sees something itself. That distinction is permanent and would be needed -by any future asserting authority, so it stays when the arm goes. - -Deletion is `git rm -r src/migrations` plus replacing the seam arm with -`Ok(())`, which is byte-for-byte a first run on a fresh seat. Measured cost of -that deletion: one compile error, at the seam. +Canonical code gains one version-free field, `Attestation{Observed, Asserted}` +on `Entry`: whether this build graded the phase or another authority asserted +it. It changes no decision — no `Retention`, `RetryDecision`, or transport +arm reads it. What holds a carried-forward attempt is its `Phase` measured +against the harness `Profile`: no profile proves `Attempted`, and `Ledger::seed` +refuses an asserted phase its profile cannot prove at all, so the safety +property is delivered by the phase, not by the label. The field is there so the +fleet can *see* an unobserved phase — it is clause 2 of the Resolution Signal +below — which makes it trigger instrumentation for this arm, not a permanent +concept. It is deleted **with** the arm. + +Deletion is `git rm -r src/migrations`, replacing the seam arm with `Ok(())`, +and removing the instrumentation the trigger needed: `Entry.attestation` and +`Attestation`, `delivery_ledger::asserted_entries`, +`migrations::delivery_state::resolution_signal`, and the `st2 doctor` row that +prints it. What remains is byte-for-byte a first run on a fresh seat. Measured +cost of the boundary deletion itself: one compile error, at the seam. ## Direction @@ -58,8 +65,16 @@ update implementation ## Resolution Signal -Both commands below print nothing, on every admitted host, for seven -consecutive days: +`st2 doctor` prints, per seat, a `pre-ledger delivery state (DELTA-006)` +advisory carrying `preLedgerRecords` and `assertedEntries`, and prints nothing +when both are zero. The trigger resolves when that line is absent from +`st2 doctor` on every admitted host for seven consecutive days. Producer: +`migrations::delivery_state::resolution_signal`, pinned by +`migrations::delivery_state::tests::the_resolution_signal_counts_each_clause_without_consuming_it`; +measuring is read-only, so a diagnostic cannot make the record it counts +disappear. + +The two clauses, and the equivalent commands for a host with no `st2` on PATH: ```sh state="${XDG_STATE_HOME:-$HOME/.local/state}/st2" @@ -74,10 +89,14 @@ find "$state/codex" "$state/opencode" -maxdepth 2 -name delivery-ledger.json -pr | xargs -0 -r jq -r 'select([.entries[].attestation] | any(. == "asserted")) | input_filename' ``` -Clause 1 also requires that rollback to a pre-ledger release has stopped being -supported: while it is supported, a rolled-back binary can write a new -`delivery-state.json`, and the roll-forward window it opens is pinned by +Clause 1 additionally requires a named artifact, not a recollection: a ratified +decision under `docs/vrs/.decisions/` recording that the pre-ledger release line +is no longer a supported rollback target. While no such record exists clause 1 +stays open, because a rolled-back binary can write a new `delivery-state.json` +and the roll-forward window it opens is pinned by `migrations::delivery_state::tests::a_rollback_then_roll_forward_does_not_see_the_record_written_in_between`. +That record's existence is what closes the clause; cite it by number here when +it lands. The local half of the trigger — that with no old record present the module contributes nothing and writes nothing, so removing it cannot change observed diff --git a/src/delivery_ledger.rs b/src/delivery_ledger.rs index 2ca3cb24..e767cee4 100644 --- a/src/delivery_ledger.rs +++ b/src/delivery_ledger.rs @@ -6,10 +6,10 @@ //! authority and is reconciled through [`Ledger::prune`]. //! //! One phase can reach this ledger without this build observing anything: an attempt an earlier -//! release made and left behind. [`Attestation`] is the whole vocabulary for that — an asserted -//! phase bounds what already happened, so it suppresses a duplicate, and it is not evidence, so -//! it authorizes no transport. The translation itself lives outside this module, behind the one -//! seam in [`Ledger::open`]. +//! release made and left behind. Such a phase holds exactly as far as a phase holds — no harness +//! profile proves `Attempted`, so nothing is re-sent — and [`Attestation`] records that this +//! build never watched it, which is what makes the leftover countable and therefore removable. +//! The translation itself lives outside this module, behind the one seam in [`Ledger::open`]. use std::fs; use std::io::Write as _; @@ -20,8 +20,8 @@ use serde::{Deserialize, Serialize}; use crate::message; -pub const LEDGER_SCHEMA: &str = "st2.delivery-ledger.v1"; -pub const LEDGER_FILE: &str = "delivery-ledger.json"; +pub(crate) const LEDGER_SCHEMA: &str = "st2.delivery-ledger.v1"; +pub(crate) const LEDGER_FILE: &str = "delivery-ledger.json"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Harness { @@ -141,16 +141,20 @@ pub enum Evidence { /// Whether this build observed the evidence behind a phase, or another authority asserted it. /// -/// An assertion is a true lower bound on what already happened, so it may suppress a duplicate; -/// it is not an observation, so it authorizes no transport until fresh evidence arrives. Nothing -/// here names the authority: any party that can bound an attempt this build never watched -/// asserts, and the record boundary in `crate::migrations::delivery_state` is one such party. +/// This is provenance, not authority: no [`Retention`], [`RetryDecision`] or transport decision +/// reads it. What holds a carried-forward attempt is its [`Phase`] measured against the harness +/// [`Profile`] — `Profile::releases` releases only a phase that harness can actually prove, and +/// [`Ledger::seed`] refuses an asserted phase the profile cannot prove at all. The field exists +/// so the fleet can *see* an unobserved phase: it is clause 2 of DELTA-006's Resolution Signal +/// (`st2 doctor` counts it per seat), and it is deleted with the record boundary that produces +/// it. Nothing here names the authority: any party that can bound an attempt this build never +/// watched asserts, and the boundary in `crate::migrations::delivery_state` is one such party. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Attestation { /// This build graded the evidence that set the phase through [`Profile::graded`]. Observed, - /// Another authority asserted the phase: enough to hold a delivery, never enough to send one. + /// Another authority asserted the phase: this build never watched the attempt it describes. Asserted, } @@ -192,9 +196,6 @@ pub enum HoldReason { AmbiguousAttempt, UnreadReceipt, NegativeReceipt, - /// The phase was asserted, not observed: enough to suppress a duplicate, never enough to - /// authorize a transport. Only fresh evidence about the world clears it. - UnattestedClaim, Quarantined, Settled, } @@ -495,9 +496,6 @@ impl Ledger { if entry.phase >= Phase::Persisted { return Retention::Hold(HoldReason::UnreadReceipt); } - if entry.attestation == Attestation::Asserted { - return Retention::Hold(HoldReason::UnattestedClaim); - } Retention::Hold(HoldReason::AmbiguousAttempt) } @@ -562,8 +560,9 @@ impl Ledger { /// /// The only constructor the recovery seam may use. The phase it carries is still checked against /// the harness [`Profile`] by [`Ledger::seed`], so a claim of evidence the harness cannot produce -/// fails closed instead of being written, and [`Attestation::Asserted`] keeps the entry holding -/// until this build observes something: it can suppress a duplicate, it can authorize nothing. +/// fails closed instead of being written, and the phase itself is what keeps the entry holding: +/// an [`Attestation::Asserted`] entry suppresses a duplicate exactly as far as its phase does, +/// and authorizes a transport only where the profile proves that phase. pub fn asserted( filename: String, binding: String, @@ -582,6 +581,31 @@ pub fn asserted( } } +/// How many entries in the ledger at `path` carry a phase this build never observed. +/// +/// Clause 2 of DELTA-006's Resolution Signal, and the reason [`Attestation`] is serialized at +/// all. Read-only on purpose: [`Ledger::open`] runs the record boundary and may write, and a +/// diagnostic must not change what it measures. A missing ledger counts zero; bytes that will +/// not parse are an error, because "I hold a delivery record I cannot read" is exactly what an +/// operator needs told. +pub(crate) fn asserted_entries(path: &Path) -> Result { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(error) + .with_context(|| format!("reading delivery ledger {}", path.display())); + } + }; + let record: Record = serde_json::from_slice(&bytes) + .with_context(|| format!("parsing delivery ledger {}", path.display()))?; + Ok(record + .entries + .iter() + .filter(|entry| entry.attestation == Attestation::Asserted) + .count()) +} + /// Durable replacement: file bytes reach disk before rename, then the directory entry is synced. /// /// The temp file is created exclusively at `0600` under a name unique to this process and write, @@ -685,9 +709,11 @@ mod tests { ); } - /// The safety property the whole record boundary rests on: an asserted phase is a bound on - /// what already happened, so it holds the delivery, and it is not evidence, so it authorizes - /// no transport. This build's own observation is what clears it. + /// The safety property the whole record boundary rests on, and where it comes from: a + /// carried-forward `Attempted` phase is a bound on what already happened, so it holds the + /// delivery, and no harness profile proves `Attempted`, so it authorizes no transport. The + /// phase does that work; the attestation only records who saw it. This build's own + /// observation — here an authoritative absence — is what clears the hold. #[test] fn an_asserted_phase_suppresses_a_duplicate_and_authorizes_no_transport() { let tmp = tempfile::tempdir().unwrap(); @@ -706,11 +732,11 @@ mod tests { .unwrap(); assert_eq!( ledger.retention(FILE_A), - Retention::Hold(HoldReason::UnattestedClaim) + Retention::Hold(HoldReason::AmbiguousAttempt) ); assert_eq!( ledger.retry(FILE_A), - RetryDecision::Hold(HoldReason::UnattestedClaim) + RetryDecision::Hold(HoldReason::AmbiguousAttempt) ); // The seed is durable, so a restart still holds instead of re-sending. @@ -721,7 +747,7 @@ mod tests { ); assert_eq!( reopened.retry(FILE_A), - RetryDecision::Hold(HoldReason::UnattestedClaim) + RetryDecision::Hold(HoldReason::AmbiguousAttempt) ); // An authoritative absence is an observation: it clears the claim and re-authorizes one diff --git a/src/event.rs b/src/event.rs index 81eea0be..dc79469a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -345,12 +345,7 @@ fn resolve_stream( let every_subject = discovered .specs .iter() - .map(|spec| crate::identity::AddressBookEntry { - id: spec.effective_id(this_host), - bus_identity: spec.bus_id(this_host), - host: spec.resolved_host(this_host).to_owned(), - address: spec.effective_address().to_owned(), - }) + .map(|spec| crate::identity::AddressBookEntry::of(spec, this_host)) .collect::>(); crate::identity::resolve_local_first(&every_subject, recipient, this_host) .map(|entry| (entry.id.clone(), entry.bus_identity.clone())) diff --git a/src/identity.rs b/src/identity.rs index 26592c16..09b032e4 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -59,6 +59,19 @@ pub struct AddressBookEntry { } impl AddressBookEntry { + /// The one mapping from a declaration to its routable subject. + /// + /// Every caller that hand-built this struct was re-deriving the same four fields from the + /// same spec, so the derivation lives here and the address book itself uses it too. + pub fn of(spec: &agent_spec::AgentSpec, this_host: &str) -> Self { + Self { + id: spec.effective_id(this_host), + bus_identity: spec.bus_id(this_host), + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + } + } + /// The human-routable bus address `.
`. pub fn bus_address(&self) -> String { format!("{}.{}", self.host, self.address) @@ -146,7 +159,7 @@ pub fn resolve_id<'a>( /// try every dotted split whose prefix is an admitted logical host and whose suffix is an /// effective address in that host. /// 3. Deduplicate by agent ID and succeed only when exactly one subject remains. -pub fn resolve_address<'a>( +fn resolve_address<'a>( entries: &'a [AddressBookEntry], reference: &str, pinned_host: Option<&str>, @@ -200,7 +213,7 @@ pub fn resolve_address<'a>( } /// Resolve either selector form against one coherent address book. -pub fn resolve<'a>( +fn resolve<'a>( entries: &'a [AddressBookEntry], selector: &AgentSelector, pinned_host: Option<&str>, @@ -237,12 +250,7 @@ pub fn address_book(specs: &[agent_spec::AgentSpec], this_host: &str) -> Vec, require_supervisor: bool) -> Re ), } } + // DELTA-006's Resolution Signal, per seat. Silence means both clauses are clear here, so + // the `delivery-state.json` boundary arm is removable once every admitted host is silent + // for the record's window — a trigger nobody produces resolves on memory instead. + // Advisory, not a problem: a carried-forward attempt is correct behaviour today. + let delivery_state_dirs = [ + st2::codex_app_server::state_dir(&catalog, &bus_id), + st2::opencode_session::state_dir(&catalog, &bus_id), + ]; + match st2::migrations::delivery_state::resolution_signal(&delivery_state_dirs) { + Ok(signal) if signal.is_clear() => {} + Ok(signal) => report_advisory( + &format!("{bus_id} pre-ledger delivery state (DELTA-006)"), + &format!( + "preLedgerRecords={} assertedEntries={}", + signal.pre_ledger_records, signal.asserted_entries + ), + ), + Err(error) => report_check( + &mut problems, + false, + &format!("{bus_id} delivery ledger readable"), + &format!("{error:#}"), + ), + } if spec.desired_state.is_retired() { let still_present = spec .tasks @@ -3094,12 +3118,7 @@ fn resolve_selected( let entries = found .specs .iter() - .map(|spec| st2::identity::AddressBookEntry { - id: spec.effective_id(host), - bus_identity: spec.bus_id(host), - host: spec.resolved_host(host).to_owned(), - address: spec.effective_address().to_owned(), - }) + .map(|spec| st2::identity::AddressBookEntry::of(spec, host)) .collect::>(); let resolved = &st2::identity::resolve_id(&entries, &id)?.id; let spec = found diff --git a/src/message.rs b/src/message.rs index a04434b1..092b4250 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1247,12 +1247,7 @@ fn select_spec<'a>( specs, selector, this_host, - |spec| crate::identity::AddressBookEntry { - id: spec.effective_id(this_host), - bus_identity: spec.bus_id(this_host), - host: spec.resolved_host(this_host).to_owned(), - address: spec.effective_address().to_owned(), - }, + |spec| crate::identity::AddressBookEntry::of(spec, this_host), |spec| spec.desired_state.is_retired(), )?; Ok(&specs[index]) diff --git a/src/migrations/delivery_state/mod.rs b/src/migrations/delivery_state/mod.rs index 88df6bc2..8c80f71f 100644 --- a/src/migrations/delivery_state/mod.rs +++ b/src/migrations/delivery_state/mod.rs @@ -17,20 +17,27 @@ //! //! * **Fresh namespace.** The canonical record is a different filename, so nothing is rewritten //! and a translation can be re-derived from bytes that are still there. -//! * **Assertion, not observation.** Every entry this module produces is -//! [`crate::delivery_ledger::Attestation::Asserted`] via -//! [`crate::delivery_ledger::asserted`], so a carried-forward phase can suppress a duplicate -//! and can never authorize a transport. That is the whole safety argument, and it is enforced -//! by the canonical validator, not by care taken here. +//! * **Phase, not label.** Every entry this module produces is graded no higher than the +//! evidence the old record actually carried, and no harness +//! [`Profile`](crate::delivery_ledger::Profile) proves `Attempted`, so a carried-forward +//! attempt suppresses a duplicate and authorizes no transport. +//! [`Ledger::seed`](crate::delivery_ledger::Ledger::seed) re-checks that against the profile +//! and fails closed, so the safety argument is enforced by the canonical validator, not by +//! care taken here. +//! [`crate::delivery_ledger::asserted`] additionally marks each entry +//! [`crate::delivery_ledger::Attestation::Asserted`], which changes no decision and exists to +//! make the leftover countable — see the trigger below. //! //! # Deletion trigger //! -//! `docs/vrs/.delta/DELTA-006-delivery-state-v1-arm.md`, whose Resolution Signal is the live -//! query that makes this directory removable — no `delivery-state.json` beside a ledger on any -//! admitted host for seven days, and no ledger entry still carrying an asserted phase. Deleting -//! it is this directory plus the one seam statement in -//! [`crate::delivery_ledger::Ledger::open`]. The local half of the trigger is -//! [`tests::deletion_trigger_absent_old_record_makes_this_module_a_no_op`]. +//! `docs/vrs/.delta/DELTA-006-delivery-state-v1-arm.md`, whose Resolution Signal is produced by +//! [`resolution_signal`] and printed per seat by `st2 doctor`: no `delivery-state.json` beside a +//! ledger and no ledger entry still carrying an asserted phase, on every admitted host, for +//! seven days. Deleting this arm is this directory, the one seam statement in +//! [`crate::delivery_ledger::Ledger::open`], and the attestation instrumentation the trigger +//! needed. The local half of the trigger is +//! [`tests::deletion_trigger_absent_old_record_makes_this_module_a_no_op`]; the signal itself is +//! pinned by [`tests::the_resolution_signal_counts_each_clause_without_consuming_it`]. mod codex_v1; mod opencode_v1; @@ -38,9 +45,9 @@ mod opencode_v1; use anyhow::{Context, Result}; use serde::de::DeserializeOwned; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; -use crate::delivery_ledger::{Entry, Harness}; +use crate::delivery_ledger::{Entry, Harness, LEDGER_FILE}; /// The filename every pre-ledger release wrote. Named here only. const LEGACY_FILE: &str = "delivery-state.json"; @@ -94,6 +101,45 @@ where } } +/// DELTA-006's Resolution Signal, counted instead of remembered. +/// +/// Clause 1 is a pre-ledger record still sitting beside a per-harness state dir; clause 2 is a +/// ledger entry whose phase this fleet asserted rather than observed. Both must read zero, on +/// every admitted host, before this directory and the seam in +/// [`crate::delivery_ledger::Ledger::open`] can go — and a trigger nothing produces resolves on +/// someone remembering, which is a date in disguise. `st2 doctor` prints this per seat so the +/// observation exists. +/// +/// Read-only: it opens no ledger and translates nothing, so running the diagnostic cannot make +/// the record it is counting disappear. +pub struct ResolutionSignal { + pub pre_ledger_records: usize, + pub asserted_entries: usize, +} + +impl ResolutionSignal { + /// Whether both clauses are clear for the seats measured, i.e. nothing to print. + pub fn is_clear(&self) -> bool { + self.pre_ledger_records == 0 && self.asserted_entries == 0 + } +} + +/// Measure the signal across one seat's per-harness state directories. +pub fn resolution_signal(state_dirs: &[PathBuf]) -> Result { + let mut signal = ResolutionSignal { + pre_ledger_records: 0, + asserted_entries: 0, + }; + for state_dir in state_dirs { + if state_dir.join(LEGACY_FILE).exists() { + signal.pre_ledger_records += 1; + } + signal.asserted_entries += + crate::delivery_ledger::asserted_entries(&state_dir.join(LEDGER_FILE))?; + } + Ok(signal) +} + /// Decode, filter by ownership, convert. /// /// A record naming another schema, another harness's field set, or another agent is *ignored*: @@ -194,8 +240,7 @@ pub(crate) mod fixture { mod tests { use super::*; use crate::delivery_ledger::{ - Attestation, HoldReason, LEDGER_FILE, LEDGER_SCHEMA, Ledger, Phase, Retention, - RetryDecision, + Attestation, HoldReason, LEDGER_SCHEMA, Ledger, Phase, Retention, RetryDecision, }; const FILE_A: &str = "1786380000000-aaa111.md"; @@ -237,6 +282,54 @@ mod tests { } } + /// The fleet half of the deletion trigger needs a producer, or it resolves on someone + /// remembering. Each clause must read nonzero exactly while the thing it names is present, + /// and measuring must not consume it. + #[test] + fn the_resolution_signal_counts_each_clause_without_consuming_it() { + let tmp = tempfile::tempdir().unwrap(); + let state_dir = tmp.path().join("state"); + let dirs = [state_dir.clone()]; + + assert!(resolution_signal(&dirs).unwrap().is_clear()); + + fixture::place( + &state_dir, + &fixture::codex_attempted( + "h.worker", + "h.worker", + "incarnation-0", + "thread-main", + FILE_A, + &correlate("thread-main", FILE_A), + ), + ); + let signal = resolution_signal(&dirs).unwrap(); + assert_eq!(signal.pre_ledger_records, 1); + assert_eq!( + signal.asserted_entries, 0, + "clause 2 counts translated entries, and nothing has opened the ledger yet" + ); + + // Opening translates: the old record stays (clause 1) and the carried-forward phase is + // now visible as asserted (clause 2). + let ledger = open(&state_dir, Harness::Codex); + assert_eq!( + ledger.entry(FILE_A).unwrap().attestation, + Attestation::Asserted + ); + let signal = resolution_signal(&dirs).unwrap(); + assert_eq!(signal.pre_ledger_records, 1); + assert_eq!(signal.asserted_entries, 1); + assert!(!signal.is_clear()); + + // Measuring is read-only: both clauses still hold after a second look. + assert!(state_dir.join(LEGACY_FILE).exists()); + let signal = resolution_signal(&dirs).unwrap(); + assert_eq!(signal.pre_ledger_records, 1); + assert_eq!(signal.asserted_entries, 1); + } + #[test] fn accepted_adopts_at_the_evidence_each_harness_actually_proved() { // Codex wrote `Accepted` only from the typed completed user message: consumption, its @@ -305,7 +398,7 @@ mod tests { ); assert_eq!( ledger.retry(FILE_A), - RetryDecision::Hold(HoldReason::UnattestedClaim), + RetryDecision::Hold(HoldReason::AmbiguousAttempt), "a drifted runtime id is carried forward and held, not ignored into a second delivery" ); } diff --git a/src/opencode_session.rs b/src/opencode_session.rs index 394c82f9..750a5dce 100644 --- a/src/opencode_session.rs +++ b/src/opencode_session.rs @@ -1631,7 +1631,7 @@ fn stable_message_id(recipient: &str, session_id: &str, filename: &str) -> Strin format!("msg{:.26}", format!("{:x}", hash.finalize())) } -fn state_dir(catalog_root: &Path, identity: &str) -> PathBuf { +pub fn state_dir(catalog_root: &Path, identity: &str) -> PathBuf { let base = std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state"))) diff --git a/tests/vrs_ledger.rs b/tests/vrs_ledger.rs index 3b851b9b..079fa679 100644 --- a/tests/vrs_ledger.rs +++ b/tests/vrs_ledger.rs @@ -25,6 +25,12 @@ fn root_requirement_ids_are_unique() { } } +/// Four decision numbers were each recorded twice before the collision was noticed. The numbers +/// are historical IDs — 26 inbound references across `docs/`, `src/` and `tests/` cite them — so +/// they are not renumbered; this test ratchets the set instead. A new number falling into a +/// collision fails, a third file joining an existing collision fails, and repairing one fails +/// loudly so the allow-list is updated deliberately. Titles are deliberately not pinned: a +/// retitled decision is not a ledger property. #[test] fn root_decision_number_duplicates_match_recorded_history() { let decision_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/vrs/.decisions"); @@ -49,51 +55,18 @@ fn root_decision_number_duplicates_match_recorded_history() { let duplicates = by_number .into_iter() .filter(|(_, stems)| stems.len() > 1) + .map(|(number, stems)| (number, stems.len())) + .collect::>(); + let expected = ["0005", "0007", "0014", "0015"] + .into_iter() + .map(|number| (number.to_owned(), 2usize)) .collect::>(); - let expected = [ - ( - "0005", - [ - "0005-pi-delivers-natively-through-an-injected-extension", - "0005-streams-are-agent-nested-and-stream-named", - ] - .as_slice(), - ), - ( - "0007", - [ - "0007-child-output-capture-is-bounded-and-tail-preserving", - "0007-omp-is-a-fifth-native-driver-with-its-own-channel-and-a-hard-version-gate", - ] - .as_slice(), - ), - ( - "0014", - [ - "0014-harness-context-is-a-sibling-numeric-record", - "0014-resource-profiles-are-state-first-read-and-observe-capabilities", - ] - .as_slice(), - ), - ( - "0015", - [ - "0015-catalog-commits-and-direct-edits-use-independent-wake-channels", - "0015-immutable-agent-id-and-mutable-address", - ] - .as_slice(), - ), - ] - .into_iter() - .map(|(number, stems)| { - ( - number.to_owned(), - stems.iter().map(|stem| (*stem).to_owned()).collect(), - ) - }) - .collect::>(); - assert_eq!(duplicates, expected); + assert_eq!( + duplicates, expected, + "decision-number collisions changed; each is recorded history, so update this \ + allow-list deliberately rather than renumbering a decision" + ); } #[test]