diff --git a/Cargo.lock b/Cargo.lock index c8b82e10..8d7999d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -841,8 +841,8 @@ dependencies = [ [[package]] name = "ciris-edge" -version = "13.0.9" -source = "git+https://github.com/CIRISAI/CIRISEdge?tag=v13.0.9#2a4dad25606b02ac8178ea73cb5f8ef88477fadd" +version = "13.1.0" +source = "git+https://github.com/CIRISAI/CIRISEdge?tag=v13.1.0#3fa15f1527753d815b0becde834b88a9ccc95552" dependencies = [ "async-trait", "axum", @@ -960,8 +960,8 @@ dependencies = [ [[package]] name = "ciris-persist" -version = "17.3.0" -source = "git+https://github.com/CIRISAI/CIRISPersist?tag=v17.3.0#c93fb98e8de980fb9cc40e9ee07ff5e5dc74949f" +version = "17.5.0" +source = "git+https://github.com/CIRISAI/CIRISPersist?tag=v17.5.0#137b4f6f371607a5b3271c7a79202b6bb4b9fa93" dependencies = [ "async-trait", "base64 0.22.1", @@ -4837,8 +4837,8 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "reticulum-core" -version = "0.9.1+ciris.1" -source = "git+https://github.com/CIRISAI/leviculum?tag=v0.9.1%2Bciris.1#ce280fe24611ef2794a7c3a5cca76c66165f7357" +version = "0.9.2+ciris.1" +source = "git+https://github.com/CIRISAI/leviculum?tag=v0.9.2%2Bciris.1#82ae6be7e855cf50c99a01641efa9864eab5d26f" dependencies = [ "aes", "cbc", @@ -4855,8 +4855,8 @@ dependencies = [ [[package]] name = "reticulum-std" -version = "0.9.1+ciris.1" -source = "git+https://github.com/CIRISAI/leviculum?tag=v0.9.1%2Bciris.1#ce280fe24611ef2794a7c3a5cca76c66165f7357" +version = "0.9.2+ciris.1" +source = "git+https://github.com/CIRISAI/leviculum?tag=v0.9.2%2Bciris.1#82ae6be7e855cf50c99a01641efa9864eab5d26f" dependencies = [ "clap 4.6.1", "hmac 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index 93d44695..b2d43a0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,8 +129,8 @@ ciris-lens-core = { path = "crates/ciris-lens-core" } # ABSORBED in-tree (wo # `cirislens_wa_cert` + `cirislens_service_token_revocation` expose the auth # substrate the fabric absorbs (CIRISServer#9, src/auth): the agent's `wa_cert` # table (users/WA/OAuth/api-keys) + the `revoked_service_tokens` table. -ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.3.0", features = ["sqlite", "cirisgraph", "cirislens_wa_cert", "cirislens_service_token_revocation", "cirisaudit", "cirislens_tasks", "cirislens_thoughts", "cirislens_tickets", "cirislens_correlations", "cirislens_deferral_reports", "cirislens_maintenance_locks", "cirislens_creation_ceremonies", "cirislens_legacy_migration", "cirisincident", "telemetry", "secrets"] } -ciris-edge = { git = "https://github.com/CIRISAI/CIRISEdge", tag = "v13.0.9", features = ["transport-reticulum", "transport-http", "transport-packet-radio", "ffi-uniffi"] } +ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.5.0", features = ["sqlite", "cirisgraph", "cirislens_wa_cert", "cirislens_service_token_revocation", "cirisaudit", "cirislens_tasks", "cirislens_thoughts", "cirislens_tickets", "cirislens_correlations", "cirislens_deferral_reports", "cirislens_maintenance_locks", "cirislens_creation_ceremonies", "cirislens_legacy_migration", "cirisincident", "telemetry", "secrets"] } +ciris-edge = { git = "https://github.com/CIRISAI/CIRISEdge", tag = "v13.1.0", features = ["transport-reticulum", "transport-http", "transport-packet-radio", "ffi-uniffi"] } # Founder-quorum verification + key-id fingerprint at the composition root # (threshold:: module path — NOT re-exported at the crate root). # LOCKSTEP: persist v9.4.0 transitively pins verify-core v6.6.x — the whole @@ -227,7 +227,7 @@ tracing-appender = "0.2" # - keyring `tpm` (tss-esapi / TPM 2.0) — the real hardware backend; needs # libtss2 (Linux build dep). Other targets keep the software keystore. [target.'cfg(target_os = "linux")'.dependencies] -ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.3.0", features = ["postgres", "cirisgraph", "cirislens_wa_cert", "cirislens_service_token_revocation", "cirisaudit", "cirislens_tasks", "cirislens_thoughts", "cirislens_tickets", "cirislens_correlations", "cirislens_deferral_reports", "cirislens_maintenance_locks", "cirislens_creation_ceremonies", "cirislens_legacy_migration", "cirisincident", "telemetry", "secrets"] } +ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.5.0", features = ["postgres", "cirisgraph", "cirislens_wa_cert", "cirislens_service_token_revocation", "cirisaudit", "cirislens_tasks", "cirislens_thoughts", "cirislens_tickets", "cirislens_correlations", "cirislens_deferral_reports", "cirislens_maintenance_locks", "cirislens_creation_ceremonies", "cirislens_legacy_migration", "cirisincident", "telemetry", "secrets"] } # keyring `tpm` (TPM-at-rest, Linux-only via tss-esapi). `pkcs11` is no longer # per-target — it rides the global `pkcs11` feature now that cryptoki builds on all # targets (CIRISVerify v6.12.0). @@ -302,13 +302,13 @@ ciris-crypto = { git = "https://github.com/CIRISAI/CIRISVerify", tag = "v10.2.0 # and never encodes/decodes). This lets tests/chaos_mesh.rs exercise the SUBSTRATE # codec (`fountain_encode`/`fountain_decode`) directly — a real MEASURED proof, # not a reference stand-in. -ciris-edge = { git = "https://github.com/CIRISAI/CIRISEdge", tag = "v13.0.9", features = ["codec-fountain"] } +ciris-edge = { git = "https://github.com/CIRISAI/CIRISEdge", tag = "v13.1.0", features = ["codec-fountain"] } # Enable persist's TEST-ONLY genesis-seam (`test-genesis-seam`, CIRISPersist#387) for the # accord ceremony tests (tests/accord.rs): `Engine::with_signer_no_genesis_seed` yields a # clean engine with the baked HUMANITY_ACCORD family seed SKIPPED, so those tests can stand # up their OWN custom-holder family via the assemble ceremony. Dev/test ONLY — resolver-2 # keeps this feature out of the shipped lib/bin/wheel (prod always seeds the baked family). -ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.3.0", features = ["test-genesis-seam"] } +ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.5.0", features = ["test-genesis-seam"] } # PQC realtime-A/V streaming E2E benchmark (CIRISEdge#62 realtime_av profile). [[bench]] diff --git a/crates/ciris-lens-core/Cargo.toml b/crates/ciris-lens-core/Cargo.toml index 19038f42..6d205b2f 100644 --- a/crates/ciris-lens-core/Cargo.toml +++ b/crates/ciris-lens-core/Cargo.toml @@ -25,8 +25,8 @@ crate-type = ["cdylib", "rlib"] # # OQ-01 closure 2026-05-03: rlib primary, PyO3 cdylib via `python` # feature for the deployed-lens cutover. -ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.3.0", version = "17", features = ["extract", "sqlite"] } -ciris-edge = { git = "https://github.com/CIRISAI/CIRISEdge", tag = "v13.0.9", version = "13", features = ["transport-http", "transport-reticulum"] } +ciris-persist = { git = "https://github.com/CIRISAI/CIRISPersist", tag = "v17.5.0", version = "17", features = ["extract", "sqlite"] } +ciris-edge = { git = "https://github.com/CIRISAI/CIRISEdge", tag = "v13.1.0", version = "13", features = ["transport-http", "transport-reticulum"] } # Keyring — relay mode (CIRISLensCore#10) loads its Edge transport- # signing identity via `ciris_keyring::load_local_seed`. Edge's diff --git a/src/compose_policy.rs b/src/compose_policy.rs index cea72fb9..678a0c0d 100644 --- a/src/compose_policy.rs +++ b/src/compose_policy.rs @@ -217,10 +217,11 @@ impl TrustSet { /// Pin a key AND classify it as one of the CC 3.4.9 `licensure:*` /// co-stewards. A co-steward is trusted by construction (pinning is /// implied) — an unpinned "co-steward" would be a contradiction. - /// CIRISServer#253: RETIRED as a production path — `compose_for_key` now - /// resolves co-steward roles from the substrate (`has_effective_role`, - /// persist v17). Kept for unit tests, which compose over a synthetic - /// corpus with no live accord roster to resolve against. + /// CIRISServer#253/#267: in production `compose_for_key` resolves + /// co-steward roles from the substrate (`has_effective_role`, persist + /// v17), consulting these consumer pins FIRST; the pin API is also what + /// lets unit tests compose over a synthetic corpus with no live accord + /// roster to resolve against. pub fn pin_co_steward(&mut self, key_id: impl Into, class: CoSteward) -> &mut Self { let key_id = key_id.into(); self.pinned.insert(key_id.clone()); @@ -533,72 +534,298 @@ impl Composer { &self.trust } - /// Compose every `scores` attestation about `attested_key_id` that this - /// node holds, reading from persist. The async convenience wrapper over - /// [`Composer::compose`]. + /// Compose the substrate's verdict for one `(dimension, attested_key_id)` + /// tuple — the production read path, now executed **substrate-side** via + /// persist v17.4.0's `resolve_scores` (CIRISServer#267, FSD-005 Appendix C, + /// CIRISPersist#455/#456). + /// + /// ## Why this is one substrate call and not a client fold (#267) + /// + /// Pre-#267 this read `list_attestations_for(subject)` — the WHOLE signed + /// history — and re-implemented the `(subject, dimension, trust, time, + /// state) → verdict` fold in Rust, plus an N+1 `has_effective_role` pair + /// per `licensure:*` attester in that history (the 2026-07-14 demand + /// survey's "wrong-shaped-read → client-fold" smell). `resolve_scores` + /// runs the CEG §6.1 precedence + per-attester latest-wins + CC 4.4.2 + /// polarity aggregation as ONE composite op inside persist's `.so` (the + /// #329 pattern), so a cohabiting consumer can never run a stale composer + /// against newer data — and the history never crosses the seam. + /// + /// The ONE [`AttestationFilter`] is built once (the pin-once contract, + /// Appendix C.4): a future substrate re-layout is invisible here. + /// + /// ## What each screen became (persist now owns the fold) + /// + /// | pre-#267 client screen | #267 home | + /// |---|---| + /// | Policy A trust gate (CC 4.4.3.8) | `attester_filter: Explicit(pinned)` — untrusted rows never leave the substrate | + /// | staleness (`expires_at`, CC 2.1) | `valid_at: now` (`src/scorer.rs` mirrors `valid_until` onto `expires_at`) | + /// | retraction precedence (CEG §6.1) | the `resolve_scores` fold (`supersedes`/`withdraws`/`recants`) | + /// | aggregation (CC 4.4.2) | `policy` = signed-mean / boolean-min, verdict as a [`ConfidenceBand`](ciris_persist::read::ConfidenceBand) | + /// + /// ## Fidelity deltas vs. the retired client fold (stated per #267) + /// + /// The pure [`Composer::compose`] REMAINS the CC 4.4 normative surface, + /// adversarially pinned by `tests/compose_policy.rs`; what follows are the + /// deltas of THIS production read against it: + /// + /// - **Per-tuple, not per-corpus.** The signature gained `dimension` + /// (persist's verdict is per `(subject, dimension)`); the returned + /// [`Composition`] carries exactly one [`Verdict`] — always, including + /// the empty fold ([`Decision::Undetermined`], visibly undecided). + /// - **persist owns `value` and the band.** [`Verdict::value`] is the + /// fold's `aggregate` (from the open trace); the qualitative band maps + /// to [`Decision`] (`InsufficientWitnesses` → `Undetermined`, else the + /// consumer `threshold` over the aggregate, as before). + /// - **CC 4.4.1 weighting is not yet substrate-side.** Frickerian + /// low-density and CC 3.4.7 self-track-record downweights do not run + /// here (contributions report `weight = 1.0`); persist's fold notes the + /// server-tier column-resolver refinement as its own TODO. + /// - **Polarity coverage.** persist executes `signed-mean` and + /// `boolean-min`; [`Polarity::Detector`]/[`Polarity::Enumerated`]/ + /// [`Polarity::PositiveOnly`] dimensions currently fold under the + /// signed-mean default (flagged; the reported [`Verdict::polarity`] + /// stays the CC 3.1 column so consumers see the intent). + /// - **No refusal ledger.** Gated/untrusted/expired rows are excluded + /// server-side and never cross the wire (no verdict-differencing), so + /// [`Composition::refusals`] only carries the CC 3.4.5 re-check below. + /// - **CC 3.1.9.3 sole-evidence screen** (`slashing:*` vs + /// `testimonial_witness:*` `evidence_refs`) needs the raw corpus and + /// runs only at admission + in the pure compose tier (flagged). + /// - **No Policy D tie-break** — persist's residual determinism is + /// latest-`asserted_at`/lex-id, not CC 4.4.3.9 affected-population. + /// + /// ## What is deliberately KEPT client-side + /// + /// - **CC 3.4.9 licensure cap** ("single-source … MUST be marked as + /// `confidence ≤ 0.5` in consumer composition"): co-steward classes are + /// resolved via `has_effective_role` (CIRISPersist#440) over the fold's + /// HEAD attesters only — bounded by `contributor_count`, never by + /// history size, which is what kills the survey's N+1. The cap marks + /// the reported confidences; the persist-owned `value`/band are not + /// re-capped (CC's MUST is about the confidence marking). + /// `TrustSet::pin_co_steward` pins still resolve first (tests compose + /// over synthetic corpora with no live accord roster). + /// - **CC 3.4.5 self-emission re-check** (CC 3.4.7: "Both checks MUST + /// agree"): a `capacity:*` head attested by its own subject — which the + /// substrate admission gate should have refused — is surfaced as a + /// [`RefusalReason::SelfEmission`] refusal and the verdict fails closed + /// to [`Decision::Undetermined`] (we cannot subtract it from persist's + /// aggregate without re-folding, and a disagreement between the two + /// lines of defense must never silently affirm). /// /// # Errors - /// Propagates the federation-directory read error. + /// Propagates the `resolve_scores` / `has_effective_role` substrate errors + /// (fail-closed: no verdict without a substrate answer). pub async fn compose_for_key( &self, engine: &Engine, attested_key_id: &str, + dimension: &str, ) -> Result { - let dir = engine.federation_directory(); - let rows = dir - .list_attestations_for(attested_key_id) - .await - .map_err(|e| anyhow::anyhow!("list_attestations_for({attested_key_id}): {e}"))?; - - // CC 3.4.9 co-steward resolution — CIRISServer#253, persist v17. - // - // `licensure_cap` caps a `licensure:*` fold below full confidence until - // BOTH co-stewards (CIRISRegistry + CIRISVerify) have emitted. Which - // co-steward an attesting key IS was, pre-17.0.0, unresolvable from the - // substrate — the identity_type vocabulary had no `registry`/`verify` - // member — so the composer resolved it by an out-of-band consumer PIN - // (`TrustSet::pin_co_steward`, the #159 workaround). persist v17 - // (CIRISPersist#440) carries the co-steward roles ON the key record with - // accord-conferred, self-authenticating semantics, so we resolve from the - // substrate here and RETIRE the by-pin fallback in production. - // - // `has_effective_role` is self-authenticating by design: a decorative - // pre-17 self-claimed `roles=["registry"]` row reads `false` (its co-scrub - // set must re-verify against the LIVE accord roster), so this read never - // trusts write-gate history. Resolution happens in THIS async phase; the - // pure `compose` stays synchronous (and the pin API remains, for tests). use ciris_persist::federation::admission::has_effective_role; use ciris_persist::federation::types::identity_type::{REGISTRY, VERIFY}; - let mut trust = self.trust.clone(); - let mut resolved: BTreeMap = BTreeMap::new(); - for att in &rows { - if !envelope_str(att, "dimension").is_some_and(|d| d.starts_with(DIM_LICENSURE)) { - continue; - } - let k = &att.attesting_key_id; - if resolved.contains_key(k) { + use ciris_persist::read::{AttestationFilter, AttesterSet, ConfidenceBand, LifecycleView}; + + let dir = engine.federation_directory(); + let now = self.cfg.now.unwrap_or_else(Utc::now); + + // ── The ONE pin-once filter (FSD-005 Appendix C.4) ─────────────────── + // subject axis: server-emitted scores set `subject_key_ids == + // [attested_key_id]` (`src/scorer.rs`), so the V106 subject seek is the + // same population the retired attested_key_id point-read served. + let mut filter = AttestationFilter::default(); + filter.subject_key_id = Some(attested_key_id.to_owned()); + filter.dimension_exact = Some(dimension.to_owned()); + // CC 2.1 staleness, server-side: `asserted_at <= now < expires_at`. + filter.valid_at = Some(now); + // CEG §6.1 lifecycle: retracted rows never reach the fold. + filter.lifecycle = LifecycleView::Live; + // CC 4.4.3.8 Policy A, server-side: ONLY pinned attesters contribute. + // An empty trust set compiles to a constant-false predicate — zero + // rows, InsufficientWitnesses, Undetermined (fail-closed preserved). + filter.attester_filter = Some(AttesterSet::Explicit( + self.trust.pinned().map(str::to_owned).collect(), + )); + + // CC 4.4.2 polarity → the persist policy id. Detector / Enumerated / + // PositiveOnly fold under persist's signed-mean default for now (see + // the doc-comment delta above). + let polarity = polarity_for(dimension); + let policy = match polarity { + Polarity::BooleanViaScore | Polarity::NegativeOnly => "cc-4.4.2-boolean-min", + _ => "cc-4.4.2-signed-mean", + }; + + // The node composes on its own behalf: its derived federation key_id + // is the §4.3 caller (self/family/community-scoped rows it is admitted + // to stay visible). A node without a derived key reads unauthenticated + // (broad tiers — which includes every federation-scope row). + let caller = engine.local_derived_key_id().await.unwrap_or_default(); + + // ── ONE substrate call: gate + fetch + fold inside persist's `.so` ── + let verdict = dir + .resolve_scores(&caller, filter, policy.to_owned(), /* trace: */ true) + .await + .map_err(|e| anyhow::anyhow!("resolve_scores({attested_key_id}, {dimension}): {e}"))?; + + // The trace is the OPEN escape hatch (Appendix C.3) — requested above, + // so its absence is a substrate contract violation, not an empty fold. + let trace = verdict + .trace + .as_ref() + .ok_or_else(|| anyhow::anyhow!("resolve_scores returned no trace (trace=true)"))?; + let aggregate = trace + .get("aggregate") + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0); + let empty = Vec::new(); + let inputs = trace + .get("inputs") + .and_then(serde_json::Value::as_array) + .unwrap_or(&empty); + + // The fold's HEADS are the contributors (one per attester, CEG §6.1 + // latest-wins) — exactly what persist aggregated. + struct Head { + attestation_id: String, + attester: String, + score: f64, + confidence: f64, + } + let mut heads: Vec = Vec::new(); + for i in inputs { + if i.get("is_head").and_then(serde_json::Value::as_bool) != Some(true) { continue; } - if has_effective_role(dir.as_ref(), k, REGISTRY) - .await - .map_err(|e| anyhow::anyhow!("has_effective_role({k}, registry): {e}"))? - { - resolved.insert(k.clone(), CoSteward::Registry); - } else if has_effective_role(dir.as_ref(), k, VERIFY) - .await - .map_err(|e| anyhow::anyhow!("has_effective_role({k}, verify): {e}"))? - { - resolved.insert(k.clone(), CoSteward::Verify); + heads.push(Head { + attestation_id: i + .get("attestation_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(), + attester: i + .get("attester") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(), + score: i + .get("score") + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0), + confidence: i + .get("confidence") + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0), + }); + } + + // ── CC 3.4.9 licensure cap — HEADS-only co-steward resolution ─────── + // Bounded by contributor_count (the survey's N+1 ranged over the whole + // history). Consumer pins resolve first; the substrate roles + // (CIRISPersist#440, accord-conferred + self-authenticating) resolve + // the rest. + let mut licensure_capped = false; + if dimension.starts_with(DIM_LICENSURE) { + let mut classes: BTreeSet = BTreeSet::new(); + for h in &heads { + if let Some(class) = self.trust.co_steward(&h.attester) { + classes.insert(class); + continue; + } + if has_effective_role(dir.as_ref(), &h.attester, REGISTRY) + .await + .map_err(|e| { + anyhow::anyhow!("has_effective_role({}, registry): {e}", h.attester) + })? + { + classes.insert(CoSteward::Registry); + } else if has_effective_role(dir.as_ref(), &h.attester, VERIFY) + .await + .map_err(|e| { + anyhow::anyhow!("has_effective_role({}, verify): {e}", h.attester) + })? + { + classes.insert(CoSteward::Verify); + } } + // Fail-closed reading of "only one of the two co-stewards has + // emitted": zero classes is also < 2 and is capped; two keys of + // the SAME institution do not lift the cap. + licensure_capped = classes.len() < 2; } - for (k, class) in resolved { - trust.pin_co_steward(k, class); + + // ── CC 3.4.5 re-check (CC 3.4.7: "Both checks MUST agree") ────────── + // A self-emitted capacity head slipping through the substrate gate is + // a disagreement between the two lines of defense → refuse + fail + // closed to Undetermined rather than affirm over a tainted aggregate. + let mut refusals: Vec = Vec::new(); + if dimension.starts_with(DIM_CAPACITY) { + for h in &heads { + if h.attester == attested_key_id { + refusals.push(Refusal { + attestation_id: h.attestation_id.clone(), + attesting_key_id: h.attester.clone(), + dimension: Some(dimension.to_owned()), + reason: RefusalReason::SelfEmission, + }); + } + } } - let composer = Composer { - trust, - cfg: self.cfg.clone(), + let self_emission_disagreement = !refusals.is_empty(); + + let contributions: Vec = heads + .iter() + .map(|h| Contribution { + attestation_id: h.attestation_id.clone(), + attesting_key_id: h.attester.clone(), + score: h.score, + confidence: if licensure_capped { + h.confidence.min(0.5) + } else { + h.confidence + }, + // CC 4.4.1 weighting is not substrate-side yet (see doc). + weight: 1.0, + self_track_record_applied: false, + low_density_applied: false, + licensure_capped, + }) + .collect(); + + let confidence = if contributions.is_empty() { + 0.0 + } else { + (contributions.iter().map(|c| c.confidence).sum::() / contributions.len() as f64) + .clamp(0.0, 1.0) + }; + + let decision = if self_emission_disagreement + || contributions.is_empty() + || verdict.band == ConfidenceBand::InsufficientWitnesses + { + Decision::Undetermined + } else if aggregate >= self.cfg.threshold { + Decision::Affirm + } else { + Decision::Deny }; - Ok(composer.compose(&rows)) + + Ok(Composition { + verdicts: vec![Verdict { + dimension: dimension.to_owned(), + attested_key_id: attested_key_id.to_owned(), + polarity, + value: aggregate, + confidence, + single_source_licensure: licensure_capped, + // persist's fold has no CC 4.4.3.9 tie-break (see doc). + lexical_tie_break_applied: false, + contributions, + decision, + }], + refusals, + }) } /// Compose a corpus of attestations into verdicts. Pure — no I/O, so the diff --git a/src/graph_config.rs b/src/graph_config.rs index 82909621..24b06efd 100644 --- a/src/graph_config.rs +++ b/src/graph_config.rs @@ -14,10 +14,10 @@ //! //! Writes reuse the EXACT signing path [`crate::peer::emit_replication_consent`] //! uses: `ceg_produce_canonicalize` → `SHA-256` → `engine.sign_hybrid` → -//! `put_attestation`. Reads mirror -//! [`crate::peer::replication_peers_from_consent`]: -//! `list_attestations_by(node) → filter SCORES && envelope["dimension"] == -//! CONFIG_DIMENSION`. +//! `put_attestation`. Reads are the persist v17.4.0 `list_scores` seek +//! (CIRISServer#267, FSD-005 Appendix C): ONE cursor-paged +//! `{attester: node, subject: node, dimension_exact: config:v1, lifecycle: +//! Live}` query — the substrate excludes retracted rows server-side. //! //! ## Versioning (latest-wins) //! @@ -26,17 +26,25 @@ //! mutates a prior row — it appends a NEW row with `version = prev + 1` and //! `previous_version = `. A read folds all rows for a key and //! returns the **highest `version`** (latest-wins, ties broken by `asserted_at`). +//! (That per-key version fold is CONFIG semantics — chained by +//! `envelope["version"]`, not by the CEG lifecycle — so it stays client-side +//! by design; the substrate fold retires only the retraction re-scan.) //! -//! ## Revocation (stubbed — flagged) +//! ## Revocation (CEG §6.1, substrate-side since #267) //! -//! Like [`crate::peer::replication_peers_from_consent`], **presence == active**: -//! a `withdraws`/`recants` against a config row's `attestation_id` is honored -//! here (a recanted key reads as absent), via [`config_key_revoked`], which is -//! the same `withdraws`/`recants`-by-the-node + `revocations_for` shape -//! [`crate::auth::ownership::is_steward_bound`]'s `delegation_revoked` uses. There -//! is no substrate `supersede`-aware reader yet (the finer RC29 §5.6.8.15 -//! supersede flow is TODO upstream); the version-fold already gives last-write- -//! wins for the common path. +//! A `withdraws`/`recants` the node authors against a config row's +//! `attestation_id` still makes the key read as absent — but the fold moved +//! into persist: `lifecycle: Live` excludes any row a same-attester structural +//! composer names via the CEG §6.1 canonical envelope member +//! `references_attestation_id`. This RETIRES the pre-#267 `config_key_revoked` +//! N+1 (a full `list_attestations_by(node)` re-scan per row, plus a +//! `revocations_for` probe) and, with it, that helper's ad-hoc targeting +//! conventions (`attested_key_id`/`subject_key_ids` carrying the row id, and +//! attestation-ids probed through the key-revocation table): nothing in +//! production ever emitted those shapes — a retraction now speaks CEG §6.1 or +//! it does not retract. The RC29 §5.6.8.15 partial-narrowing supersede remains +//! TODO upstream; the version-fold already gives last-write-wins for the +//! common path. //! //! ## Scope (declared now; finer enforcement is Phase 2) //! @@ -237,74 +245,75 @@ struct StoredRow { entry: ConfigEntry, } -/// Read every LIVE (unrevoked) `config:v1` row this node authored, parsed into -/// [`StoredRow`]s. Mirrors [`crate::peer::replication_peers_from_consent`]'s read -/// (`list_attestations_by(node) → filter SCORES && dimension`). +/// Page size for the [`live_config_rows`] `list_scores` seek. Config stores +/// are small (tens of keys × a few versions); one page is the common case and +/// the cursor loop stays correct for any size. +const CONFIG_SCORES_PAGE: i64 = 512; + +/// Read every LIVE (unretracted) `config:v1` row this node authored, parsed +/// into [`StoredRow`]s — the persist v17.4.0 `list_scores` read +/// (CIRISServer#267, FSD-005 Appendix C, CIRISPersist#455/#456). +/// +/// ## Why one seek and not a scan + re-scan (#267) +/// +/// Pre-#267 this was `list_attestations_by(node)` (EVERY row the node ever +/// attested, every type, every dimension) filtered client-side to +/// `SCORES && dimension == config:v1` — and then, PER surviving row, a second +/// full `list_attestations_by(node)` walk + a `revocations_for` probe to +/// decide revocation (`config_key_revoked`): the O(N²) N+1 the 2026-07-14 +/// demand survey flagged. `list_scores` is the ordered V106 subject+dimension +/// seek with `lifecycle: Live` — withdrawn/recanted/superseded rows are +/// excluded server-side by the CEG §6.1 fold (composers match via the +/// canonical `references_attestation_id` envelope member), so the whole +/// revocation re-scan is gone. +/// +/// The ONE [`AttestationFilter`](ciris_persist::read::AttestationFilter) is +/// built once and reused across pages (the pin-once contract, Appendix C.4 — +/// `#[non_exhaustive]`, so it is mutated from `Default` rather than +/// struct-literal-constructed). Both attester AND subject pin to the node: +/// config rows are self-directed (`subject_key_ids == [node]`, see +/// [`set_config`]), and the attester pin keeps a peer-replicated row about us +/// from ever reading as OUR config. +/// +/// The read is unauthenticated (`caller = ""`): config rows are +/// `cohort_scope: federation` by construction ([`config_envelope`]), which the +/// §4.3 gate admits to any caller — so the empty caller skips the per-page +/// admission resolution without narrowing the result. async fn live_config_rows(engine: &Arc, node_key_id: &str) -> Result> { + use ciris_persist::read::{AttestationFilter, LifecycleView}; + let directory = engine.federation_directory(); - let rows = directory - .list_attestations_by(node_key_id) - .await - .map_err(|e| anyhow::anyhow!("list attestations by {node_key_id}: {e}"))?; - let mut out = Vec::new(); - for a in rows { - if a.attestation_type != attestation_type::SCORES { - continue; - } - if a.attestation_envelope - .get("dimension") - .and_then(|d| d.as_str()) - != Some(CONFIG_DIMENSION) - { - continue; - } - // Revocation: a recanted/withdrawn config row reads as absent. - if config_key_revoked(engine, node_key_id, &a.attestation_id).await { - continue; - } - if let Some(entry) = entry_from_envelope(&a.attestation_envelope) { - out.push(StoredRow { - attestation_id: a.attestation_id, - asserted_at: a.asserted_at, - entry, - }); - } - } - Ok(out) -} + let mut filter = AttestationFilter::default(); + filter.attesting_key_id = Some(node_key_id.to_owned()); + filter.subject_key_id = Some(node_key_id.to_owned()); + filter.attestation_type = Some(attestation_type::SCORES.to_owned()); + filter.dimension_exact = Some(CONFIG_DIMENSION.to_owned()); + filter.lifecycle = LifecycleView::Live; -/// True iff a config row (`attestation_id`) authored by `node_key_id` has been -/// revoked — by a `withdraws`/`recants` the node authored against it, or by a -/// `revocations_for` row. Same shape as -/// `crate::auth::ownership`'s `delegation_revoked`, scoped to the config row id. -/// -/// NOTE (flagged): the substrate has no `supersede`-aware federation-tier reader -/// yet, so partial-narrowing supersede (RC29 §5.6.8.15) is NOT honored — only -/// explicit withdraws/recants/revocation. For the common last-write-wins path the -/// version-fold already supersedes prior values. -async fn config_key_revoked(engine: &Arc, node_key_id: &str, attestation_id: &str) -> bool { - let directory = engine.federation_directory(); - if let Ok(by_node) = directory.list_attestations_by(node_key_id).await { - for a in by_node { - let is_retraction = a.attestation_type == attestation_type::WITHDRAWS - || a.attestation_type == attestation_type::RECANTS; - // A retraction can target the row either via attested_key_id or via - // its subject_key_ids carrying the row id. - if is_retraction - && (a.attested_key_id == attestation_id - || a.subject_key_ids.iter().any(|s| s == attestation_id)) - { - return true; + let mut out = Vec::new(); + let mut cursor = None; + loop { + let page = directory + .list_scores("", filter.clone(), cursor.take(), CONFIG_SCORES_PAGE) + .await + .map_err(|e| anyhow::anyhow!("list_scores(config:v1, {node_key_id}): {e}"))?; + for a in page.items { + // Defensive: a malformed envelope is skipped, not fatal. + if let Some(entry) = entry_from_envelope(&a.attestation_envelope) { + out.push(StoredRow { + attestation_id: a.attestation_id, + asserted_at: a.asserted_at, + entry, + }); } } - } - if let Ok(revs) = directory.revocations_for(attestation_id).await { - if !revs.is_empty() { - return true; + match page.next_cursor { + Some(next) => cursor = Some(next), + None => break, } } - false + Ok(out) } /// Fold a key's rows to the latest-wins [`ConfigEntry`] + its row id: highest @@ -320,7 +329,7 @@ fn latest_for_key<'a>(rows: &'a [StoredRow], key: &str) -> Option<&'a StoredRow> /// Read the latest [`ConfigEntry`] for `key` (highest version, latest-wins), or /// `None` if the key has no live row. A recanted/withdrawn key reads as absent -/// (see [`config_key_revoked`]). +/// (excluded substrate-side by [`live_config_rows`]' `lifecycle: Live` seek). pub async fn get_config( engine: &Arc, node_key_id: &str, diff --git a/tests/graph_config.rs b/tests/graph_config.rs index eaf8ba8b..3c513c0d 100644 --- a/tests/graph_config.rs +++ b/tests/graph_config.rs @@ -378,15 +378,17 @@ async fn written_row_id(engine: &Arc, key: &str) -> String { } /// Emit a node-authored RECANTS attestation targeting `target_attestation_id` -/// (subject_key_ids carries the row id) — the revocation shape `config_key_revoked` -/// recognizes. +/// via the CEG §6.1 canonical envelope member `references_attestation_id` — +/// the shape persist's `lifecycle: Live` server-side fold honors +/// (CIRISServer#267; pre-#267 the ad-hoc `config_key_revoked` helper matched +/// a `"recants"` envelope key / `subject_key_ids` instead). async fn recant_row(engine: &Arc, target_attestation_id: &str) { let now = chrono::Utc::now(); let nk = node_key_id(engine).await; let envelope = serde_json::json!({ "dimension": "config:v1", "attesting_key_id": nk, - "recants": target_attestation_id, + "references_attestation_id": target_attestation_id, "asserted_at": now.to_rfc3339(), }); let canonical = ceg_produce_canonicalize(&envelope).expect("canonicalize recant"); diff --git a/tests/release_gates/support.rs b/tests/release_gates/support.rs index eb9f81c6..eaa94896 100644 --- a/tests/release_gates/support.rs +++ b/tests/release_gates/support.rs @@ -35,8 +35,11 @@ use std::path::PathBuf; // KeyRecord.consent_role, the accord-conferred `canonical` identity_type role // (CIRISPersist#372), single-owner `owner_of` (#162), verify unchanged v8.7.0. pub const TARGET_VERIFY: &str = "v10.2.0"; -pub const TARGET_PERSIST: &str = "v17.3.0"; -pub const TARGET_EDGE: &str = "v13.0.9"; +// 0.5.117 (CIRISServer#267): persist v17.3.0→v17.5.0 — adopt resolve_scores/ +// list_scores (FSD-005 Appendix C, CIRISPersist#455/#456); edge v13.0.9→v13.1.0 +// re-pins persist v17.5.0 in lockstep (the shared-substrate seam must unify). +pub const TARGET_PERSIST: &str = "v17.5.0"; +pub const TARGET_EDGE: &str = "v13.1.0"; /// Stage 6/7: the persist MAJOR family that bakes the canonical genesis seed. /// (Name is historical — the seed-bake family moved v10 → v12 → **v13**: the v12.0 /// genesis-mesh rooting anchor persisted, v13.0.0 adds the accord-conferred