From 91b0af759e2d56637dc03d955045a430fc559ed4 Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Sun, 7 Jun 2026 23:18:55 +0200 Subject: [PATCH 1/3] feat(core): add the operator-driven alias map + audited alias events (RFC0001.12-.16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements SLICE A of the RFC 0001 §6.7 alias-index write path: - `AuditPayload::AliasAsserted` / `AliasRetracted` on the existing §6.4 audit stream, carrying `representative_id`, `member_ids`, the new `ActorId` newtype (validated non-empty — aliasing is never anonymous, §3.1), and a ≤256 B `reason`. Routed through `AuditSink` exactly like the Template / Compaction payloads; new stable event_kind/event_type ordinals (4/5). Alias events do not count as `merges_total`. - `alias::AliasMap`: the per-tenant equivalence-class projection (§3.7 isolation). Operator API `assert` / `retract` validates, emits the audited event, and folds it in (union-on-overlap on assert, remove-and-resplit on retract). Canonical = `min(members)`, derived. `resolves` returns the whole class, `{id}` for a non-aliased id. The map is foldable from the durable event log (`apply` / `from_events`); the physical on-disk artifact is the RFC 0005 split (#147 sibling), out of scope here. - `alias_assertions_total` / `alias_retractions_total` OTel-API counters on `global::meter("ourios.miner")` with the `tenant_id` attribute (§6.8 telemetry table), seeded at init for collect-on-read. Flips RFC0001.12-.16 from ignored red-gate stubs to real AAA tests. The audit-Parquet writer has no columns for alias payloads yet (that schema extension is the RFC 0005 split), so it rejects alias events via a new `AuditBatchError::AliasEventNotYetPersistable` rather than inventing columns or dropping them silently — alias events stay durable via the alias event log meanwhile. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 4 + crates/ourios-core/Cargo.toml | 15 + crates/ourios-core/src/alias.rs | 640 ++++++++++++++++++ crates/ourios-core/src/audit.rs | 65 +- crates/ourios-core/src/lib.rs | 1 + crates/ourios-core/tests/rfc0001_alias.rs | 207 ++++-- .../ourios-parquet/src/audit_record_batch.rs | 57 +- 7 files changed, 942 insertions(+), 47 deletions(-) create mode 100644 crates/ourios-core/src/alias.rs diff --git a/Cargo.lock b/Cargo.lock index 2adde442e..00bbb5c4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2653,8 +2653,12 @@ name = "ourios-core" version = "0.0.0" dependencies = [ "blake3", + "opentelemetry", "opentelemetry-proto", + "opentelemetry_sdk", + "ourios-telemetry", "serde_json", + "tokio", ] [[package]] diff --git a/crates/ourios-core/Cargo.toml b/crates/ourios-core/Cargo.toml index d5c7158a8..ec7e2dea4 100644 --- a/crates/ourios-core/Cargo.toml +++ b/crates/ourios-core/Cargo.toml @@ -30,6 +30,21 @@ blake3 = { version = "1", default-features = false } # derives so the spec mapping stays single-sourced through the proto # crate. serde_json = { version = "1", default-features = false, features = ["std"] } +# The OTel metrics **API** only (RFC 0001 §6.8 "Export architecture": +# library crates depend on the lightweight API and resolve instruments +# through `global::meter`; the heavy SDK + OTLP exporter live in +# `ourios-telemetry`). Drives the alias map's +# `alias_assertions_total` / `alias_retractions_total` counters +# (§6.8 telemetry table). +opentelemetry = { version = "0.32", default-features = false, features = ["metrics"] } + +[dev-dependencies] +# `init_in_memory` collects the alias counters' exported metric stream +# through an in-memory reader (no OTLP endpoint) in the §6.8 telemetry +# unit test — same pattern as the compaction-metrics test. +ourios-telemetry = { path = "../ourios-telemetry", features = ["testing"] } +tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "macros"] } +opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics", "testing"] } [lints] workspace = true diff --git a/crates/ourios-core/src/alias.rs b/crates/ourios-core/src/alias.rs new file mode 100644 index 000000000..b9e871244 --- /dev/null +++ b/crates/ourios-core/src/alias.rs @@ -0,0 +1,640 @@ +//! Operator-driven, audited alias index (RFC 0001 §6.7). +//! +//! An **alias set** is a per-tenant `[§3.7]` equivalence class of +//! `template_id`s that an operator has asserted mean the same template. +//! Aliasing is **cross-leaf only**: it groups `template_id`s the miner +//! allocated as separate leaves. The cross-*version* axis (one leaf's +//! `template_id` is stable across widenings, only `template_version` +//! advances) is *not* an alias concern, so this module holds no +//! `template_version` field. +//! +//! Membership is the only thing that carries contract weight: +//! [`AliasMap::resolves`] expands by membership and nothing else +//! (RFC0001.13). The **canonical representative** of a class is *derived* +//! as `min(members)` — a stable, order-independent display/identity +//! convenience, **not** what defines membership; it re-derives whenever +//! membership changes. This rule is an evolvable implementation detail, +//! not a contract. +//! +//! # Source of truth vs. projection +//! +//! The durable [`AuditPayload::AliasAsserted`] / +//! [`AuditPayload::AliasRetracted`](crate::audit::AuditPayload) event log +//! (on the §6.4 audit stream, WAL-durable under the §3.4 +//! WAL-before-ack barrier) is the source of truth. The [`AliasMap`] is an +//! **in-memory projection** of that log: it is foldable from the events +//! ([`AliasMap::apply`] / [`AliasMap::from_events`]), so a fresh process +//! reconstructs the same classes by replaying the stream. The +//! **physical on-disk map artifact** (its serialization format and the +//! snapshot/refresh cadence) is explicitly **out of scope here** — that +//! is the RFC 0005 storage split (sibling to issue #147). This module +//! adds no new on-disk write plane. +//! +//! The operator API ([`AliasMap::assert`] / [`AliasMap::retract`]) +//! validates the request, emits the audited event through an injected +//! [`AuditSink`], and folds the same event into the in-memory classes — +//! so the projection an operator sees in-process matches what a later +//! replay of the log produces. + +use std::collections::{BTreeSet, HashMap}; +use std::time::SystemTime; + +use opentelemetry::metrics::Counter; +use opentelemetry::{KeyValue, global}; + +use crate::audit::{AuditEvent, AuditPayload, AuditSink}; +use crate::tenant::TenantId; + +/// Maximum length of an alias assertion's `reason`, in bytes. +/// +/// Mirrors the RFC §6.4 triggering-line-sample cap (256 B) so the +/// audit stream's per-event size stays bounded regardless of operator +/// input. +pub const REASON_BYTE_LIMIT: usize = 256; + +/// `alias_assertions_total` (RFC 0001 §6.8 telemetry table). Named with +/// the pre-redesign identifier the table pins — the dotted-`ourios.*` +/// semconv conversion is the deferred §6.8 redesign, not this slice. +const METRIC_ALIAS_ASSERTIONS_TOTAL: &str = "alias_assertions_total"; +/// `alias_retractions_total` (RFC 0001 §6.8 telemetry table). +const METRIC_ALIAS_RETRACTIONS_TOTAL: &str = "alias_retractions_total"; +/// The `tenant_id` data-point attribute key. Pre-redesign name per the +/// §6.8 table (the namespaced `ourios.tenant` key is the deferred +/// dotted-semconv redesign). +const ATTR_TENANT_ID: &str = "tenant_id"; + +/// The operator / API principal that issued an alias assertion. +/// +/// `[§3.1]` "explicit": aliasing is never anonymous, so every assertion +/// names its actor. This is purely the *identity* of the principal — the +/// authentication / authorization model is out of scope; an `ActorId` +/// is whatever id the control plane already authenticated. +/// +/// Construction validates that the id is non-empty (an empty actor would +/// defeat the "never anonymous" contract); see [`ActorId::new`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ActorId(String); + +impl ActorId { + /// Wrap a non-empty string as an `ActorId`. + /// + /// # Errors + /// Returns [`AliasError::EmptyActor`] if `s` is empty. + pub fn new(s: impl Into) -> Result { + let s = s.into(); + if s.is_empty() { + return Err(AliasError::EmptyActor); + } + Ok(Self(s)) + } + + /// Borrow the underlying string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for ActorId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Errors from the operator-driven alias API. +/// +/// One variant per validated precondition; hand-rolled to match the +/// crate's existing error style (see [`MinerConfigError`](crate::config)). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AliasError { + /// An assertion's `actor` was empty — aliasing is never anonymous + /// (`[§3.1]` "explicit"). + EmptyActor, + /// An assertion's `reason` exceeded [`REASON_BYTE_LIMIT`] bytes. + /// Carries the offending length for diagnostics. + ReasonTooLong(usize), + /// An [`AliasMap::assert`] named fewer than two distinct + /// `template_id`s in its asserted set (`{representative_id} ∪ + /// member_ids`). A class of one id is not an alias set — that id + /// resolves only to itself (RFC0001.16) — so a one-id assertion is + /// a no-op the caller almost certainly did not intend; reject rather + /// than silently emit a meaningless audit event. + DegenerateAssertion, +} + +impl std::fmt::Display for AliasError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyActor => write!( + f, + "alias assertion actor must be non-empty (`[§3.1]`: aliasing is never anonymous)", + ), + Self::ReasonTooLong(len) => write!( + f, + "alias assertion reason is {len} bytes, exceeding the {REASON_BYTE_LIMIT}-byte \ + limit (RFC 0001 §6.4)", + ), + Self::DegenerateAssertion => write!( + f, + "alias assertion must name at least two distinct template_ids in its asserted \ + set {{representative_id}} ∪ member_ids (a one-id class is not an alias set, \ + RFC0001.16)", + ), + } + } +} + +impl std::error::Error for AliasError {} + +/// The operator context common to an alias assert / retract call: who +/// issued it, why, and when. +/// +/// Bundling these keeps the [`AliasMap`] operator API to a small, +/// readable argument list and pins the "every alias action names an +/// actor" contract (`[§3.1]`) at the type level — an [`Operator`] +/// cannot exist without a validated [`ActorId`]. `reason` is validated +/// (≤ [`REASON_BYTE_LIMIT`]) at the call boundary, not here, so the +/// error surfaces from the operation that records it. +#[derive(Debug, Clone)] +pub struct Operator { + /// The principal issuing the action — never anonymous (`[§3.1]`). + pub actor: ActorId, + /// Operator-supplied justification, ≤ [`REASON_BYTE_LIMIT`] bytes + /// (validated by [`AliasMap::assert`] / [`AliasMap::retract`]). + /// Empty string when none given. + pub reason: String, + /// When the action was issued — stamped on the audit event. + pub timestamp: SystemTime, +} + +impl Operator { + /// An [`Operator`] context with an explicit `timestamp`. + #[must_use] + pub fn new(actor: ActorId, reason: impl Into, timestamp: SystemTime) -> Self { + Self { + actor, + reason: reason.into(), + timestamp, + } + } + + /// An [`Operator`] context stamped at [`SystemTime::now`]. + #[must_use] + pub fn now(actor: ActorId, reason: impl Into) -> Self { + Self::new(actor, reason, SystemTime::now()) + } +} + +/// Per-tenant projection of the alias event log (RFC 0001 §6.7). +/// +/// Holds each tenant's equivalence classes, folded from the durable +/// [`AuditPayload::AliasAsserted`] / [`AuditPayload::AliasRetracted`] +/// stream. The operator API ([`Self::assert`] / [`Self::retract`]) +/// emits the audited event through an injected [`AuditSink`] *and* +/// folds it into the in-memory classes; [`Self::from_events`] / +/// [`Self::apply`] rebuild the same classes from a replayed log. +/// +/// Classes are scoped strictly per [`TenantId`] `[§3.7]`: an assertion +/// in one tenant never affects another. +#[derive(Debug)] +pub struct AliasMap { + /// Per-tenant set of equivalence classes. Each inner `BTreeSet` is + /// one class of ≥ 2 members (singletons are not stored — an id in + /// no class resolves to `{id}` by [`Self::resolves`]). + classes: HashMap>>, + assertions_total: Counter, + retractions_total: Counter, +} + +impl AliasMap { + /// Build an empty map whose counters resolve through the process- + /// global meter (RFC 0001 §6.8 API/SDK split: a no-op when no + /// provider is installed, so constructing and recording is always + /// safe). + /// + /// The two counters are seeded with a zero `add` so they surface in + /// the first collection cycle even at zero traffic (§6.8 + /// collect-on-read). The seed carries no `tenant_id` attribute — a + /// per-tenant point appears on the first real assertion / retraction. + #[must_use] + pub fn new() -> Self { + let meter = global::meter("ourios.miner"); + let assertions_total = meter + .u64_counter(METRIC_ALIAS_ASSERTIONS_TOTAL) + .with_unit("{assertion}") + .build(); + let retractions_total = meter + .u64_counter(METRIC_ALIAS_RETRACTIONS_TOTAL) + .with_unit("{retraction}") + .build(); + assertions_total.add(0, &[]); + retractions_total.add(0, &[]); + + Self { + classes: HashMap::new(), + assertions_total, + retractions_total, + } + } + + /// Assert that the union `{representative_id} ∪ member_ids` is one + /// equivalence class under `tenant`. + /// + /// Validates (`by.reason` ≤ [`REASON_BYTE_LIMIT`]; the `by.actor` + /// non-empty contract is already a type guarantee of [`ActorId`], + /// ≥ 2 distinct ids), emits an [`AuditPayload::AliasAsserted`] + /// event through `sink`, folds the asserted set into the tenant's + /// classes (union-on-overlap: any pre-existing class sharing a + /// member merges in), and increments `alias_assertions_total`. + /// `representative_id` is the operator's anchor id only — membership + /// is the union, independent of which id was named the anchor. + /// + /// # Errors + /// - [`AliasError::ReasonTooLong`] if `by.reason` exceeds the limit. + /// - [`AliasError::DegenerateAssertion`] if the asserted set has + /// fewer than two distinct ids. + pub fn assert( + &mut self, + sink: &mut dyn AuditSink, + tenant: &TenantId, + representative_id: u64, + member_ids: Vec, + by: Operator, + ) -> Result<(), AliasError> { + validate_reason(&by.reason)?; + + let asserted: BTreeSet = std::iter::once(representative_id) + .chain(member_ids.iter().copied()) + .collect(); + if asserted.len() < 2 { + return Err(AliasError::DegenerateAssertion); + } + + let event = AuditEvent { + tenant_id: tenant.clone(), + timestamp: by.timestamp, + payload: AuditPayload::AliasAsserted { + representative_id, + member_ids, + actor: by.actor, + reason: by.reason, + }, + }; + sink.emit(event); + + self.union_in(tenant, &asserted); + self.assertions_total.add( + 1, + &[KeyValue::new(ATTR_TENANT_ID, tenant.as_str().to_owned())], + ); + Ok(()) + } + + /// Retract `id` from its alias class under `tenant`. + /// + /// Representative-independent: `id` may be any member, including the + /// derived canonical. Emits an [`AuditPayload::AliasRetracted`] + /// event through `sink` (with `representative_id = id` as the + /// operator's anchor and empty `member_ids`), removes `id` from its + /// class, and increments `alias_retractions_total`. A class that + /// drops to a single member is no longer an alias set and is + /// dropped — that lone id then resolves only to itself + /// (RFC0001.16). The canonical re-derives as `min` of the remainder + /// on the next [`Self::resolves`]. + /// + /// Retracting an id that is in no class is a valid no-op on the + /// projection (the id already resolves to itself), but it is still + /// audited — un-aliasing is explicit and recorded either way + /// (`[§3.1]`). + /// + /// # Errors + /// - [`AliasError::ReasonTooLong`] if `by.reason` exceeds the limit. + pub fn retract( + &mut self, + sink: &mut dyn AuditSink, + tenant: &TenantId, + id: u64, + by: Operator, + ) -> Result<(), AliasError> { + validate_reason(&by.reason)?; + + let event = AuditEvent { + tenant_id: tenant.clone(), + timestamp: by.timestamp, + payload: AuditPayload::AliasRetracted { + representative_id: id, + member_ids: Vec::new(), + actor: by.actor, + reason: by.reason, + }, + }; + sink.emit(event); + + self.remove_id(tenant, id); + self.retractions_total.add( + 1, + &[KeyValue::new(ATTR_TENANT_ID, tenant.as_str().to_owned())], + ); + Ok(()) + } + + /// The equivalence class containing `id` under `tenant`. + /// + /// Returns the whole class (representative and every member — + /// expansion is by the set, not the assertion direction, + /// RFC0001.13). An `id` in no class resolves to the singleton + /// `{id}` (RFC0001.16), identical to bare `template_id = id`. + #[must_use] + pub fn resolves(&self, tenant: &TenantId, id: u64) -> BTreeSet { + self.classes + .get(tenant) + .and_then(|classes| classes.iter().find(|c| c.contains(&id))) + .cloned() + .unwrap_or_else(|| std::iter::once(id).collect()) + } + + /// The derived canonical representative — `min(members)` — of the + /// class containing `id` under `tenant`, or `id` itself when `id` is + /// in no class. + /// + /// A display/identity convenience, **not** what defines membership + /// (RFC 0001 §6.7); it re-derives whenever membership changes. + #[must_use] + pub fn canonical(&self, tenant: &TenantId, id: u64) -> u64 { + self.resolves(tenant, id) + .iter() + .next() + .copied() + .unwrap_or(id) + } + + /// Fold one durable alias event into the projection. + /// + /// The replay path: applying every [`AuditPayload::AliasAsserted`] / + /// [`AuditPayload::AliasRetracted`] event in log order reconstructs + /// the same classes the operator API produced live. Non-alias + /// payloads ([`AuditPayload::Template`] / + /// [`AuditPayload::Compaction`]) are ignored — the alias projection + /// folds only its own two event kinds off the shared §6.4 stream. + pub fn apply(&mut self, event: &AuditEvent) { + match &event.payload { + AuditPayload::AliasAsserted { + representative_id, + member_ids, + .. + } => { + let asserted: BTreeSet = std::iter::once(*representative_id) + .chain(member_ids.iter().copied()) + .collect(); + // A degenerate (< 2 ids) asserted set folds to nothing — + // the live API rejects it, but a replay must tolerate any + // log content without panicking. + if asserted.len() >= 2 { + self.union_in(&event.tenant_id, &asserted); + } + } + AuditPayload::AliasRetracted { + representative_id, + member_ids, + .. + } => { + for id in std::iter::once(*representative_id).chain(member_ids.iter().copied()) { + self.remove_id(&event.tenant_id, id); + } + } + AuditPayload::Template { .. } | AuditPayload::Compaction { .. } => {} + } + } + + /// Rebuild a projection by folding `events` in log order. + /// + /// The counters resolve through the global meter as in [`Self::new`] + /// but a pure replay does **not** re-increment them — the counts + /// belong to the live operator actions, not to a projection rebuild. + #[must_use] + pub fn from_events<'a, I>(events: I) -> Self + where + I: IntoIterator, + { + let mut map = Self::new(); + for event in events { + map.apply(event); + } + map + } + + /// Union `asserted` into `tenant`'s classes, merging every existing + /// class that shares a member (union-on-overlap, order-independent). + fn union_in(&mut self, tenant: &TenantId, asserted: &BTreeSet) { + let classes = self.classes.entry(tenant.clone()).or_default(); + let mut merged = asserted.clone(); + // Drain out every class overlapping the (growing) `merged` set, + // absorbing each into it; repeat until a pass finds no overlap, + // since absorbing one class can bring in ids that now overlap a + // class an earlier pass skipped. + loop { + let mut absorbed_any = false; + let mut i = 0; + while i < classes.len() { + if classes[i].iter().any(|id| merged.contains(id)) { + let overlapping = classes.swap_remove(i); + merged.extend(overlapping); + absorbed_any = true; + } else { + i += 1; + } + } + if !absorbed_any { + break; + } + } + classes.push(merged); + } + + /// Remove `id` from `tenant`'s classes, dropping any class that + /// falls below two members (no longer an alias set) and any + /// tenant entry that empties out. + fn remove_id(&mut self, tenant: &TenantId, id: u64) { + let Some(classes) = self.classes.get_mut(tenant) else { + return; + }; + for class in &mut *classes { + class.remove(&id); + } + classes.retain(|c| c.len() >= 2); + if classes.is_empty() { + self.classes.remove(tenant); + } + } +} + +impl Default for AliasMap { + fn default() -> Self { + Self::new() + } +} + +/// Reject a `reason` longer than [`REASON_BYTE_LIMIT`] bytes. +fn validate_reason(reason: &str) -> Result<(), AliasError> { + if reason.len() > REASON_BYTE_LIMIT { + return Err(AliasError::ReasonTooLong(reason.len())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::InMemoryAuditSink; + + fn actor() -> ActorId { + ActorId::new("op-alice").expect("non-empty actor") + } + + fn op() -> Operator { + Operator::now(actor(), "") + } + + #[test] + fn actor_id_rejects_empty() { + assert_eq!(ActorId::new(""), Err(AliasError::EmptyActor)); + assert!(ActorId::new("op").is_ok()); + } + + #[test] + fn assert_rejects_over_limit_reason() { + // Arrange. + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("t"); + let reason = "x".repeat(REASON_BYTE_LIMIT + 1); + + // Act. + let result = map.assert(&mut sink, &t, 1, vec![2], Operator::now(actor(), reason)); + + // Assert — rejected before any event is emitted. + assert_eq!( + result, + Err(AliasError::ReasonTooLong(REASON_BYTE_LIMIT + 1)) + ); + assert!(sink.is_empty(), "no event emitted on a rejected assertion"); + } + + #[test] + fn assert_rejects_single_id_set() { + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("t"); + + // representative == member → asserted set is {7}, one id. + let result = map.assert(&mut sink, &t, 7, vec![7], op()); + assert_eq!(result, Err(AliasError::DegenerateAssertion)); + assert!(sink.is_empty()); + } + + #[test] + fn union_on_overlap_merges_classes() { + // Arrange — two assertions sharing member B merge into one class. + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("t"); + + // Act — {A,B} then {B,C}; overlap on B. + map.assert(&mut sink, &t, 1, vec![2], op()).unwrap(); + map.assert(&mut sink, &t, 2, vec![3], op()).unwrap(); + + // Assert — one class {1,2,3}; every member resolves to it. + let expected: BTreeSet = [1, 2, 3].into_iter().collect(); + assert_eq!(map.resolves(&t, 1), expected); + assert_eq!(map.resolves(&t, 3), expected); + assert_eq!(map.canonical(&t, 3), 1, "canonical is min(members)"); + } + + #[test] + fn from_events_reconstructs_the_live_projection() { + // Arrange — drive the live API, capture the event log. + let mut live = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("t"); + live.assert(&mut sink, &t, 1, vec![2, 3], op()).unwrap(); + live.retract(&mut sink, &t, 2, op()).unwrap(); + let log = sink.drain(); + + // Act — replay the log into a fresh projection. + let replayed = AliasMap::from_events(&log); + + // Assert — replay matches the live projection. + assert_eq!(replayed.resolves(&t, 1), live.resolves(&t, 1)); + assert_eq!(replayed.resolves(&t, 1), [1, 3].into_iter().collect()); + assert_eq!(replayed.resolves(&t, 2), [2].into_iter().collect()); + } + + // RFC 0001 §6.8 telemetry table: `alias_assertions_total` / + // `alias_retractions_total` are mandatory counters with a + // `tenant_id` attribute. Collect the exported metric stream through + // an in-memory reader (no OTLP endpoint) and assert both surface, + // exactly as the compaction-metrics test does. `init_in_memory` + // installs the *global* provider, so this is a single-provider test. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn alias_counters_are_exported_with_tenant_attribute() { + use opentelemetry_sdk::metrics::data::{ + AggregatedMetrics, MetricData, ResourceMetrics, ScopeMetrics, + }; + + // Arrange — in-memory provider, then the map (so its counters + // resolve against it). + let (guard, exporter) = ourios_telemetry::init_in_memory("ourios-test"); + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("acme"); + + // Act — one assertion, one retraction. + map.assert(&mut sink, &t, 1, vec![2], op()).unwrap(); + map.retract(&mut sink, &t, 2, op()).unwrap(); + guard.force_flush().expect("force_flush succeeds"); + + // Assert — both counters are in the exported stream, and each + // carries a datapoint with the tenant_id attribute. + let rms = exporter.get_finished_metrics().expect("metrics exported"); + let names: Vec = rms + .iter() + .flat_map(ResourceMetrics::scope_metrics) + .flat_map(ScopeMetrics::metrics) + .map(|m| m.name().to_string()) + .collect(); + for expected in [ + METRIC_ALIAS_ASSERTIONS_TOTAL, + METRIC_ALIAS_RETRACTIONS_TOTAL, + ] { + assert!( + names.iter().any(|n| n == expected), + "exported stream missing {expected}, got {names:?}", + ); + } + + let tenant_attr_present = |name: &str| { + let data = rms + .iter() + .flat_map(ResourceMetrics::scope_metrics) + .flat_map(ScopeMetrics::metrics) + .find(|m| m.name() == name) + .unwrap_or_else(|| panic!("{name} missing")) + .data(); + let AggregatedMetrics::U64(MetricData::Sum(sum)) = data else { + panic!("{name} should be a u64 sum"); + }; + sum.data_points().any(|dp| { + dp.attributes() + .any(|kv| kv.key.as_str() == ATTR_TENANT_ID && kv.value.as_str() == "acme") + }) + }; + assert!( + tenant_attr_present(METRIC_ALIAS_ASSERTIONS_TOTAL), + "alias_assertions_total must carry the tenant_id attribute", + ); + assert!( + tenant_attr_present(METRIC_ALIAS_RETRACTIONS_TOTAL), + "alias_retractions_total must carry the tenant_id attribute", + ); + } +} diff --git a/crates/ourios-core/src/audit.rs b/crates/ourios-core/src/audit.rs index ef5600e18..b6a2c209c 100644 --- a/crates/ourios-core/src/audit.rs +++ b/crates/ourios-core/src/audit.rs @@ -23,6 +23,7 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; +use crate::alias::ActorId; use crate::tenant::TenantId; /// The specific state-change of a template-mining @@ -126,6 +127,50 @@ pub enum AuditPayload { /// Which template state-change this is. change: TemplateChange, }, + /// An operator asserted that a set of `template_id`s form one + /// alias equivalence class under this event's tenant + /// (RFC 0001 §6.7). Cross-leaf only — never a `template_version` + /// axis concern, so it carries no `template_version`. + /// + /// The **asserted set** is the union `{representative_id} ∪ + /// member_ids`; `representative_id` is the operator's anchor id for + /// the assertion (one named member), not the set's *derived* + /// canonical (`min(members)`). The projection folds this event by + /// unioning the asserted set into one class + /// ([`crate::alias::AliasMap`]). + AliasAsserted { + /// The operator's anchor id — one member of the asserted set; + /// carries no contract weight beyond naming the assertion. + representative_id: u64, + /// The other ids grouped into the set by this assertion. + member_ids: Vec, + /// The principal that issued the assertion — aliasing is never + /// anonymous (`[§3.1]` "explicit"). + actor: ActorId, + /// Operator-supplied justification, ≤ 256 B + /// ([`crate::alias::REASON_BYTE_LIMIT`], mirroring the §6.4 + /// triggering-line-sample cap). Empty string when none given. + reason: String, + }, + /// An operator retracted an id from its alias class + /// (RFC 0001 §6.7). Representative-independent: `representative_id` + /// names the retracted id (the operator's anchor); `member_ids` is + /// the rest of the retracted set (empty for a single-id + /// retraction). The projection removes every id in the asserted set + /// from its class; a class that drops below two members is no longer + /// an alias set. + AliasRetracted { + /// The operator's anchor id — the (primary) retracted id. + representative_id: u64, + /// Additional ids retracted in the same action (empty for the + /// common single-id retraction). + member_ids: Vec, + /// The principal that issued the retraction — un-aliasing is + /// explicit and audited too (`[§3.1]`). + actor: ActorId, + /// Operator-supplied justification, ≤ 256 B. Empty when none. + reason: String, + }, /// A compaction consolidated a sealed partition's files /// (RFC 0009 §3.6). Carries no template identity. Compaction { @@ -157,6 +202,10 @@ pub const EVENT_KIND_TEMPLATE_TYPE_EXPANDED: u8 = 1; pub const EVENT_KIND_TEMPLATE_WIDENING_REJECTED_DEGENERATE: u8 = 2; /// See [`EVENT_KIND_TEMPLATE_WIDENED`]. pub const EVENT_KIND_COMPACTION: u8 = 3; +/// See [`EVENT_KIND_TEMPLATE_WIDENED`]. RFC 0001 §6.7 alias write path. +pub const EVENT_KIND_ALIAS_ASSERTED: u8 = 4; +/// See [`EVENT_KIND_TEMPLATE_WIDENED`]. RFC 0001 §6.7 alias write path. +pub const EVENT_KIND_ALIAS_RETRACTED: u8 = 5; /// Canonical `event_type` strings paired with the ordinals above /// (RFC 0005 §3.7 / RFC 0001 §6.4 / RFC 0009 §3.6). @@ -168,6 +217,10 @@ pub const EVENT_TYPE_TEMPLATE_WIDENING_REJECTED_DEGENERATE: &str = "template_widening_rejected_degenerate"; /// See [`EVENT_TYPE_TEMPLATE_WIDENED`]. pub const EVENT_TYPE_COMPACTION: &str = "compaction"; +/// See [`EVENT_TYPE_TEMPLATE_WIDENED`]. RFC 0001 §6.7 alias write path. +pub const EVENT_TYPE_ALIAS_ASSERTED: &str = "alias_asserted"; +/// See [`EVENT_TYPE_TEMPLATE_WIDENED`]. RFC 0001 §6.7 alias write path. +pub const EVENT_TYPE_ALIAS_RETRACTED: &str = "alias_retracted"; impl AuditPayload { /// The stable `event_kind` ordinal for this payload (RFC 0005 @@ -183,6 +236,8 @@ impl AuditPayload { EVENT_KIND_TEMPLATE_WIDENING_REJECTED_DEGENERATE } }, + Self::AliasAsserted { .. } => EVENT_KIND_ALIAS_ASSERTED, + Self::AliasRetracted { .. } => EVENT_KIND_ALIAS_RETRACTED, Self::Compaction { .. } => EVENT_KIND_COMPACTION, } } @@ -199,6 +254,8 @@ impl AuditPayload { EVENT_TYPE_TEMPLATE_WIDENING_REJECTED_DEGENERATE } }, + Self::AliasAsserted { .. } => EVENT_TYPE_ALIAS_ASSERTED, + Self::AliasRetracted { .. } => EVENT_TYPE_ALIAS_RETRACTED, Self::Compaction { .. } => EVENT_TYPE_COMPACTION, } } @@ -210,7 +267,13 @@ impl AuditPayload { pub fn counts_as_merge(&self) -> bool { match self { Self::Template { change, .. } => change.counts_as_merge(), - Self::Compaction { .. } => false, + // Alias activity is counted separately via + // `alias_assertions_total` / `alias_retractions_total` + // (RFC 0001 §6.7); `merges_total` is reserved for the two + // structural widenings. + Self::AliasAsserted { .. } | Self::AliasRetracted { .. } | Self::Compaction { .. } => { + false + } } } } diff --git a/crates/ourios-core/src/lib.rs b/crates/ourios-core/src/lib.rs index d544e9a15..09c4d76c4 100644 --- a/crates/ourios-core/src/lib.rs +++ b/crates/ourios-core/src/lib.rs @@ -1,5 +1,6 @@ //! Foundational types for Ourios. +pub mod alias; pub mod audit; pub mod clock; pub mod confidence; diff --git a/crates/ourios-core/tests/rfc0001_alias.rs b/crates/ourios-core/tests/rfc0001_alias.rs index 847554d52..f06d2f046 100644 --- a/crates/ourios-core/tests/rfc0001_alias.rs +++ b/crates/ourios-core/tests/rfc0001_alias.rs @@ -1,13 +1,11 @@ //! RFC 0001 — alias-index write path acceptance criteria (RFC0001.12–.16). //! -//! Red gate (`specified → red`, per the 2026-06-07 alias-write-path -//! amendment to RFC 0001 §6.7): `#[ignore]`'d `unimplemented!()` stubs -//! until the operator-driven alias model lands — the `alias_asserted` / -//! `alias_retracted` audit events on the §6.4 stream, the per-tenant -//! alias-map projection folded from that log, and the operator assertion -//! API. Per `docs/verification.md` §3 the scenarios become ignored stubs -//! first, implementations second; each carries the §2.3 doc-comment form -//! so the spec↔test mapping is greppable. +//! Green gate (`red → green`, per the 2026-06-07 alias-write-path +//! amendment to RFC 0001 §6.7): the operator-driven alias model has +//! landed — the `alias_asserted` / `alias_retracted` audit events on the +//! §6.4 stream, the per-tenant [`AliasMap`] projection folded from that +//! log, and the operator assertion API. Each scenario keeps the §2.3 +//! doc-comment form so the spec↔test mapping stays greppable. //! //! Placement rationale: the alias types live in `ourios-core` alongside //! [`ourios_core::audit`] (the alias events are new `AuditPayload` @@ -15,75 +13,194 @@ //! `ourios-miner` (emission) and `ourios-querier` (`resolves_to` reads), //! so the shared crate is the natural home. The querier-side //! `resolves_to` *DSL surface* is RFC0002.9's gate -//! (`crates/ourios-querier/tests/rfc0002_dsl.rs`); these stubs own the +//! (`crates/ourios-querier/tests/rfc0002_dsl.rs`); these tests own the //! write path and the map's expansion semantics that RFC0002.9 compiles //! against. +use std::collections::BTreeSet; + +use ourios_core::alias::{ActorId, AliasMap, Operator}; +use ourios_core::audit::{AuditPayload, InMemoryAuditSink}; +use ourios_core::tenant::TenantId; + +fn actor() -> ActorId { + ActorId::new("op-alice").expect("non-empty actor") +} + +fn op(reason: &str) -> Operator { + Operator::now(actor(), reason) +} + +fn set(ids: impl IntoIterator) -> BTreeSet { + ids.into_iter().collect() +} + /// Scenario RFC0001.12 — Alias assertion is durably recorded and appears in the per-tenant map. /// See `docs/rfcs/0001-template-miner.md` §5. -#[ignore = "RFC 0001 alias write path pending (RFC0001.12)"] #[test] fn rfc0001_12_alias_assertion_is_durably_recorded_and_in_the_map() { - unimplemented!( - "RFC0001.12 — asserting B is an alias of A (A < B) under tenant T emits a \ - durable `alias_asserted` audit event under the §3.4 WAL-before-ack barrier \ - (naming tenant_id = T, representative_id = A, member_ids = [B], actor, \ - timestamp), and after the projection rebuilds T's alias map holds the class \ - {{A, B}} with derived canonical = A (the smallest member)." - ); + // Arrange — tenant T, an audit sink standing in for the §3.4 + // WAL-durable stream, and the per-tenant projection. A < B. + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("T"); + let (a, b) = (10_u64, 20_u64); + + // Act — assert B is an alias of A under T. + map.assert( + &mut sink, + &t, + a, + vec![b], + op("deploy 2026-06 re-split login"), + ) + .expect("assertion succeeds"); + + // Assert — a durable `alias_asserted` event was emitted naming the + // asserted set, and the map holds the class {A, B} with derived + // canonical = A (the smallest member). + let events = sink.drain(); + assert_eq!(events.len(), 1, "exactly one event durably recorded"); + assert_eq!(events[0].tenant_id, t); + let AuditPayload::AliasAsserted { + representative_id, + ref member_ids, + .. + } = events[0].payload + else { + panic!( + "expected an AliasAsserted payload, got {:?}", + events[0].payload + ); + }; + assert_eq!(representative_id, a); + assert_eq!(member_ids, &vec![b]); + + assert_eq!(map.resolves(&t, a), set([a, b])); + assert_eq!(map.canonical(&t, b), a, "canonical = min(members)"); } /// Scenario RFC0001.13 — `resolves_to(rep)` returns all members and excludes non-members. /// See `docs/rfcs/0001-template-miner.md` §5. -#[ignore = "RFC 0001 alias write path pending (RFC0001.13)"] #[test] fn rfc0001_13_resolves_to_expands_to_the_whole_set() { - unimplemented!( - "RFC0001.13 — for tenant T whose map records {{A, B}} and an unrelated leaf C, \ - `template_id.resolves_to(A)` expands to {{A, B}}; `resolves_to(B)` expands to \ - the same {{A, B}} (member↔representative symmetry — expansion is by the set, \ - not the assertion direction); `resolves_to(C)` expands to exactly {{C}}." + // Arrange — T's map records {A, B}; C is an unrelated leaf. + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("T"); + let (a, b, c) = (10_u64, 20_u64, 30_u64); + map.assert(&mut sink, &t, a, vec![b], op("")) + .expect("assertion succeeds"); + + // Act / Assert — expansion is by the set, not the assertion + // direction: representative and member both expand to {A, B}; the + // unrelated C expands to exactly {C}. + assert_eq!(map.resolves(&t, a), set([a, b])); + assert_eq!( + map.resolves(&t, b), + set([a, b]), + "member↔representative symmetry" ); + assert_eq!(map.resolves(&t, c), set([c])); } /// Scenario RFC0001.14 — Cross-tenant isolation: an alias in tenant A never affects tenant B. /// See `docs/rfcs/0001-template-miner.md` §5. -#[ignore = "RFC 0001 alias write path pending (RFC0001.14)"] #[test] fn rfc0001_14_alias_sets_are_per_tenant_isolated() { - unimplemented!( - "RFC0001.14 `[§3.7]` — tenant T1's map records {{A, B}} while T2 has the same \ - template_ids A and B but no assertion; `resolves_to(A)` expands to {{A, B}} for \ - T1 and to exactly {{A}} for T2 — an assertion in one tenant is invisible to \ - every other." + // Arrange `[§3.7]` — T1's map records {A, B}; T2 has the same ids + // A and B but no assertion. + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let (t1, t2) = (TenantId::new("T1"), TenantId::new("T2")); + let (a, b) = (10_u64, 20_u64); + map.assert(&mut sink, &t1, a, vec![b], op("")) + .expect("assertion succeeds"); + + // Act / Assert — the assertion is visible only in T1. + assert_eq!(map.resolves(&t1, a), set([a, b])); + assert_eq!( + map.resolves(&t2, a), + set([a]), + "invisible to every other tenant" ); + assert_eq!(map.resolves(&t2, b), set([b])); } /// Scenario RFC0001.15 — Retraction removes any member, including the canonical, and is itself audited. /// See `docs/rfcs/0001-template-miner.md` §5. -#[ignore = "RFC 0001 alias write path pending (RFC0001.15)"] #[test] fn rfc0001_15_retraction_removes_a_member_and_rederives_canonical() { - unimplemented!( - "RFC0001.15 — retracting member A (the canonical / smallest) from class {{A, B}} \ - under tenant T emits a durable `alias_retracted` audit event (same \ - WAL-before-ack barrier and field shape as RFC0001.12: representative_id = A \ - as the operator's anchor, empty member_ids, actor); after the projection \ - rebuilds the class is {{B}} — a single member, no longer an alias set — so \ - `resolves_to(A)` expands to {{A}} and `resolves_to(B)` expands to {{B}} \ - (representative-independent retraction; canonical re-derived as min of the \ - remainder)." + // Arrange — class {A, B} under T, A the canonical (smallest). + let mut map = AliasMap::new(); + let mut sink = InMemoryAuditSink::new(); + let t = TenantId::new("T"); + let (a, b) = (10_u64, 20_u64); + map.assert(&mut sink, &t, a, vec![b], op("")) + .expect("assertion succeeds"); + let _ = sink.drain(); + + // Act — retract member A (the canonical / smallest). + map.retract(&mut sink, &t, a, op("")) + .expect("retraction succeeds"); + + // Assert — a durable `alias_retracted` event was emitted + // (representative_id = A as the operator's anchor, empty member_ids); + // the class drops to {B} (a single member, no longer an alias set), + // so both A and B now resolve only to themselves + // (representative-independent retraction; canonical re-derived as min + // of the remainder). + let events = sink.drain(); + assert_eq!(events.len(), 1, "the retraction is itself audited"); + let AuditPayload::AliasRetracted { + representative_id, + ref member_ids, + .. + } = events[0].payload + else { + panic!( + "expected an AliasRetracted payload, got {:?}", + events[0].payload + ); + }; + assert_eq!(representative_id, a); + assert!(member_ids.is_empty()); + + assert_eq!(map.resolves(&t, a), set([a])); + assert_eq!( + map.resolves(&t, b), + set([b]), + "remainder is no longer an alias set" + ); + assert_eq!( + map.canonical(&t, b), + b, + "canonical re-derived over the remainder" ); + + // And retraction is representative-independent: retracting the + // *non-canonical* member from a fresh {A, B} drops it the same way. + let mut map2 = AliasMap::new(); + let mut sink2 = InMemoryAuditSink::new(); + map2.assert(&mut sink2, &t, a, vec![b], op("")) + .expect("assertion succeeds"); + map2.retract(&mut sink2, &t, b, op("")) + .expect("retraction succeeds"); + assert_eq!(map2.resolves(&t, a), set([a])); + assert_eq!(map2.resolves(&t, b), set([b])); } /// Scenario RFC0001.16 — A non-aliased id resolves to itself. /// See `docs/rfcs/0001-template-miner.md` §5. -#[ignore = "RFC 0001 alias write path pending (RFC0001.16)"] #[test] fn rfc0001_16_non_aliased_id_resolves_to_itself() { - unimplemented!( - "RFC0001.16 — for tenant T with leaf Z and no assertion naming Z, \ - `template_id.resolves_to(Z)` expands to exactly {{Z}} — identical to the \ - base-member behaviour and to bare `template_id = Z` (RFC0001.6)." - ); + // Arrange — tenant T with leaf Z and no assertion naming Z. + let map = AliasMap::new(); + let t = TenantId::new("T"); + let z = 42_u64; + + // Act / Assert — Z expands to exactly {Z}, identical to the + // base-member behaviour and to bare `template_id = Z` (RFC0001.6). + assert_eq!(map.resolves(&t, z), set([z])); + assert_eq!(map.canonical(&t, z), z); } diff --git a/crates/ourios-parquet/src/audit_record_batch.rs b/crates/ourios-parquet/src/audit_record_batch.rs index d140403fc..3475212f7 100644 --- a/crates/ourios-parquet/src/audit_record_batch.rs +++ b/crates/ourios-parquet/src/audit_record_batch.rs @@ -110,6 +110,17 @@ pub enum AuditBatchError { old_template: String, new_template: String, }, + /// An `alias_asserted` / `alias_retracted` event (RFC 0001 §6.7) + /// was handed to the audit-Parquet writer, whose §3.7 schema has + /// no columns to represent the alias payload (`representative_id`, + /// `member_ids`, `actor`, `reason`). Adding those columns is the + /// RFC 0005 storage split (sibling to issue #147), deliberately + /// out of scope for the alias write-path slice — so the writer + /// rejects rather than inventing columns or dropping the event + /// silently. Carries the offending `event_type` for diagnostics. + /// Alias events are durable via the alias event log; their + /// audit-Parquet materialization waits for that split. + AliasEventNotYetPersistable { event_type: &'static str }, /// Arrow rejected the constructed `RecordBatch` (column-length /// mismatch, schema-shape mismatch). Internal bug if it ever /// fires — the array builders are constructed against @@ -141,6 +152,13 @@ impl fmt::Display for AuditBatchError { = {new_template:?}, but RFC 0005 §3.7 requires they be equal for this \ variant (template tokens don't change)", ), + Self::AliasEventNotYetPersistable { event_type } => write!( + f, + "audit event {event_type} (RFC 0001 §6.7 alias write path) is not yet \ + representable in the audit-Parquet schema; the alias columns are the RFC \ + 0005 storage split (issue #147 sibling). Alias events are durable via the \ + alias event log, not this writer.", + ), Self::Arrow(e) => write!(f, "arrow rejected RecordBatch: {e}"), } } @@ -151,7 +169,8 @@ impl std::error::Error for AuditBatchError { match self { Self::PreEpochTimestamp | Self::TimestampOverflow { .. } - | Self::TemplateMustNotChange { .. } => None, + | Self::TemplateMustNotChange { .. } + | Self::AliasEventNotYetPersistable { .. } => None, Self::Arrow(e) => Some(e), } } @@ -285,6 +304,16 @@ impl Builders { } self.append_template_change(change)?; } + AuditPayload::AliasAsserted { .. } | AuditPayload::AliasRetracted { .. } => { + // RFC 0001 §6.7 alias events have no audit-Parquet + // columns yet — that schema extension is the RFC 0005 + // split (#147 sibling). Reject rather than persist a + // lossy row; alias events are durable via the alias + // event log meanwhile. + return Err(AuditBatchError::AliasEventNotYetPersistable { + event_type: e.payload.event_type(), + }); + } AuditPayload::Compaction { partition, input_files, @@ -634,4 +663,30 @@ mod tests { .expect_err("pre-epoch timestamp must error"); assert!(matches!(err, AuditBatchError::PreEpochTimestamp)); } + + #[test] + fn alias_events_are_rejected_pending_the_rfc_0005_split() { + // RFC 0001 §6.7 alias events have no audit-Parquet columns yet + // (the RFC 0005 storage split). The writer must reject them + // rather than persist a lossy row or panic; they stay durable + // via the alias event log meanwhile. + let asserted = AuditEvent { + tenant_id: TenantId::new("acme"), + timestamp: ts(1_775_127_600), + payload: AuditPayload::AliasAsserted { + representative_id: 1, + member_ids: vec![2], + actor: ourios_core::alias::ActorId::new("op").expect("non-empty actor"), + reason: String::new(), + }, + }; + let err = audit_events_to_batch(std::slice::from_ref(&asserted)) + .expect_err("alias events are not yet persistable"); + assert!(matches!( + err, + AuditBatchError::AliasEventNotYetPersistable { + event_type: "alias_asserted" + } + )); + } } From bd82c3559782b9258f1acd7ee9f67df984284474 Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Sun, 7 Jun 2026 23:30:51 +0200 Subject: [PATCH 2/3] perf(core): drop the clone in canonical() + the attribute-less counter seed Review: canonical() now reads min via BTreeSet::first on the stored class (no resolves() clone); removed the attribute-less add(0,&[]) counter seeds that created a spurious tenant_id-less timeseries (the counters carry tenant_id and materialize per-tenant on first increment). Co-Authored-By: Claude Opus 4.8 --- crates/ourios-core/src/alias.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/ourios-core/src/alias.rs b/crates/ourios-core/src/alias.rs index b9e871244..3bce317df 100644 --- a/crates/ourios-core/src/alias.rs +++ b/crates/ourios-core/src/alias.rs @@ -227,9 +227,6 @@ impl AliasMap { .u64_counter(METRIC_ALIAS_RETRACTIONS_TOTAL) .with_unit("{retraction}") .build(); - assertions_total.add(0, &[]); - retractions_total.add(0, &[]); - Self { classes: HashMap::new(), assertions_total, @@ -361,10 +358,10 @@ impl AliasMap { /// (RFC 0001 §6.7); it re-derives whenever membership changes. #[must_use] pub fn canonical(&self, tenant: &TenantId, id: u64) -> u64 { - self.resolves(tenant, id) - .iter() - .next() - .copied() + self.classes + .get(tenant) + .and_then(|classes| classes.iter().find(|c| c.contains(&id))) + .and_then(|c| c.first().copied()) .unwrap_or(id) } From 0816f13802a96d30bad08e8b4d3e94fdd3831e9d Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Sun, 7 Jun 2026 23:37:29 +0200 Subject: [PATCH 3/3] docs(core): fix stale counter-seed doc + use &payload in alias tests Review pass 2: AliasMap::new doc no longer claims a zero-seed (removed); the RFC0001.12/.15 audit-payload assertions match on &events[0].payload (ergonomics) instead of a place + ref binding, matching the repo idiom. Co-Authored-By: Claude Opus 4.8 --- crates/ourios-core/src/alias.rs | 8 ++++---- crates/ourios-core/tests/rfc0001_alias.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/ourios-core/src/alias.rs b/crates/ourios-core/src/alias.rs index 3bce317df..027562022 100644 --- a/crates/ourios-core/src/alias.rs +++ b/crates/ourios-core/src/alias.rs @@ -212,10 +212,10 @@ impl AliasMap { /// provider is installed, so constructing and recording is always /// safe). /// - /// The two counters are seeded with a zero `add` so they surface in - /// the first collection cycle even at zero traffic (§6.8 - /// collect-on-read). The seed carries no `tenant_id` attribute — a - /// per-tenant point appears on the first real assertion / retraction. + /// `alias_assertions_total` / `alias_retractions_total` carry a + /// `tenant_id` attribute, so a per-tenant point appears on the first + /// real assertion / retraction (§6.8 collect-on-read) — they are not + /// zero-seeded, which would emit a spurious attribute-less series. #[must_use] pub fn new() -> Self { let meter = global::meter("ourios.miner"); diff --git a/crates/ourios-core/tests/rfc0001_alias.rs b/crates/ourios-core/tests/rfc0001_alias.rs index f06d2f046..3fd421b5d 100644 --- a/crates/ourios-core/tests/rfc0001_alias.rs +++ b/crates/ourios-core/tests/rfc0001_alias.rs @@ -64,16 +64,16 @@ fn rfc0001_12_alias_assertion_is_durably_recorded_and_in_the_map() { assert_eq!(events[0].tenant_id, t); let AuditPayload::AliasAsserted { representative_id, - ref member_ids, + member_ids, .. - } = events[0].payload + } = &events[0].payload else { panic!( "expected an AliasAsserted payload, got {:?}", events[0].payload ); }; - assert_eq!(representative_id, a); + assert_eq!(*representative_id, a); assert_eq!(member_ids, &vec![b]); assert_eq!(map.resolves(&t, a), set([a, b])); @@ -154,16 +154,16 @@ fn rfc0001_15_retraction_removes_a_member_and_rederives_canonical() { assert_eq!(events.len(), 1, "the retraction is itself audited"); let AuditPayload::AliasRetracted { representative_id, - ref member_ids, + member_ids, .. - } = events[0].payload + } = &events[0].payload else { panic!( "expected an AliasRetracted payload, got {:?}", events[0].payload ); }; - assert_eq!(representative_id, a); + assert_eq!(*representative_id, a); assert!(member_ids.is_empty()); assert_eq!(map.resolves(&t, a), set([a]));