Authz Capability Graph (Phase B0) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Scaffold the icn-authz crate with canonical model types, deterministic BLAKE3 hashing (length-prefixed, explicit tag bytes), CapabilitySource trait, GraphBuilder, and CapabilityGraph with query — the foundation for unifying ICN's four authorization systems.
Architecture: New app-layer crate icn-authz in icn/crates/. Domain-agnostic model in icn-authz::model (promotable to kernel later). Graph builder/query in icn-authz::graph. No persistence, no domain adapters (those are Phase B1). All hashing uses BLAKE3 with length-prefixed variable-length fields and explicit frozen tag byte constants.
Tech Stack: Rust (1.88.0), BLAKE3, serde, thiserror, icn-kernel-api (BlockHeight type alias = u64)
Design doc: docs/plans/2026-02-23-authz-capability-graph-design.md
Landmines & Gotchas
BlockHeightispub type BlockHeight = u64;(a type alias, NOT a newtype). The design doc saysheight.0 as u64— this is WRONG. Useheightdirectly since it IS u64. Defined inicn-kernel-api/src/invariants.rs:10.blake3is NOT a workspace dependency. Declare it directly:blake3 = "1.8"(same asicn-governance).serde_jsonis NOT a workspace dependency for dev-deps. Useserde_json = "1.0"directly in[dev-dependencies].Workspace members list is NOT alphabetical. Insert
"crates/icn-authz"after"crates/icn-naming"(line 36) to keep app-layer crates grouped near the end.Workspace lints say
unwrap_used = "warn". Tests that call.unwrap()will emit warnings. Use#[allow(clippy::unwrap_used)]on test modules, or useexpect("reason")(also warned but with context).No
icn-identitydependency needed for B0.SubjectIdis just a validated String newtype — no DID resolution or crypto needed yet.Ordderive on enums uses variant declaration order as discriminant. Our "frozen variant order" comments + explicit tag bytes in hashing mean theOrdderive is safe for sorting, but hashing MUST use the explicit constants, never rely on derived discriminants.
Task 1: Scaffold crate and workspace registration
Files:
- Create:
icn/crates/icn-authz/Cargo.toml - Create:
icn/crates/icn-authz/src/lib.rs - Create:
icn/crates/icn-authz/src/error.rs - Modify:
icn/Cargo.toml:3-41(workspace members) - Modify:
icn/Cargo.toml:117-147(workspace dependencies)
Step 1: Create directory structure
mkdir -p icn/crates/icn-authz/src/model
mkdir -p icn/crates/icn-authz/src/graph
Step 2: Write icn/crates/icn-authz/Cargo.toml
[package]
name = "icn-authz"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
blake3 = "1.8"
serde = { workspace = true, features = ["derive"] }
thiserror.workspace = true
icn-kernel-api = { path = "../icn-kernel-api" }
[dev-dependencies]
serde_json = "1.0"
[lints]
workspace = true
Step 3: Write icn/crates/icn-authz/src/error.rs
#[derive(Debug, thiserror::Error)]
pub enum AuthzError {
#[error("invalid subject ID (must start with 'did:'): {0}")]
InvalidSubjectId(String),
#[error("invalid action format (expected 'domain:verb[:subverb]'): {0}")]
InvalidAction(String),
}
Step 4: Write icn/crates/icn-authz/src/lib.rs
//! # ICN Authorization
//!
//! Canonical capability graph model for unifying ICN's authorization systems.
//!
//! ## Architecture
//!
//! - `model` — Domain-agnostic types: subjects, actions, resources, constraints, edges.
//! Designed to be liftable to `icn-kernel-api` in a future phase.
//! - `graph` — Builder and query: `CapabilitySource` trait, `GraphBuilder`, `CapabilityGraph`.
//! - `error` — Error types for validation failures.
//!
//! ## Meaning Firewall
//!
//! This is an **app-layer** crate. It interprets domain semantics (what capabilities mean).
//! The kernel sees only hashes produced by this crate, never the capability model itself.
pub mod error;
pub mod graph;
pub mod model;
pub use error::AuthzError;
Step 5: Write placeholder icn/crates/icn-authz/src/model/mod.rs
pub mod hash;
pub mod ids;
pub mod edge;
Step 6: Write placeholder icn/crates/icn-authz/src/graph/mod.rs
pub mod builder;
Step 7: Write placeholder icn/crates/icn-authz/src/model/ids.rs
// Canonical identity and resource types — Task 2
Step 8: Write placeholder icn/crates/icn-authz/src/model/edge.rs
// CapabilityEdge and CapabilityGraph — Task 4
Step 9: Write placeholder icn/crates/icn-authz/src/model/hash.rs
// Deterministic BLAKE3 hashing — Task 3
Step 10: Write placeholder icn/crates/icn-authz/src/graph/builder.rs
// CapabilitySource trait and GraphBuilder — Task 5
Step 11: Add to workspace members in icn/Cargo.toml
In the members array, after "crates/icn-naming", (line 36), add:
"crates/icn-authz",
Step 12: Add to workspace dependencies in icn/Cargo.toml
In the [workspace.dependencies] section, after icn-naming = { path = "crates/icn-naming" } (line 147), add:
icn-authz = { path = "crates/icn-authz" }
Step 13: Verify the crate compiles
Run: cd icn/icn && cargo check -p icn-authz
Expected: Compiles with 0 errors (warnings about empty files are OK)
Step 14: Run clippy
Run: cd icn/icn && cargo clippy -p icn-authz --all-targets -- -D warnings
Expected: PASS
Step 15: Commit
git add icn/crates/icn-authz/ icn/Cargo.toml
git commit -m "feat(authz): scaffold icn-authz crate with workspace registration"
Task 2: Canonical identity types (SubjectId, Action, ResourceKind, ResourceId, Constraint, EdgeSource)
Files:
- Create:
icn/crates/icn-authz/src/model/ids.rs(overwrite placeholder)
Step 1: Write the failing tests
Add the following test module at the bottom of ids.rs:
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
// --- SubjectId ---
#[test]
fn subject_id_valid_did() {
let s = SubjectId::new("did:icn:abc123").unwrap();
assert_eq!(s.as_str(), "did:icn:abc123");
}
#[test]
fn subject_id_rejects_non_did() {
assert!(SubjectId::new("not-a-did").is_err());
}
#[test]
fn subject_id_rejects_empty() {
assert!(SubjectId::new("").is_err());
}
// --- Action ---
#[test]
fn action_normalizes_uppercase() {
let a = Action::new("GOVERNANCE:PROPOSE").unwrap();
assert_eq!(a.as_str(), "governance:propose");
}
#[test]
fn action_trims_whitespace() {
let a = Action::new(" ledger:transfer ").unwrap();
assert_eq!(a.as_str(), "ledger:transfer");
}
#[test]
fn action_three_segments_ok() {
let a = Action::new("ledger:transfer:credit").unwrap();
assert_eq!(a.as_str(), "ledger:transfer:credit");
assert_eq!(a.domain(), "ledger");
assert_eq!(a.verb(), "transfer");
}
#[test]
fn action_rejects_empty() {
assert!(Action::new("").is_err());
}
#[test]
fn action_rejects_no_colon() {
assert!(Action::new("single").is_err());
}
#[test]
fn action_rejects_empty_segment() {
assert!(Action::new("a::b").is_err());
}
#[test]
fn action_rejects_non_ascii() {
assert!(Action::new("gov:propos\u{00e9}").is_err());
}
#[test]
fn action_allows_hyphens() {
let a = Action::new("compute:long-running").unwrap();
assert_eq!(a.as_str(), "compute:long-running");
}
// --- ResourceKind ordering ---
#[test]
fn resource_kind_ordering_is_declaration_order() {
assert!(ResourceKind::Entity < ResourceKind::Scope);
assert!(ResourceKind::Scope < ResourceKind::Asset);
assert!(ResourceKind::Asset < ResourceKind::Contract);
assert!(ResourceKind::Contract < ResourceKind::System);
}
// --- Constraint ordering ---
#[test]
fn constraint_variants_ordered_by_discriminant() {
let rate = Constraint::RateLimit(100);
let credit = Constraint::CreditMultiplier(10000);
let topics = Constraint::MaxTopics(50);
let timelock = Constraint::TimeLock(1000);
let quorum = Constraint::RequiresQuorum(3);
let tag = Constraint::Tag("x".into());
assert!(rate < credit);
assert!(credit < topics);
assert!(topics < timelock);
assert!(timelock < quorum);
assert!(quorum < tag);
}
#[test]
fn constraint_same_variant_orders_by_value() {
assert!(Constraint::RateLimit(10) < Constraint::RateLimit(20));
assert!(Constraint::Tag("a".into()) < Constraint::Tag("b".into()));
}
// --- EdgeSource ordering ---
#[test]
fn edge_source_ordering_is_declaration_order() {
assert!(EdgeSource::CclContract("a".into()) < EdgeSource::TrustScore("a".into()));
assert!(EdgeSource::TrustScore("a".into()) < EdgeSource::GovernanceVote("a".into()));
assert!(EdgeSource::GovernanceVote("a".into()) < EdgeSource::Static("a".into()));
}
// --- Serde round-trip ---
#[test]
fn subject_id_serde_roundtrip() {
let s = SubjectId::new("did:icn:test").unwrap();
let json = serde_json::to_string(&s).unwrap();
let back: SubjectId = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn action_serde_roundtrip() {
let a = Action::new("governance:propose").unwrap();
let json = serde_json::to_string(&a).unwrap();
let back: Action = serde_json::from_str(&json).unwrap();
assert_eq!(a, back);
}
}
Step 2: Run tests to verify they fail
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: FAIL — SubjectId, Action, etc. not defined yet
Step 3: Write the implementation
Write the full ids.rs with types + the test module from Step 1:
use serde::{Deserialize, Serialize};
use crate::AuthzError;
/// An actor in the capability graph. Always a DID.
///
/// Validated on construction: must start with "did:" prefix.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct SubjectId(String);
impl SubjectId {
pub fn new(did: impl Into<String>) -> Result<Self, AuthzError> {
let s = did.into();
if !s.starts_with("did:") {
return Err(AuthzError::InvalidSubjectId(s));
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A capability action. Lowercased, trimmed, colon-separated segments.
///
/// Format: `domain:verb[:subverb]`. Only ASCII alphanumeric, colon, and hyphen.
/// Soft normalization: lowercases and trims on construction.
///
/// Examples: "governance:propose", "ledger:transfer:credit", "compute:submit"
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Action(String);
impl Action {
pub fn new(raw: impl Into<String>) -> Result<Self, AuthzError> {
let s = raw.into().trim().to_ascii_lowercase();
let segments: Vec<&str> = s.split(':').collect();
if segments.len() < 2 || segments.iter().any(|seg| seg.is_empty()) {
return Err(AuthzError::InvalidAction(s));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == ':' || c == '-')
{
return Err(AuthzError::InvalidAction(s));
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn domain(&self) -> &str {
self.0.split(':').next().expect("validated in new()")
}
pub fn verb(&self) -> &str {
self.0.splitn(3, ':').nth(1).expect("validated in new()")
}
}
/// Variant order is FROZEN. Do not reorder. Append only.
/// Tag bytes: Entity=0x01, Scope=0x02, Asset=0x03, Contract=0x04, System=0x05
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ResourceKind {
/// tag 0x01 — cooperatives, communities, federations, individuals
Entity,
/// tag 0x02 — cells, namespaces, governance scopes
Scope,
/// tag 0x03 — ledger accounts, credit lines, treasury
Asset,
/// tag 0x04 — CCL contracts, amendments
Contract,
/// tag 0x05 — protocol parameters, invariant config
System,
}
/// A typed resource identifier.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ResourceId {
pub kind: ResourceKind,
pub id: String,
}
impl ResourceId {
pub fn new(kind: ResourceKind, id: impl Into<String>) -> Self {
Self {
kind,
id: id.into(),
}
}
}
/// Variant order is FROZEN. Do not reorder. Append only.
/// Tag bytes: RateLimit=0x01, CreditMultiplier=0x02, MaxTopics=0x03,
/// TimeLock=0x04, RequiresQuorum=0x05, Tag=0x06
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Constraint {
/// tag 0x01 — messages per second
RateLimit(u32),
/// tag 0x02 — basis points (10000 = 1.0x)
CreditMultiplier(u32),
/// tag 0x03 — topic subscription limit
MaxTopics(u32),
/// tag 0x04 — block height before which action is locked
TimeLock(u64),
/// tag 0x05 — minimum votes (absolute count)
RequiresQuorum(u32),
/// tag 0x06 — escape hatch for domain-specific labels
Tag(String),
}
/// Variant order is FROZEN. Do not reorder. Append only.
/// Tag bytes: CclContract=0x01, TrustScore=0x02, GovernanceVote=0x03, Static=0x04
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EdgeSource {
/// tag 0x01 — contract hash or ID
CclContract(String),
/// tag 0x02 — trust graph identifier
TrustScore(String),
/// tag 0x03 — proposal ID
GovernanceVote(String),
/// tag 0x04 — hardcoded/bootstrap grants
Static(String),
}
// --- tests at bottom (see Step 1) ---
Step 4: Run tests to verify they pass
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: 17 tests PASS
Step 5: Run clippy
Run: cd icn/icn && cargo clippy -p icn-authz --all-targets -- -D warnings
Expected: PASS
Step 6: Commit
git add icn/crates/icn-authz/src/model/ids.rs
git commit -m "feat(authz): canonical identity types with validation and ordering"
Task 3: Deterministic hashing (tag constants, hash functions, length-prefixing)
Files:
- Create:
icn/crates/icn-authz/src/model/hash.rs(overwrite placeholder)
Step 1: Write the failing tests
Add the following test module at the bottom of hash.rs:
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::model::ids::*;
fn test_subject() -> SubjectId {
SubjectId::new("did:icn:alice").unwrap()
}
fn test_action() -> Action {
Action::new("governance:propose").unwrap()
}
fn test_resource() -> ResourceId {
ResourceId::new(ResourceKind::Entity, "coop-1")
}
// --- Determinism ---
#[test]
fn hash_subject_deterministic() {
let a = hash_subject(&test_subject());
let b = hash_subject(&test_subject());
assert_eq!(a, b);
}
#[test]
fn hash_action_deterministic() {
let a = hash_action(&test_action());
let b = hash_action(&test_action());
assert_eq!(a, b);
}
#[test]
fn hash_resource_deterministic() {
let a = hash_resource(&test_resource());
let b = hash_resource(&test_resource());
assert_eq!(a, b);
}
// --- Different inputs → different hashes ---
#[test]
fn hash_subject_differs_for_different_dids() {
let a = hash_subject(&SubjectId::new("did:icn:alice").unwrap());
let b = hash_subject(&SubjectId::new("did:icn:bob").unwrap());
assert_ne!(a, b);
}
#[test]
fn hash_action_differs_for_different_actions() {
let a = hash_action(&Action::new("governance:propose").unwrap());
let b = hash_action(&Action::new("governance:vote").unwrap());
assert_ne!(a, b);
}
#[test]
fn hash_resource_differs_for_different_kinds() {
let a = hash_resource(&ResourceId::new(ResourceKind::Entity, "x"));
let b = hash_resource(&ResourceId::new(ResourceKind::Asset, "x"));
assert_ne!(a, b);
}
#[test]
fn hash_resource_differs_for_different_ids() {
let a = hash_resource(&ResourceId::new(ResourceKind::Entity, "x"));
let b = hash_resource(&ResourceId::new(ResourceKind::Entity, "y"));
assert_ne!(a, b);
}
// --- Constraint hashing ---
#[test]
fn hash_constraint_deterministic() {
let c = Constraint::RateLimit(100);
assert_eq!(hash_constraint(&c), hash_constraint(&c));
}
#[test]
fn hash_constraint_differs_across_variants() {
let a = hash_constraint(&Constraint::RateLimit(100));
let b = hash_constraint(&Constraint::MaxTopics(100));
assert_ne!(a, b);
}
#[test]
fn hash_constraint_tag_differs_by_string() {
let a = hash_constraint(&Constraint::Tag("foo".into()));
let b = hash_constraint(&Constraint::Tag("bar".into()));
assert_ne!(a, b);
}
// --- EdgeSource hashing ---
#[test]
fn hash_edge_source_deterministic() {
let s = EdgeSource::Static("bootstrap".into());
assert_eq!(hash_edge_source(&s), hash_edge_source(&s));
}
#[test]
fn hash_edge_source_differs_across_variants() {
let a = hash_edge_source(&EdgeSource::CclContract("x".into()));
let b = hash_edge_source(&EdgeSource::Static("x".into()));
assert_ne!(a, b);
}
// --- Option<BlockHeight> hashing ---
#[test]
fn hash_option_height_none_vs_some_differ() {
let mut h1 = blake3::Hasher::new();
hash_option_height(&mut h1, None);
let mut h2 = blake3::Hasher::new();
hash_option_height(&mut h2, Some(0));
assert_ne!(h1.finalize().as_bytes(), h2.finalize().as_bytes());
}
#[test]
fn hash_option_height_different_values_differ() {
let mut h1 = blake3::Hasher::new();
hash_option_height(&mut h1, Some(100));
let mut h2 = blake3::Hasher::new();
hash_option_height(&mut h2, Some(200));
assert_ne!(h1.finalize().as_bytes(), h2.finalize().as_bytes());
}
// --- Length-prefix prevents ambiguity ---
#[test]
fn length_prefix_prevents_concatenation_ambiguity() {
// "ab" ++ "cd" must differ from "a" ++ "bcd"
let mut h1 = blake3::Hasher::new();
hash_bytes(&mut h1, b"ab");
hash_bytes(&mut h1, b"cd");
let mut h2 = blake3::Hasher::new();
hash_bytes(&mut h2, b"a");
hash_bytes(&mut h2, b"bcd");
assert_ne!(h1.finalize().as_bytes(), h2.finalize().as_bytes());
}
}
Step 2: Run tests to verify they fail
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: FAIL — hash functions not defined
Step 3: Write the implementation
Write the full hash.rs with constants + functions + test module:
use icn_kernel_api::BlockHeight;
use crate::model::ids::*;
// ─── Top-level type tags ───────────────────────────────────────────
// These tag bytes are FROZEN. Do not change values. Append only.
pub(crate) const TAG_SUBJECT: u8 = 0x10;
pub(crate) const TAG_ACTION: u8 = 0x11;
pub(crate) const TAG_RESOURCE: u8 = 0x12;
pub(crate) const TAG_CONSTRAINT: u8 = 0x13;
pub(crate) const TAG_EDGE_SOURCE: u8 = 0x14;
pub(crate) const TAG_EDGE: u8 = 0x15;
pub(crate) const TAG_EDGE_SET: u8 = 0x16;
// ─── ResourceKind tag bytes ────────────────────────────────────────
// FROZEN. Do not reorder. Append only. Must match ResourceKind variant order.
pub(crate) const RESOURCE_ENTITY: u8 = 0x01;
pub(crate) const RESOURCE_SCOPE: u8 = 0x02;
pub(crate) const RESOURCE_ASSET: u8 = 0x03;
pub(crate) const RESOURCE_CONTRACT: u8 = 0x04;
pub(crate) const RESOURCE_SYSTEM: u8 = 0x05;
// ─── Constraint tag bytes ──────────────────────────────────────────
// FROZEN. Do not reorder. Append only. Must match Constraint variant order.
pub(crate) const CONSTRAINT_RATE_LIMIT: u8 = 0x01;
pub(crate) const CONSTRAINT_CREDIT_MULTIPLIER: u8 = 0x02;
pub(crate) const CONSTRAINT_MAX_TOPICS: u8 = 0x03;
pub(crate) const CONSTRAINT_TIME_LOCK: u8 = 0x04;
pub(crate) const CONSTRAINT_REQUIRES_QUORUM: u8 = 0x05;
pub(crate) const CONSTRAINT_TAG: u8 = 0x06;
// ─── EdgeSource tag bytes ──────────────────────────────────────────
// FROZEN. Do not reorder. Append only. Must match EdgeSource variant order.
pub(crate) const SOURCE_CCL_CONTRACT: u8 = 0x01;
pub(crate) const SOURCE_TRUST_SCORE: u8 = 0x02;
pub(crate) const SOURCE_GOVERNANCE_VOTE: u8 = 0x03;
pub(crate) const SOURCE_STATIC: u8 = 0x04;
// ─── Helpers ───────────────────────────────────────────────────────
/// Length-prefix then append bytes. Prevents concatenation ambiguity.
pub(crate) fn hash_bytes(hasher: &mut blake3::Hasher, data: &[u8]) {
hasher.update(&(data.len() as u32).to_le_bytes());
hasher.update(data);
}
/// Hash an Option<BlockHeight>. None = 0x00. Some(h) = 0x01 ++ h as u64 LE.
/// BlockHeight is a type alias for u64, so no .0 accessor needed.
pub(crate) fn hash_option_height(hasher: &mut blake3::Hasher, h: Option<BlockHeight>) {
match h {
None => hasher.update(&[0x00]),
Some(height) => {
hasher.update(&[0x01]);
hasher.update(&height.to_le_bytes());
}
}
}
// ─── Per-type hash functions ───────────────────────────────────────
pub fn hash_subject(s: &SubjectId) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[TAG_SUBJECT]);
hash_bytes(&mut hasher, s.as_str().as_bytes());
*hasher.finalize().as_bytes()
}
pub fn hash_action(a: &Action) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[TAG_ACTION]);
hash_bytes(&mut hasher, a.as_str().as_bytes());
*hasher.finalize().as_bytes()
}
fn resource_kind_tag(kind: &ResourceKind) -> u8 {
match kind {
ResourceKind::Entity => RESOURCE_ENTITY,
ResourceKind::Scope => RESOURCE_SCOPE,
ResourceKind::Asset => RESOURCE_ASSET,
ResourceKind::Contract => RESOURCE_CONTRACT,
ResourceKind::System => RESOURCE_SYSTEM,
}
}
pub fn hash_resource(r: &ResourceId) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[TAG_RESOURCE]);
hasher.update(&[resource_kind_tag(&r.kind)]);
hash_bytes(&mut hasher, r.id.as_bytes());
*hasher.finalize().as_bytes()
}
fn constraint_tag(c: &Constraint) -> u8 {
match c {
Constraint::RateLimit(_) => CONSTRAINT_RATE_LIMIT,
Constraint::CreditMultiplier(_) => CONSTRAINT_CREDIT_MULTIPLIER,
Constraint::MaxTopics(_) => CONSTRAINT_MAX_TOPICS,
Constraint::TimeLock(_) => CONSTRAINT_TIME_LOCK,
Constraint::RequiresQuorum(_) => CONSTRAINT_REQUIRES_QUORUM,
Constraint::Tag(_) => CONSTRAINT_TAG,
}
}
pub fn hash_constraint(c: &Constraint) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[TAG_CONSTRAINT]);
hasher.update(&[constraint_tag(c)]);
match c {
Constraint::RateLimit(v) => hasher.update(&v.to_le_bytes()),
Constraint::CreditMultiplier(v) => hasher.update(&v.to_le_bytes()),
Constraint::MaxTopics(v) => hasher.update(&v.to_le_bytes()),
Constraint::TimeLock(v) => hasher.update(&v.to_le_bytes()),
Constraint::RequiresQuorum(v) => hasher.update(&v.to_le_bytes()),
Constraint::Tag(s) => {
hash_bytes(&mut hasher, s.as_bytes());
return *hasher.finalize().as_bytes();
}
};
*hasher.finalize().as_bytes()
}
fn edge_source_tag(es: &EdgeSource) -> u8 {
match es {
EdgeSource::CclContract(_) => SOURCE_CCL_CONTRACT,
EdgeSource::TrustScore(_) => SOURCE_TRUST_SCORE,
EdgeSource::GovernanceVote(_) => SOURCE_GOVERNANCE_VOTE,
EdgeSource::Static(_) => SOURCE_STATIC,
}
}
fn edge_source_inner(es: &EdgeSource) -> &str {
match es {
EdgeSource::CclContract(s)
| EdgeSource::TrustScore(s)
| EdgeSource::GovernanceVote(s)
| EdgeSource::Static(s) => s,
}
}
pub fn hash_edge_source(es: &EdgeSource) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[TAG_EDGE_SOURCE]);
hasher.update(&[edge_source_tag(es)]);
hash_bytes(&mut hasher, edge_source_inner(es).as_bytes());
*hasher.finalize().as_bytes()
}
/// Hash a sorted, deduped slice of edges into a single edge-set hash.
pub fn hash_edge_set(edges: &[crate::model::edge::CapabilityEdge]) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[TAG_EDGE_SET]);
hasher.update(&(edges.len() as u32).to_le_bytes());
for edge in edges {
hasher.update(&crate::model::edge::hash_edge(edge));
}
*hasher.finalize().as_bytes()
}
// --- tests at bottom (see Step 1) ---
Step 4: Run tests to verify they pass
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: All hash tests PASS (edge tests may fail if edge.rs is still placeholder — that's expected)
Step 5: Run clippy
Run: cd icn/icn && cargo clippy -p icn-authz --all-targets -- -D warnings
Expected: PASS (or warn about unused hash_edge_set if edge module is still placeholder — acceptable)
Step 6: Commit
git add icn/crates/icn-authz/src/model/hash.rs
git commit -m "feat(authz): deterministic BLAKE3 hashing with length-prefixed fields and explicit tags"
Task 4: CapabilityEdge, CapabilityGraph, Decision, and edge hashing
Files:
- Create:
icn/crates/icn-authz/src/model/edge.rs(overwrite placeholder)
Step 1: Write the failing tests
Add the following test module at the bottom of edge.rs:
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::model::ids::*;
fn alice() -> SubjectId {
SubjectId::new("did:icn:alice").unwrap()
}
fn bob() -> SubjectId {
SubjectId::new("did:icn:bob").unwrap()
}
fn propose() -> Action {
Action::new("governance:propose").unwrap()
}
fn vote() -> Action {
Action::new("governance:vote").unwrap()
}
fn coop_resource() -> ResourceId {
ResourceId::new(ResourceKind::Entity, "coop-1")
}
fn make_edge(subject: SubjectId, action: Action) -> CapabilityEdge {
CapabilityEdge::new(
subject,
action,
coop_resource(),
vec![Constraint::RateLimit(10)],
EdgeSource::Static("bootstrap".into()),
None,
)
}
// --- Constructor sorts and dedupes constraints ---
#[test]
fn constructor_sorts_constraints() {
let edge = CapabilityEdge::new(
alice(),
propose(),
coop_resource(),
vec![
Constraint::Tag("z".into()),
Constraint::RateLimit(10),
Constraint::MaxTopics(5),
],
EdgeSource::Static("test".into()),
None,
);
let kinds: Vec<&str> = edge
.constraints
.iter()
.map(|c| match c {
Constraint::RateLimit(_) => "RateLimit",
Constraint::MaxTopics(_) => "MaxTopics",
Constraint::Tag(_) => "Tag",
_ => "other",
})
.collect();
assert_eq!(kinds, vec!["RateLimit", "MaxTopics", "Tag"]);
}
#[test]
fn constructor_dedupes_constraints() {
let edge = CapabilityEdge::new(
alice(),
propose(),
coop_resource(),
vec![Constraint::RateLimit(10), Constraint::RateLimit(10)],
EdgeSource::Static("test".into()),
None,
);
assert_eq!(edge.constraints.len(), 1);
}
// --- Edge hashing ---
#[test]
fn hash_edge_deterministic() {
let e = make_edge(alice(), propose());
assert_eq!(hash_edge(&e), hash_edge(&e));
}
#[test]
fn hash_edge_differs_by_subject() {
let a = hash_edge(&make_edge(alice(), propose()));
let b = hash_edge(&make_edge(bob(), propose()));
assert_ne!(a, b);
}
#[test]
fn hash_edge_differs_by_action() {
let a = hash_edge(&make_edge(alice(), propose()));
let b = hash_edge(&make_edge(alice(), vote()));
assert_ne!(a, b);
}
#[test]
fn hash_edge_differs_by_valid_at() {
let mut e1 = make_edge(alice(), propose());
e1.valid_at = None;
let mut e2 = make_edge(alice(), propose());
e2.valid_at = Some(100);
assert_ne!(hash_edge(&e1), hash_edge(&e2));
}
// --- Graph hashing ---
#[test]
fn graph_hash_deterministic() {
let edges = vec![make_edge(alice(), propose()), make_edge(bob(), vote())];
let g = CapabilityGraph::from_edges(edges.clone());
let g2 = CapabilityGraph::from_edges(edges);
assert_eq!(g.hash(), g2.hash());
}
#[test]
fn graph_hash_order_independent() {
let e1 = make_edge(alice(), propose());
let e2 = make_edge(bob(), vote());
let g1 = CapabilityGraph::from_edges(vec![e1.clone(), e2.clone()]);
let g2 = CapabilityGraph::from_edges(vec![e2, e1]);
assert_eq!(g1.hash(), g2.hash());
}
#[test]
fn graph_hash_changes_when_edge_added() {
let e1 = make_edge(alice(), propose());
let e2 = make_edge(bob(), vote());
let g1 = CapabilityGraph::from_edges(vec![e1.clone()]);
let g2 = CapabilityGraph::from_edges(vec![e1, e2]);
assert_ne!(g1.hash(), g2.hash());
}
#[test]
fn graph_hash_changes_when_constraint_changes() {
let mut e1 = make_edge(alice(), propose());
e1.constraints = vec![Constraint::RateLimit(10)];
let mut e2 = make_edge(alice(), propose());
e2.constraints = vec![Constraint::RateLimit(20)];
// These are different edges so graphs differ
let g1 = CapabilityGraph::from_edges(vec![e1]);
let g2 = CapabilityGraph::from_edges(vec![e2]);
assert_ne!(g1.hash(), g2.hash());
}
// --- Query ---
#[test]
fn query_allowed_when_edge_exists() {
let g = CapabilityGraph::from_edges(vec![make_edge(alice(), propose())]);
let d = g.query(&alice(), &propose(), &coop_resource());
assert!(d.allowed);
assert_eq!(d.matching_edges, vec![0]);
}
#[test]
fn query_denied_when_no_edge() {
let g = CapabilityGraph::from_edges(vec![make_edge(alice(), propose())]);
let d = g.query(&bob(), &propose(), &coop_resource());
assert!(!d.allowed);
assert!(d.matching_edges.is_empty());
}
#[test]
fn query_denied_on_empty_graph() {
let g = CapabilityGraph::from_edges(vec![]);
let d = g.query(&alice(), &propose(), &coop_resource());
assert!(!d.allowed);
}
// --- Serde roundtrip ---
#[test]
fn edge_serde_roundtrip() {
let e = make_edge(alice(), propose());
let json = serde_json::to_string(&e).unwrap();
let back: CapabilityEdge = serde_json::from_str(&json).unwrap();
assert_eq!(e, back);
}
}
Step 2: Run tests to verify they fail
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: FAIL — CapabilityEdge, CapabilityGraph not defined
Step 3: Write the implementation
Write the full edge.rs:
use icn_kernel_api::BlockHeight;
use serde::{Deserialize, Serialize};
use crate::model::hash::*;
use crate::model::ids::*;
/// A capability edge: "subject can do action on resource, with constraints, from source, at height."
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CapabilityEdge {
pub subject: SubjectId,
pub action: Action,
pub resource: ResourceId,
pub constraints: Vec<Constraint>,
pub source: EdgeSource,
pub valid_at: Option<BlockHeight>,
}
impl CapabilityEdge {
/// Create an edge. Constraints are sorted and deduped automatically.
pub fn new(
subject: SubjectId,
action: Action,
resource: ResourceId,
mut constraints: Vec<Constraint>,
source: EdgeSource,
valid_at: Option<BlockHeight>,
) -> Self {
constraints.sort();
constraints.dedup();
Self {
subject,
action,
resource,
constraints,
source,
valid_at,
}
}
}
/// Hash a single edge field-by-field using BLAKE3.
pub fn hash_edge(e: &CapabilityEdge) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[TAG_EDGE]);
hasher.update(&hash_subject(&e.subject));
hasher.update(&hash_action(&e.action));
hasher.update(&hash_resource(&e.resource));
hasher.update(&(e.constraints.len() as u32).to_le_bytes());
for c in &e.constraints {
hasher.update(&hash_constraint(c));
}
hasher.update(&hash_edge_source(&e.source));
hash_option_height(&mut hasher, e.valid_at);
*hasher.finalize().as_bytes()
}
/// B0 decision: allowed + matching edge indices. No string reasons.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Decision {
pub allowed: bool,
pub matching_edges: Vec<usize>,
}
/// A computed capability graph: sorted, deduped edges.
#[derive(Clone, Debug)]
pub struct CapabilityGraph {
edges: Vec<CapabilityEdge>,
}
impl CapabilityGraph {
/// Create a graph from edges. Sorts and dedupes.
pub fn from_edges(mut edges: Vec<CapabilityEdge>) -> Self {
edges.sort();
edges.dedup();
Self { edges }
}
pub fn edges(&self) -> &[CapabilityEdge] {
&self.edges
}
/// Deterministic hash of the entire edge set.
pub fn hash(&self) -> [u8; 32] {
hash_edge_set(&self.edges)
}
/// Query: can subject do action on resource?
pub fn query(
&self,
subject: &SubjectId,
action: &Action,
resource: &ResourceId,
) -> Decision {
let matching: Vec<usize> = self
.edges
.iter()
.enumerate()
.filter(|(_, e)| {
e.subject == *subject && e.action == *action && e.resource == *resource
})
.map(|(i, _)| i)
.collect();
Decision {
allowed: !matching.is_empty(),
matching_edges: matching,
}
}
}
// --- tests at bottom (see Step 1) ---
Step 4: Run tests to verify they pass
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: All tests PASS (hash tests from Task 3 + edge/graph tests from Task 4)
Step 5: Run clippy
Run: cd icn/icn && cargo clippy -p icn-authz --all-targets -- -D warnings
Expected: PASS
Step 6: Commit
git add icn/crates/icn-authz/src/model/edge.rs
git commit -m "feat(authz): CapabilityEdge, CapabilityGraph, Decision with deterministic hashing"
Task 5: GraphBuilder and CapabilitySource trait
Files:
- Create:
icn/crates/icn-authz/src/graph/builder.rs(overwrite placeholder)
Step 1: Write the failing tests
Add the following test module at the bottom of builder.rs:
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::model::edge::CapabilityGraph;
use crate::model::ids::*;
fn alice() -> SubjectId {
SubjectId::new("did:icn:alice").unwrap()
}
fn bob() -> SubjectId {
SubjectId::new("did:icn:bob").unwrap()
}
fn propose() -> Action {
Action::new("governance:propose").unwrap()
}
fn coop_resource() -> ResourceId {
ResourceId::new(ResourceKind::Entity, "coop-1")
}
fn make_edge(subject: SubjectId, action: Action) -> CapabilityEdge {
CapabilityEdge::new(
subject,
action,
coop_resource(),
vec![Constraint::RateLimit(10)],
EdgeSource::Static("bootstrap".into()),
None,
)
}
/// Mock source that returns a fixed set of edges.
struct MockSource {
edges: Vec<CapabilityEdge>,
}
impl CapabilitySource for MockSource {
fn edges_for_subject(&self, subject: &SubjectId) -> Vec<CapabilityEdge> {
self.edges
.iter()
.filter(|e| e.subject == *subject)
.cloned()
.collect()
}
fn all_edges(&self) -> Vec<CapabilityEdge> {
self.edges.clone()
}
}
#[test]
fn empty_builder_produces_empty_graph() {
let g = GraphBuilder::new().build();
assert!(g.edges().is_empty());
let d = g.query(&alice(), &propose(), &coop_resource());
assert!(!d.allowed);
}
#[test]
fn single_source_produces_edges() {
let source = MockSource {
edges: vec![make_edge(alice(), propose())],
};
let g = GraphBuilder::new()
.add_source(Box::new(source))
.build();
assert_eq!(g.edges().len(), 1);
}
#[test]
fn two_sources_with_overlapping_edges_deduped() {
let edge = make_edge(alice(), propose());
let s1 = MockSource {
edges: vec![edge.clone()],
};
let s2 = MockSource {
edges: vec![edge],
};
let g = GraphBuilder::new()
.add_source(Box::new(s1))
.add_source(Box::new(s2))
.build();
assert_eq!(g.edges().len(), 1);
}
#[test]
fn two_sources_with_different_edges_merged() {
let s1 = MockSource {
edges: vec![make_edge(alice(), propose())],
};
let s2 = MockSource {
edges: vec![make_edge(bob(), propose())],
};
let g = GraphBuilder::new()
.add_source(Box::new(s1))
.add_source(Box::new(s2))
.build();
assert_eq!(g.edges().len(), 2);
}
#[test]
fn build_for_subject_returns_subset() {
let s1 = MockSource {
edges: vec![make_edge(alice(), propose()), make_edge(bob(), propose())],
};
let g = GraphBuilder::new()
.add_source(Box::new(s1))
.build_for_subject(&alice());
assert_eq!(g.edges().len(), 1);
assert_eq!(g.edges()[0].subject, alice());
}
#[test]
fn build_same_edges_different_order_same_hash() {
let e1 = make_edge(alice(), propose());
let e2 = make_edge(bob(), propose());
let s1 = MockSource {
edges: vec![e1.clone(), e2.clone()],
};
let s2 = MockSource {
edges: vec![e2, e1],
};
let g1 = GraphBuilder::new().add_source(Box::new(s1)).build();
let g2 = GraphBuilder::new().add_source(Box::new(s2)).build();
assert_eq!(g1.hash(), g2.hash());
}
}
Step 2: Run tests to verify they fail
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: FAIL — CapabilitySource, GraphBuilder not defined
Step 3: Write the implementation
Write the full builder.rs:
use crate::model::edge::{CapabilityEdge, CapabilityGraph};
use crate::model::ids::SubjectId;
/// An adapter that produces capability edges from a domain-specific source.
///
/// Implementations live in `icn-authz::adapters` (Phase B1).
/// The trait is defined here so B0 can write tests with mock sources.
pub trait CapabilitySource: Send + Sync {
/// Return all capability edges this source knows about for the given subject.
fn edges_for_subject(&self, subject: &SubjectId) -> Vec<CapabilityEdge>;
/// Return all capability edges this source can produce.
fn all_edges(&self) -> Vec<CapabilityEdge>;
}
/// Constructs a CapabilityGraph from one or more CapabilitySource adapters.
pub struct GraphBuilder {
sources: Vec<Box<dyn CapabilitySource>>,
}
impl GraphBuilder {
pub fn new() -> Self {
Self {
sources: Vec::new(),
}
}
pub fn add_source(mut self, source: Box<dyn CapabilitySource>) -> Self {
self.sources.push(source);
self
}
/// Build the graph by polling all sources and canonicalizing.
pub fn build(&self) -> CapabilityGraph {
let edges: Vec<CapabilityEdge> = self
.sources
.iter()
.flat_map(|s| s.all_edges())
.collect();
CapabilityGraph::from_edges(edges)
}
/// Build a subject-scoped graph (cheaper than full build).
pub fn build_for_subject(&self, subject: &SubjectId) -> CapabilityGraph {
let edges: Vec<CapabilityEdge> = self
.sources
.iter()
.flat_map(|s| s.edges_for_subject(subject))
.collect();
CapabilityGraph::from_edges(edges)
}
}
impl Default for GraphBuilder {
fn default() -> Self {
Self::new()
}
}
// --- tests at bottom (see Step 1) ---
Step 4: Run tests to verify they pass
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: All tests PASS
Step 5: Run clippy
Run: cd icn/icn && cargo clippy -p icn-authz --all-targets -- -D warnings
Expected: PASS
Step 6: Commit
git add icn/crates/icn-authz/src/graph/builder.rs
git commit -m "feat(authz): CapabilitySource trait and GraphBuilder with dedup and canonicalization"
Task 6: Wire up re-exports in lib.rs and model/mod.rs
Files:
- Modify:
icn/crates/icn-authz/src/lib.rs - Modify:
icn/crates/icn-authz/src/model/mod.rs - Modify:
icn/crates/icn-authz/src/graph/mod.rs
Step 1: Update model/mod.rs with re-exports
pub mod edge;
pub mod hash;
pub mod ids;
pub use edge::{CapabilityEdge, CapabilityGraph, Decision};
pub use ids::{Action, Constraint, EdgeSource, ResourceId, ResourceKind, SubjectId};
Step 2: Update graph/mod.rs with re-exports
pub mod builder;
pub use builder::{CapabilitySource, GraphBuilder};
Step 3: Update lib.rs with re-exports
//! # ICN Authorization
//!
//! Canonical capability graph model for unifying ICN's authorization systems.
//!
//! ## Architecture
//!
//! - `model` — Domain-agnostic types: subjects, actions, resources, constraints, edges.
//! Designed to be liftable to `icn-kernel-api` in a future phase.
//! - `graph` — Builder and query: `CapabilitySource` trait, `GraphBuilder`, `CapabilityGraph`.
//! - `error` — Error types for validation failures.
//!
//! ## Meaning Firewall
//!
//! This is an **app-layer** crate. It interprets domain semantics (what capabilities mean).
//! The kernel sees only hashes produced by this crate, never the capability model itself.
pub mod error;
pub mod graph;
pub mod model;
pub use error::AuthzError;
pub use graph::{CapabilitySource, GraphBuilder};
pub use model::{
Action, CapabilityEdge, CapabilityGraph, Constraint, Decision, EdgeSource, ResourceId,
ResourceKind, SubjectId,
};
Step 4: Run full test suite
Run: cd icn/icn && cargo test -p icn-authz --lib
Expected: All tests PASS
Step 5: Run clippy
Run: cd icn/icn && cargo clippy -p icn-authz --all-targets -- -D warnings
Expected: PASS
Step 6: Run fmt
Run: cd icn/icn && cargo fmt -p icn-authz --check
Expected: PASS
Step 7: Commit
git add icn/crates/icn-authz/src/lib.rs icn/crates/icn-authz/src/model/mod.rs icn/crates/icn-authz/src/graph/mod.rs
git commit -m "feat(authz): wire up re-exports for public API surface"
Task 7: Integration tests (ordering invariants, hash determinism across boundaries)
Files:
- Create:
icn/crates/icn-authz/tests/capability_graph_integration.rs
Step 1: Write integration tests
//! Integration tests for the icn-authz capability graph.
//!
//! These test cross-module properties: ordering invariants hold after
//! serialization round-trips, hash determinism across graph boundaries,
//! and the full build→query→hash pipeline.
#[allow(clippy::unwrap_used)]
mod capability_graph_integration {
use icn_authz::*;
fn alice() -> SubjectId {
SubjectId::new("did:icn:alice").unwrap()
}
fn bob() -> SubjectId {
SubjectId::new("did:icn:bob").unwrap()
}
fn carol() -> SubjectId {
SubjectId::new("did:icn:carol").unwrap()
}
fn propose() -> Action {
Action::new("governance:propose").unwrap()
}
fn vote() -> Action {
Action::new("governance:vote").unwrap()
}
fn transfer() -> Action {
Action::new("ledger:transfer").unwrap()
}
fn coop() -> ResourceId {
ResourceId::new(ResourceKind::Entity, "coop-1")
}
fn make_edge(
subject: SubjectId,
action: Action,
resource: ResourceId,
source: EdgeSource,
) -> CapabilityEdge {
CapabilityEdge::new(
subject,
action,
resource,
vec![Constraint::RateLimit(10)],
source,
None,
)
}
struct FixedSource(Vec<CapabilityEdge>);
impl CapabilitySource for FixedSource {
fn edges_for_subject(&self, subject: &SubjectId) -> Vec<CapabilityEdge> {
self.0
.iter()
.filter(|e| e.subject == *subject)
.cloned()
.collect()
}
fn all_edges(&self) -> Vec<CapabilityEdge> {
self.0.clone()
}
}
// --- Ordering invariant: sort survives serde round-trip ---
#[test]
fn edges_sorted_after_serde_roundtrip() {
let edges = vec![
make_edge(carol(), vote(), coop(), EdgeSource::Static("s".into())),
make_edge(alice(), propose(), coop(), EdgeSource::Static("s".into())),
make_edge(bob(), transfer(), coop(), EdgeSource::Static("s".into())),
];
let g = CapabilityGraph::from_edges(edges);
let json = serde_json::to_string(g.edges()).unwrap();
let deserialized: Vec<CapabilityEdge> = serde_json::from_str(&json).unwrap();
let g2 = CapabilityGraph::from_edges(deserialized);
assert_eq!(g.hash(), g2.hash());
}
// --- Full pipeline: two sources → build → query → hash ---
#[test]
fn full_pipeline_two_sources() {
let s1 = FixedSource(vec![
make_edge(
alice(),
propose(),
coop(),
EdgeSource::CclContract("contract-1".into()),
),
make_edge(
bob(),
vote(),
coop(),
EdgeSource::TrustScore("trust-main".into()),
),
]);
let s2 = FixedSource(vec![make_edge(
carol(),
transfer(),
ResourceId::new(ResourceKind::Asset, "treasury"),
EdgeSource::GovernanceVote("prop-42".into()),
)]);
let g = GraphBuilder::new()
.add_source(Box::new(s1))
.add_source(Box::new(s2))
.build();
assert_eq!(g.edges().len(), 3);
// Alice can propose
let d = g.query(&alice(), &propose(), &coop());
assert!(d.allowed);
// Bob can vote
let d = g.query(&bob(), &vote(), &coop());
assert!(d.allowed);
// Carol can transfer on treasury
let d = g.query(
&carol(),
&transfer(),
&ResourceId::new(ResourceKind::Asset, "treasury"),
);
assert!(d.allowed);
// Alice cannot vote
let d = g.query(&alice(), &vote(), &coop());
assert!(!d.allowed);
}
// --- Hash stability: same edges from different source configs → same hash ---
#[test]
fn hash_stable_across_source_configurations() {
let e1 = make_edge(alice(), propose(), coop(), EdgeSource::Static("s".into()));
let e2 = make_edge(bob(), vote(), coop(), EdgeSource::Static("s".into()));
// Config A: one source with both edges
let g1 = GraphBuilder::new()
.add_source(Box::new(FixedSource(vec![e1.clone(), e2.clone()])))
.build();
// Config B: two sources, one edge each
let g2 = GraphBuilder::new()
.add_source(Box::new(FixedSource(vec![e1])))
.add_source(Box::new(FixedSource(vec![e2])))
.build();
assert_eq!(g1.hash(), g2.hash());
}
// --- Subject-scoped build produces correct subset ---
#[test]
fn build_for_subject_correct_subset() {
let source = FixedSource(vec![
make_edge(alice(), propose(), coop(), EdgeSource::Static("s".into())),
make_edge(alice(), vote(), coop(), EdgeSource::Static("s".into())),
make_edge(bob(), propose(), coop(), EdgeSource::Static("s".into())),
]);
let g = GraphBuilder::new()
.add_source(Box::new(source))
.build_for_subject(&alice());
assert_eq!(g.edges().len(), 2);
assert!(g.edges().iter().all(|e| e.subject == alice()));
}
// --- Multiple matching edges for same subject+action+resource ---
#[test]
fn query_returns_multiple_matching_edges() {
let e1 = CapabilityEdge::new(
alice(),
propose(),
coop(),
vec![Constraint::RateLimit(10)],
EdgeSource::CclContract("c1".into()),
None,
);
let e2 = CapabilityEdge::new(
alice(),
propose(),
coop(),
vec![Constraint::RateLimit(20)],
EdgeSource::TrustScore("t1".into()),
None,
);
let g = CapabilityGraph::from_edges(vec![e1, e2]);
let d = g.query(&alice(), &propose(), &coop());
assert!(d.allowed);
assert_eq!(d.matching_edges.len(), 2);
}
}
Step 2: Run integration tests
Run: cd icn/icn && cargo test -p icn-authz --test capability_graph_integration
Expected: All 6 tests PASS
Step 3: Run full crate test suite one final time
Run: cd icn/icn && cargo test -p icn-authz
Expected: All unit + integration tests PASS
Step 4: Run fmt + clippy on the entire crate
Run: cd icn/icn && cargo fmt -p icn-authz --check && cargo clippy -p icn-authz --all-targets -- -D warnings
Expected: PASS
Step 5: Commit
git add icn/crates/icn-authz/tests/
git commit -m "test(authz): integration tests for ordering, hash determinism, and full pipeline"
Final Verification
After all 7 tasks, run:
cd icn/icn && cargo fmt --all --check
cd icn/icn && cargo clippy -p icn-authz --all-targets -- -D warnings
cd icn/icn && cargo test -p icn-authz
Expected: fmt clean, clippy clean, all tests pass.
Test count target: ~50 tests total (17 ids + 15 hash + 13 edge/graph + 6 builder + 6 integration = ~57).