From 97d6d5561ac6985e7dfe9f83f451733dc0d90e4e Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:24:36 -0400 Subject: [PATCH] fix(token): the ERA policy stand-in is deleted and the hole left; the enforcer is keyed by the policy commitment and registers a policy only from its committed bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placeholder sweep, the last item (owner decision 2026-09-25: delete the fake, leave the hole). CONFORMANCE_GAPS §6.32. - TokenPolicySystem::register_policy(bytes) recomputes BLAKE3(TAG_DSM_POLICY, bytes), reads the TokenPolicyV3 blob with Core's one parser and derives the enforcer's view from it (enforced_policy, moved in from the SDK); policy_at(commit) takes the durable bytes the SDK resolver answers only when they re-hash to the commitment asked for; enforce_policy(commit, ..) denies where no policy is committed. The ERA preload, create_root_token_policy, ticker-keyed registration, the caller-asserted anchor binding, PolicyCommitResolver, the validator module and the cache's ticker index are deleted. - The SDK keys enforcement by the policy_commit the operation carries (Transfer, Burn, CreateToken); a lock operation, which carries none, is refused for it (§9.1). Creation and rehydration hand Core the bytes (CoreSDK::register_policy_bytes); the resolver answers load_policy_verified(commit). - IdentityConstraint, EmissionsSchedule, CreditBundlePolicy, Custom, roles and StoredPolicy deleted; PolicyConditionProto fields 1, 5, 6, 7 and CanonicalPolicy.roles reserved; TypeScript bindings regenerated. BitcoinTapConstraint untouched, recorded. - The hole: ERA_POLICY_COMMIT has no preimage, so every ERA transfer and burn is refused at enforcement until ERA's policy blob exists. The tests this turns red are the expected-red manifest, recorded from this commit's board. --- .../dsm/src/core/token/mod.rs | 1 - .../dsm/src/core/token/policy/mod.rs | 418 ++++++----- .../dsm/src/core/token/policy/policy_cache.rs | 56 +- .../core/token/policy/policy_enforcement.rs | 330 ++++----- .../core/token/policy/policy_validation.rs | 679 ------------------ .../dsm/src/core/token/token_state_manager.rs | 8 - .../dsm/src/types/policy_types.rs | 443 ++---------- .../dsm_sdk/src/handlers/token_routes.rs | 204 +----- .../dsm_sdk/src/sdk/core_sdk.rs | 282 ++------ dsm_client/frontend/src/proto/dsm_app_pb.ts | 371 +--------- proto/dsm_app.proto | 51 +- specs/requirements/CONFORMANCE_GAPS.md | 46 +- specs/requirements/VERIFICATION_MATRIX.md | 10 +- 13 files changed, 545 insertions(+), 2354 deletions(-) delete mode 100644 dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_validation.rs diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs index 25b176e5e..8460ad475 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs @@ -16,5 +16,4 @@ pub use token_state_manager::{ builtin_policy_commit_for_token, builtin_token_id_for_policy_commit, canonical_balance_key_for_commit, register_policy_commit_ticker, TOKEN_CREATION_FEE_ERA, resolve_ticker_for_policy_commit, derive_canonical_balance_key, resolve_policy_commit, - PolicyCommitResolver, }; diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs index e1187b2c2..7d16f3946 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs @@ -1,25 +1,28 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 //! src/core/token/policy/mod.rs -//! Token Policy Module +//! Token policies as the enforcer sees them. //! -//! Implements the Content-Addressed Token Policy Anchor (CTPA) system for DSM tokens. -//! - Caching -//! - Validation -//! - Enforcement -//! - Governance +//! A token's policy is the `TokenPolicyV3` bytes committed at its +//! `policy_commit = BLAKE3(TAG_DSM_POLICY, bytes)` (SoFi §47), read by Core's +//! one parser (`crate::economic::token_policy`). Nothing here takes a policy +//! any other way: registration takes the bytes, recomputes the commitment +//! from them and derives the enforcer's view from what the blob says +//! (`policy_enforcement::enforced_policy`), and enforcement is keyed by the +//! commitment an operation names. A token with no committed policy — ERA, +//! whose `policy_commit` constant has no preimage yet — has no policy here, +//! and an operation naming it is refused for that reason. //! -//! Determinism rules: -//! - No wall-clock. -//! - Enforcement prefers explicit tick witness in context_data ("tick" -> u64 LE). +//! Determinism rules: no wall-clock; enforcement reads only what the +//! operation carries and what Core derived from canonical state. pub mod policy_cache; pub mod policy_enforcement; -pub mod policy_validation; -pub use policy_cache::{PolicyCache, PolicyCacheEntry, PolicyCacheConfig}; -pub use policy_enforcement::{EnforcementError, EnforcementResult, PolicyEnforcer}; -pub use policy_validation::{PolicyValidator, ValidationContext, ValidationMode, ValidationResult}; +pub use policy_cache::{PolicyCache, PolicyCacheConfig, PolicyCacheEntry}; +pub use policy_enforcement::{ + enforced_policy, permitted_operations, EnforcementError, EnforcementResult, PolicyEnforcer, +}; use std::collections::HashMap; use std::sync::Arc; @@ -28,37 +31,33 @@ use parking_lot::RwLock; use crate::types::{ error::DsmError, - policy_types::{PolicyAnchor, PolicyFile, TokenPolicy}, + policy_types::{PolicyAnchor, TokenPolicy}, }; -/// Loads a token's committed policy from durable storage on a cache miss. -/// -/// Installed by the SDK, which owns both the durable `token_policies` store and -/// the single policy parser. Core keeps enforcement; it does not learn to read -/// the client database or to parse policy bytes a second way. +/// Answers the durable `TokenPolicyV3` bytes recorded at a policy commitment, +/// on a cache miss. Installed by the SDK, which owns the durable +/// `token_policies` store; Core does not learn to read the client database. /// -/// The implementation is required to re-derive the CPTA anchor from the loaded -/// bytes and reject anything that does not match, so a miss can never be -/// satisfied by bytes the storage layer merely *claims* belong to this token. -pub type PolicyResolver = - Arc Option<(PolicyFile, PolicyAnchor)> + Send + Sync + 'static>; +/// The bytes are trusted for nothing: [`TokenPolicySystem::policy_at`] +/// recomputes the commitment from them and takes them only when it is the +/// one asked for, so a miss can never be satisfied by bytes the storage +/// layer merely *claims* belong to this commitment. +pub type PolicyResolver = Arc Option> + Send + Sync + 'static>; -/// Central token policy system for DSM +/// The token policies this process has read, keyed by commitment. #[derive(Clone)] pub struct TokenPolicySystem { + /// In-memory ONLY. Authority for a policy is its committed bytes, behind + /// `resolver`; this is a cache in front of them. policy_cache: Arc, enforcer: Arc, - validator: Arc, - /// In-memory index ONLY. Authority for persisted policy bytes is the - /// durable store behind `resolver`; this is a cache in front of it. - token_policies: Arc>>, resolver: Arc>>, } impl std::fmt::Debug for TokenPolicySystem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TokenPolicySystem") - .field("indexed_tokens", &self.token_policies.read().len()) + .field("cached_policies", &self.policy_cache.len()) .finish_non_exhaustive() } } @@ -71,236 +70,221 @@ impl Default for TokenPolicySystem { impl TokenPolicySystem { pub fn new() -> Self { - let cache = Arc::new(PolicyCache::new(PolicyCacheConfig::default())); - let enforcer = Arc::new(PolicyEnforcer::new()); - let validator = Arc::new(PolicyValidator::new()); - Self { - policy_cache: cache, - enforcer, - validator, - token_policies: Arc::new(RwLock::new(HashMap::new())), + policy_cache: Arc::new(PolicyCache::new(PolicyCacheConfig::default())), + enforcer: Arc::new(PolicyEnforcer::new()), resolver: Arc::new(RwLock::new(None)), } } - /// Install the durable-storage resolver used on a cache miss. + /// Install the durable-storage resolver consulted on a cache miss. pub fn set_policy_resolver(&self, resolver: PolicyResolver) { *self.resolver.write() = Some(resolver); } - pub async fn register_token_policy( - &self, - token_id: &str, - policy_file: PolicyFile, - ) -> Result { - let anchor = PolicyAnchor::from_policy(&policy_file)?; - self.register_token_policy_with_anchor(token_id, policy_file, anchor.clone()) - .await?; - Ok(anchor) + /// The commitment of `bytes`: `BLAKE3(TAG_DSM_POLICY, bytes)` (SoFi §47). + pub fn commitment_of(bytes: &[u8]) -> [u8; 32] { + crate::crypto::blake3::domain_hash_bytes(crate::common::domain_tags::TAG_DSM_POLICY, bytes) } - /// Register a token policy while preserving an already-authoritative - /// policy anchor. - /// - /// Use this when the policy bytes are committed externally (for example, - /// via storage-layer `DSM/policy` anchoring) and token operations must - /// bind to that exact 32-byte commitment. - pub async fn register_token_policy_with_anchor( - &self, - token_id: &str, - policy_file: PolicyFile, - anchor: PolicyAnchor, - ) -> Result<(), DsmError> { - // Validate policy deterministically - let validation_context = ValidationContext::new(token_id, &policy_file); - let validation_result = self.validator.validate_policy(&validation_context).await?; - - if !validation_result.is_valid { - return Err(DsmError::policy_violation( - "policy_validation", - format!( - "Policy validation failed: {} (errors: {:?})", - validation_result.message, validation_result.errors - ), - None::, - )); - } - - let token_policy = TokenPolicy::new_with_anchor(policy_file, anchor.clone()); - self.policy_cache.store_policy(anchor.clone(), token_policy); - - // Register mappings - self.policy_cache - .index_token_policy(token_id.to_string(), anchor.clone()); - self.token_policies - .write() - .insert(token_id.to_string(), anchor.clone()); - - log::info!("Registered policy for token {}", token_id); - Ok(()) + /// Register the policy these exact `TokenPolicyV3` bytes are, and answer + /// its commitment. The commitment is recomputed from the bytes, the blob + /// is read by Core's one parser, and the enforcer's view is derived from + /// what it says: nothing about the policy is taken from the caller. + pub fn register_policy(&self, bytes: &[u8]) -> Result<[u8; 32], DsmError> { + let commit = Self::commitment_of(bytes); + let parsed = crate::economic::token_policy::parse_token_policy(bytes).map_err(|e| { + DsmError::invalid_operation(format!( + "token policy: the committed bytes do not parse: {e}" + )) + })?; + let anchor = PolicyAnchor::from_bytes(commit); + self.policy_cache.store_policy( + anchor.clone(), + TokenPolicy::new_with_anchor(enforced_policy(&parsed), anchor), + ); + Ok(commit) } - pub async fn get_token_policy(&self, token_id: &str) -> Result, DsmError> { - let anchor = { self.token_policies.read().get(token_id).cloned() }; - if let Some(anchor) = anchor { - if let Some(policy) = self.policy_cache.get_policy(&anchor).await? { - return Ok(Some(policy)); - } + /// The policy committed at `commit`: from the cache, else from the + /// durable bytes the resolver answers, taken only when they are the + /// policy at exactly this commitment. `None` when no committed policy is + /// in hand — a miss is not absence, and neither is bytes at another + /// commitment, but there is nothing to evaluate against either way. + pub async fn policy_at(&self, commit: &[u8; 32]) -> Result, DsmError> { + let anchor = PolicyAnchor::from_bytes(*commit); + if let Some(policy) = self.policy_cache.get_policy(&anchor).await? { + return Ok(Some(policy)); } - - // Cache miss (never indexed, or evicted) is NOT absence. This map lives only as long as the - // process, so after a restart every created and adopted token looked - // policy-less and enforcement denied them — on device that surfaced as - // "Token policy violation for RIGB: No policy registered for token" - // while the committed policy sat in durable storage the whole time. - // - // So a miss consults the durable store rather than concluding. The - // resolver re-derives the CPTA anchor from the loaded bytes and returns - // None unless it matches exactly, so this can only ever install the - // policy the token actually committed to. Genuinely absent, malformed, - // or mismatched bytes still yield None and the caller still denies. let resolver = { self.resolver.read().clone() }; - if let Some(resolver) = resolver { - if let Some((policy_file, anchor)) = resolver(token_id) { - let policy = TokenPolicy::new_with_anchor(policy_file, anchor.clone()); - self.policy_cache.store_policy(anchor.clone(), policy); - self.policy_cache - .index_token_policy(token_id.to_string(), anchor.clone()); - self.token_policies - .write() - .insert(token_id.to_string(), anchor.clone()); - log::info!("[policy] rehydrated {token_id} from durable storage on cache miss"); - return self.policy_cache.get_policy(&anchor).await; - } + let Some(resolver) = resolver else { + return Ok(None); + }; + let Some(bytes) = resolver(commit) else { + return Ok(None); + }; + if Self::commitment_of(&bytes) != *commit { + log::warn!( + "[policy] the durable store answered bytes at another commitment for {}; not a policy", + crate::utils::text_id::encode_base32_crockford(commit) + ); + return Ok(None); } - Ok(None) + self.register_policy(&bytes)?; + log::info!( + "[policy] rehydrated {} from durable storage on cache miss", + crate::utils::text_id::encode_base32_crockford(commit) + ); + self.policy_cache.get_policy(&anchor).await } + /// Whether the policy committed at `commit` permits `operation_type` in + /// `context`. Denied when no policy is committed there. pub async fn enforce_policy( &self, - token_id: &str, + commit: &[u8; 32], operation_type: &str, context: &HashMap>, ) -> Result { - if let Some(policy) = self.get_token_policy(token_id).await? { - self.enforcer - .enforce_policy(&policy, operation_type, context) - .await - } else { - Ok(EnforcementResult::denied("No policy registered for token")) - } - } - - pub fn has_policy_restrictions(&self, token_id: &str) -> bool { - self.token_policies.read().contains_key(token_id) - } - - pub fn get_policy_anchor(&self, token_id: &str) -> Option { - self.token_policies.read().get(token_id).cloned() - } - - pub async fn preload_standard_policies(&self) -> Result<(), DsmError> { - let root_policy = self.create_root_token_policy(); - self.register_token_policy("ERA", root_policy).await?; - Ok(()) - } - - pub fn preload_standard_policies_blocking(&self) -> Result<(), DsmError> { - // Avoid nested runtime panics: if inside a runtime, do the work on a dedicated thread. - if tokio::runtime::Handle::try_current().is_ok() { - let sys = self.clone(); - let join_res = std::thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| { - DsmError::internal( - format!("Failed to build runtime for policy preload: {e}"), - None::, - ) - })?; - rt.block_on(sys.preload_standard_policies()) - }) - .join(); - - return match join_res { - Ok(res) => res, - Err(_) => Err(DsmError::internal( - "Failed to join policy preload thread", - None::, - )), - }; + match self.policy_at(commit).await? { + Some(policy) => { + self.enforcer + .enforce_policy(&policy, operation_type, context) + .await + } + None => Ok(EnforcementResult::denied( + "no policy is committed at the commitment the operation names", + )), } - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| { - DsmError::internal( - format!("Failed to build runtime for policy preload: {e}"), - None::, - ) - })?; - rt.block_on(self.preload_standard_policies()) - } - - fn create_root_token_policy(&self) -> PolicyFile { - let mut policy = PolicyFile::new("ERA Token Policy", "1.0.0", "system"); - policy.with_description("Default policy for the ERA token in DSM ecosystem"); - policy.add_metadata("token_type", "native"); - policy.add_metadata("governance", "meritocratic"); - policy.add_metadata("supply_model", "fixed"); - policy - } -} - -impl crate::core::token::token_state_manager::PolicyCommitResolver for TokenPolicySystem { - /// Resolve a token_id to its 32-byte CPTA policy_commit. - /// - /// Returns the registered `PolicyAnchor` bytes if the token has a policy. - /// Missing policy anchors fail closed. - fn resolve(&self, token_id: &str) -> Result<[u8; 32], DsmError> { - self.get_policy_anchor(token_id) - .map(|a| a.0) - .ok_or_else(|| { - DsmError::invalid_operation(format!("Missing policy anchor for token {token_id}")) - }) } } #[cfg(test)] mod tests { use super::*; + use crate::economic::token_policy::{POLICY_FLAG_BURN, POLICY_FLAG_TRANSFERABLE}; + use crate::sofi::validation::fixtures::token_policy_bytes_with; + use crate::types::policy_types::PolicyCondition; + + fn context(amount: u64) -> HashMap> { + let mut context = HashMap::new(); + context.insert("amount_u64".to_string(), amount.to_le_bytes().to_vec()); + context + } + /// SoFi §47: a policy is registered from its committed bytes alone — the + /// commitment recomputed from them, the enforcer's view derived from what + /// the blob says (its genesis supply, its flags) and nothing else. #[tokio::test] - async fn test_policy_system_creation() { + async fn a_policy_is_registered_from_its_committed_bytes_alone() { let system = TokenPolicySystem::new(); - assert!(!system.has_policy_restrictions("test_token")); + let bytes = token_policy_bytes_with(1, POLICY_FLAG_TRANSFERABLE | POLICY_FLAG_BURN); + let commit = system + .register_policy(&bytes) + .expect("the fixture blob parses"); + assert_eq!(commit, TokenPolicySystem::commitment_of(&bytes)); + + let policy = system + .policy_at(&commit) + .await + .expect("cache read") + .expect("registered"); + assert_eq!(*policy.anchor.as_bytes(), commit); + assert!(policy + .file + .conditions + .contains(&PolicyCondition::SupplyCap { + max_supply: 1_000_000_000 + })); + assert!(policy + .file + .conditions + .contains(&PolicyCondition::OperationRestriction { + allowed_operations: permitted_operations(true, true), + })); + assert_eq!(policy.file.conditions.len(), 2); + assert!( + system + .enforce_policy(&commit, "transfer", &context(5)) + .await + .expect("enforced") + .allowed + ); } + /// Storage §4: bytes the durable store answers for a commitment are the + /// policy there only if they re-hash to it. Bytes at another commitment + /// establish no policy, and an operation naming the commitment is denied. #[tokio::test] - async fn test_register_token_policy() { + async fn bytes_at_another_commitment_are_not_the_policy_asked_for() { let system = TokenPolicySystem::new(); - - let mut policy = PolicyFile::new("Test Policy", "1.0.0", "test_creator"); - policy.add_metadata("test_key", "test_value"); - let anchor = system - .register_token_policy("test_token", policy) + let other = token_policy_bytes_with(2, POLICY_FLAG_TRANSFERABLE); + let other_again = other.clone(); + let asked = + TokenPolicySystem::commitment_of(&token_policy_bytes_with(3, POLICY_FLAG_TRANSFERABLE)); + system.set_policy_resolver(Arc::new(move |_commit: &[u8; 32]| Some(other.clone()))); + + assert!(system.policy_at(&asked).await.expect("read").is_none()); + let result = system + .enforce_policy(&asked, "transfer", &context(1)) .await - .unwrap(); + .expect("enforced"); + assert!(!result.allowed, "{}", result.reason); + assert!( + system + .policy_cache + .get_policy(&PolicyAnchor::from_bytes(TokenPolicySystem::commitment_of( + &other_again + ))) + .await + .expect("cache read") + .is_none(), + "a refused answer registers nothing, under any commitment" + ); + } - assert!(system.has_policy_restrictions("test_token")); - assert_eq!(system.get_policy_anchor("test_token"), Some(anchor)); + /// A commitment no committed policy is in hand for — ERA's today, whose + /// constant has no preimage — permits nothing: the operation is denied + /// for the absence, never allowed by a default. + #[tokio::test] + async fn an_operation_naming_a_commitment_without_a_policy_is_denied() { + let system = TokenPolicySystem::new(); + let era = crate::core::token::token_state_manager::era_policy_commit(); + let result = system + .enforce_policy(&era, "transfer", &context(1)) + .await + .expect("enforced"); + assert!(!result.allowed); + assert_eq!( + result.reason, + "no policy is committed at the commitment the operation names" + ); } + /// The durable bytes at a commitment are taken on a cache miss when they + /// re-hash to it; the policy is then the one those bytes commit. #[tokio::test] - async fn test_resolve_missing_policy_fails_closed() { + async fn a_cache_miss_takes_the_durable_bytes_that_re_hash_to_the_commitment() { let system = TokenPolicySystem::new(); - let resolved = crate::core::token::token_state_manager::PolicyCommitResolver::resolve( - &system, - "missing_token", + let bytes = token_policy_bytes_with(4, 0); + let commit = TokenPolicySystem::commitment_of(&bytes); + let stored = bytes.clone(); + system.set_policy_resolver(Arc::new(move |asked: &[u8; 32]| { + (*asked == TokenPolicySystem::commitment_of(&stored)).then(|| stored.clone()) + })); + let policy = system + .policy_at(&commit) + .await + .expect("read") + .expect("rehydrated from the durable bytes"); + assert_eq!(*policy.anchor.as_bytes(), commit); + let result = system + .enforce_policy(&commit, "transfer", &context(1)) + .await + .expect("enforced"); + assert!( + !result.allowed, + "the fixture with no flags is not transferable" ); - assert!(resolved.is_err()); } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_cache.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_cache.rs index 5e39af73b..f456329a8 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_cache.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_cache.rs @@ -46,7 +46,6 @@ impl Entries { #[derive(Debug)] pub struct PolicyCache { entries: RwLock, - token_index: RwLock>, config: PolicyCacheConfig, } @@ -54,7 +53,6 @@ impl PolicyCache { pub fn new(config: PolicyCacheConfig) -> Self { Self { entries: RwLock::new(Entries::default()), - token_index: RwLock::new(HashMap::new()), config, } } @@ -95,15 +93,6 @@ impl PolicyCache { ); } - pub fn index_token_policy(&self, token_id: String, anchor: PolicyAnchor) { - let mut index = self.token_index.write(); - index.insert(token_id, anchor); - } - - pub fn get_anchor_for_token(&self, token_id: &str) -> Option { - self.token_index.read().get(token_id).cloned() - } - pub fn len(&self) -> usize { self.entries.read().by_anchor.len() } @@ -116,25 +105,21 @@ impl PolicyCache { #[cfg(test)] mod tests { use super::*; - use crate::types::policy_types::{PolicyCondition, PolicyFile, PolicyRole}; + use crate::types::policy_types::{PolicyCondition, PolicyFile}; - fn make_policy(author: &str) -> TokenPolicy { - let mut pf = PolicyFile::new("TestPolicy", "1.0", author); + /// A policy at the commitment `[tag; 32]`. + fn make_policy(tag: u8) -> TokenPolicy { + let mut pf = PolicyFile::new("TestPolicy", "1.0", "author"); pf.add_condition(PolicyCondition::OperationRestriction { allowed_operations: vec!["Transfer".to_string()], }); - pf.add_role(PolicyRole { - id: "owner".into(), - name: "Owner".into(), - permissions: vec!["Transfer".into()], - }); - TokenPolicy::new(pf).unwrap() + TokenPolicy::new_with_anchor(pf, PolicyAnchor::from_bytes([tag; 32])) } #[tokio::test] async fn test_store_and_get_policy() { let cache = PolicyCache::new(PolicyCacheConfig::default()); - let policy = make_policy("author-stored"); + let policy = make_policy(0x01); let anchor = policy.anchor.clone(); cache.store_policy(anchor.clone(), policy.clone()); @@ -156,9 +141,9 @@ mod tests { let config = PolicyCacheConfig { max_entries: 2 }; let cache = PolicyCache::new(config); - let p1 = make_policy("author-p1"); - let p2 = make_policy("author-p2"); - let p3 = make_policy("author-p3"); + let p1 = make_policy(0x11); + let p2 = make_policy(0x12); + let p3 = make_policy(0x13); let a1 = p1.anchor.clone(); let a2 = p2.anchor.clone(); @@ -173,25 +158,6 @@ mod tests { assert!(cache.entries.read().by_anchor.contains_key(&a3)); } - #[test] - fn test_index_token_policy_and_lookup() { - let cache = PolicyCache::new(PolicyCacheConfig::default()); - let policy = make_policy("author-indexed"); - let anchor = policy.anchor.clone(); - - cache.store_policy(anchor.clone(), policy); - cache.index_token_policy("tok-123".to_string(), anchor.clone()); - - let looked_up = cache.get_anchor_for_token("tok-123"); - assert_eq!(looked_up, Some(anchor)); - } - - #[test] - fn test_index_token_policy_missing_returns_none() { - let cache = PolicyCache::new(PolicyCacheConfig::default()); - assert!(cache.get_anchor_for_token("nonexistent").is_none()); - } - #[test] fn test_default_config_values() { let config = PolicyCacheConfig::default(); @@ -204,7 +170,7 @@ mod tests { assert!(cache.is_empty()); assert_eq!(cache.len(), 0); - let policy = make_policy("author-len"); + let policy = make_policy(0x21); let anchor = policy.anchor.clone(); cache.store_policy(anchor, policy); @@ -217,7 +183,7 @@ mod tests { let config = PolicyCacheConfig { max_entries: 2 }; let cache = PolicyCache::new(config); - let policy = make_policy("author-same"); + let policy = make_policy(0x22); let anchor = policy.anchor.clone(); cache.store_policy(anchor.clone(), policy.clone()); diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs index 77709f3e6..9407d134c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs @@ -3,7 +3,11 @@ //! src/core/token/policy/policy_enforcement.rs //! Policy Enforcement Engine (protobuf-only; binary comparisons; no hex/base64/JSON). //! -//! Enforces token policy constraints (CTPA). +//! Enforces what a token's committed policy says (SoFi §47–§54): the supply +//! it was created with and the operations its flags permit. The enforcer's +//! view of a policy is derived here from the parsed blob +//! ([`enforced_policy`]) and nowhere else. +//! //! Determinism rules: //! - No time of any kind. //! - No alternate paths. @@ -12,7 +16,7 @@ use std::collections::HashMap; use crate::types::{ error::DsmError, - policy_types::{PolicyCondition, PolicyRole, TokenPolicy}, + policy_types::{PolicyCondition, PolicyFile, TokenPolicy}, }; /// Minimal error type for policy enforcement failures that are not simply allow/deny decisions @@ -71,20 +75,11 @@ impl EnforcementResult { } } -/// Identity context (local-only; deterministic) -#[derive(Debug, Clone)] -pub struct IdentityContext { - pub id: String, - pub assigned_roles: Option>, - pub derivation_path: Option>, -} - -/// Policy enforcement context (constructed from operation + caller-provided binary data) +/// Policy enforcement context: the operation type and the binary data the +/// SDK derived from the operation itself. #[derive(Debug, Clone)] pub struct EnforcementContext { pub operation_type: String, - pub identity: Option, - pub region: Option, pub data: HashMap>, } @@ -92,26 +87,10 @@ impl EnforcementContext { pub fn new(operation_type: &str) -> Self { Self { operation_type: operation_type.to_string(), - identity: None, - region: None, data: HashMap::new(), } } - pub fn with_identity(mut self, identity: &str) -> Self { - self.identity = Some(IdentityContext { - id: identity.to_string(), - assigned_roles: None, - derivation_path: None, - }); - self - } - - pub fn with_region(mut self, region: &str) -> Self { - self.region = Some(region.to_string()); - self - } - pub fn with_data(mut self, key: &str, value: Vec) -> Self { self.data.insert(key.to_string(), value); self @@ -127,13 +106,57 @@ impl EnforcementContext { } } +/// The enforcer's view of a parsed policy blob. +/// +/// SOLE constructor. It is a pure function of the parsed (and therefore of +/// the committed) policy, so every device that reads the same policy bytes +/// derives the same view: there is no second place that decides what a +/// token's policy means. +pub fn enforced_policy(parsed: &crate::economic::token_policy::TokenPolicy) -> PolicyFile { + // Name, version and author are display fields: the policy's identity is + // its commitment, never anything stated here. + let mut file = PolicyFile::new(&parsed.ticker, "1.0.0", "committed policy"); + if let Some(description) = parsed.description.as_ref() { + file.description = Some(description.clone()); + } + // CONDITIONS, not metadata: conditions are what the enforcer evaluates. + // The whole supply exists from creation (SoFi §51): no unit is issued + // after it. + file.add_condition(PolicyCondition::SupplyCap { + max_supply: parsed.genesis_supply, + }); + // What each flag governs (SoFi §49, §54): `transferable` every transfer + // (vault creation and SoFi legs are refused in Core, at genesis + // acceptance and route validation), `burn_enabled` burns only. Creation + // is always the creator's own (Amendment S8, checked at the genesis + // release). The signer set the blob carries authorizes only what the + // policy's own rules name, and the standard release rule names none + // (§47), so no condition is built from it. + file.add_condition(PolicyCondition::OperationRestriction { + allowed_operations: permitted_operations(parsed.transferable, parsed.burn_enabled), + }); + file.add_metadata("token_name", &parsed.ticker); + file +} + +/// The operations a token's policy permits, from its two flags. +pub fn permitted_operations(transferable: bool, burn_enabled: bool) -> Vec { + let mut ops = vec!["create_token".to_string()]; + if transferable { + ops.extend(["transfer", "lock", "unlock"].map(String::from)); + } + if burn_enabled { + ops.push("burn".to_string()); + } + ops +} + /// Policy enforcement engine #[derive(Debug, Default)] pub struct PolicyEnforcer; /// Context keys carrying what the supply cap is evaluated against. pub mod witness_keys { - pub const POLICY_COMMIT: &str = "policy_commit"; pub const AMOUNT: &str = "amount_le"; /// Circulating supply DERIVED from canonical state (never a cached count). pub const CIRCULATING: &str = "circulating_le"; @@ -151,40 +174,15 @@ impl PolicyEnforcer { context_data: &HashMap>, ) -> Result { let mut ctx = EnforcementContext::new(operation_type); - for (k, v) in context_data { ctx = ctx.with_data(k, v.clone()); } - - if let Some(id_bytes) = context_data.get("identity") { - if let Ok(id) = String::from_utf8(id_bytes.clone()) { - ctx = ctx.with_identity(&id); - } - } - if let Some(region_bytes) = context_data.get("region") { - if let Ok(region) = String::from_utf8(region_bytes.clone()) { - ctx = ctx.with_region(®ion); - } - } - for condition in &policy.file.conditions { let res = self.check_condition(condition, &ctx).await?; if !res.allowed { return Ok(res); } } - - if !policy.file.roles.is_empty() { - let ok = self - .check_role_permissions(&policy.file.roles, &ctx) - .await?; - if !ok { - return Ok(EnforcementResult::denied( - "Operation not permitted by role-based access control", - )); - } - } - Ok(EnforcementResult::allowed( "All policy conditions satisfied", )) @@ -199,31 +197,9 @@ impl PolicyEnforcer { ctx: &EnforcementContext, ) -> Result { match condition { - PolicyCondition::IdentityConstraint { - allowed_identities, - allow_derived, - } => { - if let Some(ref id) = ctx.identity { - if allowed_identities.iter().any(|s| s == &id.id) { - return Ok(EnforcementResult::allowed("Identity authorized")); - } - if *allow_derived && self.is_derived_identity(id, allowed_identities).await { - return Ok(EnforcementResult::allowed("Derived identity authorized")); - } - Ok(EnforcementResult::denied("Identity not authorized")) - } else { - Ok(EnforcementResult::denied("No identity provided")) - } - } - PolicyCondition::OperationRestriction { allowed_operations } => { - // Issue #183 Finding 3 fix: match the case-sensitive canonical - // encoding. `CanonicalPolicy` sorts `allowed_operations` with - // a case-sensitive `Vec::sort()`, so `["transfer"]` and - // `["Transfer"]` are distinct policy_commits. Enforcement - // previously used `eq_ignore_ascii_case`, which let - // `"Transfer"` (uppercase) pass under a policy committed to - // `"transfer"` only — semantic gap. Match exactly. + // Match the case-sensitive canonical encoding: `["transfer"]` + // and `["Transfer"]` are distinct policies. let allowed = allowed_operations .iter() .any(|op| op == &ctx.operation_type); @@ -270,24 +246,6 @@ impl PolicyEnforcer { } } - PolicyCondition::Custom { - constraint_type, - parameters, - } => { - self.check_custom_constraint(constraint_type, parameters, ctx) - .await - } - - PolicyCondition::EmissionsSchedule { .. } => { - // Configuration-only; does not deny operations directly. - Ok(EnforcementResult::allowed("Emissions schedule parameter")) - } - - PolicyCondition::CreditBundlePolicy { .. } => { - // Configuration-only; does not deny operations directly. - Ok(EnforcementResult::allowed("Credit bundle policy parameter")) - } - PolicyCondition::BitcoinTapConstraint { .. } => { // Configuration-only; tap safety is enforced at vault creation // and fractional exit time, not during generic policy enforcement. @@ -297,116 +255,84 @@ impl PolicyEnforcer { } } } - - async fn check_custom_constraint( - &self, - constraint_type: &str, - parameters: &HashMap, - ctx: &EnforcementContext, - ) -> Result { - match constraint_type { - "amount_limit" => { - let max_amount = parameters - .get("max_amount") - .and_then(|s| s.parse::().ok()); - - let Some(max_amount) = max_amount else { - return Ok(EnforcementResult::denied("Missing/invalid max_amount")); - }; - - match ctx.amount_witness() { - Some(v) if v <= max_amount => { - Ok(EnforcementResult::allowed("Amount limit satisfied")) - } - Some(_) => Ok(EnforcementResult::denied("Amount exceeds limit")), - None => Ok(EnforcementResult::denied("No amount witness provided")), - } - } - - _ => { - // Production-safe default: unknown custom constraint DENIES unless explicitly waived. - Ok(EnforcementResult::denied("Unknown custom constraint")) - } - } - } - - async fn check_role_permissions( - &self, - roles: &[PolicyRole], - ctx: &EnforcementContext, - ) -> Result { - let Some(identity) = ctx.identity.as_ref() else { - return Ok(false); - }; - - for role in roles { - if self.user_has_role(identity, &role.id).await { - // Issue #183 Finding 3 fix: role-permission match must use - // case-sensitive comparison to align with the case-sensitive - // canonical sort of role permissions in `CanonicalPolicy`. - let permitted = role.permissions.iter().any(|op| op == &ctx.operation_type); - if permitted { - return Ok(true); - } - } - } - Ok(false) - } - - async fn user_has_role(&self, id: &IdentityContext, role_id: &str) -> bool { - id.assigned_roles - .as_ref() - .map(|rs| rs.iter().any(|r| r == role_id)) - .unwrap_or(false) - } - - async fn is_derived_identity(&self, id: &IdentityContext, allowed: &[String]) -> bool { - if allowed.is_empty() { - return false; - } - match &id.derivation_path { - Some(path) if !path.is_empty() => { - if let Some(tail) = path.last() { - allowed.iter().any(|a| a == tail) - } else { - false - } - } - _ => false, - } - } } #[cfg(test)] mod tests { use super::*; - use crate::types::policy_types::{PolicyCondition, PolicyFile, TokenPolicy}; - - #[tokio::test] - async fn identity_constraint_denies() -> Result<(), Box> { - let enforcer = PolicyEnforcer::new(); - - let mut pf = PolicyFile::new("ID", "1.0.0", "a"); - pf.add_condition(PolicyCondition::IdentityConstraint { - allowed_identities: vec!["allowed_user".into()], - allow_derived: false, - }); - let pol = TokenPolicy::new(pf)?; - - let mut ctx = HashMap::new(); - ctx.insert("identity".into(), b"unauthorized_user".to_vec()); - - let res = enforcer.enforce_policy(&pol, "transfer", &ctx).await?; - assert!(!res.allowed); - Ok(()) + use crate::economic::token_policy::{ReleaseRule, TokenPolicy as ParsedTokenPolicy}; + use crate::types::policy_types::{PolicyAnchor, PolicyCondition, PolicyFile, TokenPolicy}; + + fn fungible_fixture() -> ParsedTokenPolicy { + ParsedTokenPolicy { + creator_genesis: [0x31; 32], + creator_device_id: [0x32; 32], + ticker: "DSM".into(), + alias: "DSM Token".into(), + decimals: 8, + genesis_supply: 1_000_000, + release_rule: ReleaseRule::AllAtCreation, + description: Some("A test token".into()), + icon_url: Some("dsm:icon".into()), + burn_enabled: true, + transferable: true, + threshold: 1, + signers: vec![vec![0xAB; 64]], + allowlist_device_ids: Vec::new(), + } } - // ──────────────────────────────────────────────────────────────────────── - // Issue #183 Finding 3 regression — OperationRestriction must be - // case-sensitive so enforcement aligns with the case-sensitive canonical - // sort that goes into the policy_commit anchor. - // ──────────────────────────────────────────────────────────────────────── + /// SoFi §49, §54: a token's operation restriction is exactly its two + /// flags — transfers (and the lock operation types) when transferable, + /// burns when burn-enabled, creation always — and the signer set builds + /// no condition, because the standard release rule names it for nothing + /// (§47). + #[test] + fn the_policy_permits_exactly_what_its_flags_name() { + for (transferable, burn_enabled) in + [(true, true), (true, false), (false, true), (false, false)] + { + let parsed = ParsedTokenPolicy { + transferable, + burn_enabled, + ..fungible_fixture() + }; + let file = enforced_policy(&parsed); + let restrictions: Vec<&Vec> = file + .conditions + .iter() + .filter_map(|c| match c { + PolicyCondition::OperationRestriction { allowed_operations } => { + Some(allowed_operations) + } + _ => None, + }) + .collect(); + assert_eq!( + restrictions.len(), + 1, + "one restriction ({transferable}, {burn_enabled})" + ); + let ops = restrictions[0]; + let has = |op: &str| ops.iter().any(|o| o == op); + assert!(has("create_token")); + assert_eq!(has("transfer"), transferable); + assert_eq!(has("lock"), transferable); + assert_eq!(has("unlock"), transferable); + assert_eq!(has("burn"), burn_enabled); + assert_eq!( + file.conditions.len(), + 2, + "the supply cap and the restriction, and nothing built from the signer set" + ); + assert!(file.conditions.contains(&PolicyCondition::SupplyCap { + max_supply: 1_000_000 + })); + } + } + // OperationRestriction is case-sensitive, so enforcement aligns with the + // case-sensitive canonical sort that goes into a policy's bytes. #[tokio::test] async fn operation_restriction_is_case_sensitive() -> Result<(), Box> { let enforcer = PolicyEnforcer::new(); @@ -415,7 +341,7 @@ mod tests { pf.add_condition(PolicyCondition::OperationRestriction { allowed_operations: vec!["transfer".into()], }); - let pol = TokenPolicy::new(pf)?; + let pol = TokenPolicy::new_with_anchor(pf, PolicyAnchor::from_bytes([0x0F; 32])); let ctx = HashMap::new(); @@ -423,9 +349,9 @@ mod tests { let res = enforcer.enforce_policy(&pol, "transfer", &ctx).await?; assert!(res.allowed, "exact-case operation must be allowed"); - // Uppercase variant — must reject (canonical anchor sees only + // Uppercase variant — must reject (the committed bytes see only // "transfer"; allowing "Transfer" would diverge enforcement from - // the anchored permission set). + // the committed permission set). let res = enforcer.enforce_policy(&pol, "Transfer", &ctx).await?; assert!( !res.allowed, diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_validation.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_validation.rs deleted file mode 100644 index 45e07d102..000000000 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_validation.rs +++ /dev/null @@ -1,679 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! src/core/token/policy/policy_validation.rs -//! Policy Validation Module -//! -//! Validates token policies and their application to token metadata -//! according to DSM Content-Addressed Token Policy Anchor (CTPA) standards. -//! -//! Determinism rules: -//! - No time of any kind. -//! - No string encodings for binary anchors/hashes in logs or comparisons. - -use std::collections::{HashMap, HashSet}; - -use crate::types::{ - error::DsmError, - policy_types::{PolicyCondition, PolicyFile, PolicyRole}, -}; - -/// Validation result for policy checks -#[derive(Debug, Clone)] -pub struct ValidationResult { - /// Whether the validation passed - pub is_valid: bool, - /// Validation message - pub message: String, - /// List of validation errors - pub errors: Vec, - /// List of validation warnings - pub warnings: Vec, - /// Additional validation context - pub context: HashMap, -} - -impl ValidationResult { - pub fn valid(message: &str) -> Self { - Self { - is_valid: true, - message: message.to_string(), - errors: Vec::new(), - warnings: Vec::new(), - context: HashMap::new(), - } - } - - pub fn invalid(message: &str, errors: Vec) -> Self { - Self { - is_valid: false, - message: message.to_string(), - errors, - warnings: Vec::new(), - context: HashMap::new(), - } - } - - pub fn with_warning(mut self, warning: ValidationWarning) -> Self { - self.warnings.push(warning); - self - } - - pub fn with_context(mut self, key: &str, value: &str) -> Self { - self.context.insert(key.to_string(), value.to_string()); - self - } - - pub fn has_errors(&self) -> bool { - !self.errors.is_empty() - } - - pub fn has_warnings(&self) -> bool { - !self.warnings.is_empty() - } -} - -/// Validation error types -#[derive(Debug, Clone)] -pub enum ValidationError { - InvalidStructure(String), - MissingField(String), - InvalidValue(String, String), - ConflictingConditions(String), - InvalidIdentityConstraint(String), - InvalidOperationRestriction(String), - InvalidCustomConstraint(String), - PolicyTooComplex(String), - UnsupportedFeature(String), -} - -impl ValidationError { - pub fn message(&self) -> &str { - match self { - ValidationError::InvalidStructure(msg) => msg, - ValidationError::MissingField(msg) => msg, - ValidationError::InvalidValue(_, msg) => msg, - ValidationError::ConflictingConditions(msg) => msg, - ValidationError::InvalidIdentityConstraint(msg) => msg, - ValidationError::InvalidOperationRestriction(msg) => msg, - ValidationError::InvalidCustomConstraint(msg) => msg, - ValidationError::PolicyTooComplex(msg) => msg, - ValidationError::UnsupportedFeature(msg) => msg, - } - } -} - -/// Validation warning types -#[derive(Debug, Clone)] -pub enum ValidationWarning { - PerformanceConcern(String), - SecurityRecommendation(String), - BestPractice(String), - CompatibilityWarning(String), -} - -impl ValidationWarning { - pub fn message(&self) -> &str { - match self { - ValidationWarning::PerformanceConcern(msg) => msg, - ValidationWarning::SecurityRecommendation(msg) => msg, - ValidationWarning::BestPractice(msg) => msg, - ValidationWarning::CompatibilityWarning(msg) => msg, - } - } -} - -/// Validation context for policy checking -#[derive(Debug, Clone)] -pub struct ValidationContext { - pub token_id: String, - pub policy_file: PolicyFile, - pub parameters: HashMap>, - pub validation_mode: ValidationMode, -} - -/// Validation mode enumeration -#[derive(Debug, Clone, PartialEq)] -pub enum ValidationMode { - Strict, - Permissive, - Development, - Production, -} - -impl ValidationContext { - pub fn new(token_id: &str, policy_file: &PolicyFile) -> Self { - Self { - token_id: token_id.to_string(), - policy_file: policy_file.clone(), - parameters: HashMap::new(), - validation_mode: ValidationMode::Strict, - } - } - - pub fn with_mode(mut self, mode: ValidationMode) -> Self { - self.validation_mode = mode; - self - } - - pub fn with_parameter(mut self, key: &str, value: Vec) -> Self { - self.parameters.insert(key.to_string(), value); - self - } -} - -/// Policy validator implementation -#[derive(Debug)] -pub struct PolicyValidator { - max_complexity: u32, - max_conditions: usize, - max_roles: usize, -} - -impl PolicyValidator { - pub fn new() -> Self { - Self { - max_complexity: 1000, - max_conditions: 50, - max_roles: 20, - } - } - - pub fn with_limits(max_complexity: u32, max_conditions: usize, max_roles: usize) -> Self { - Self { - max_complexity, - max_conditions, - max_roles, - } - } - - #[allow(clippy::unused_async)] - pub async fn validate_policy( - &self, - context: &ValidationContext, - ) -> Result { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - - self.validate_basic_structure(&context.policy_file, &mut errors); - self.validate_conditions(&context.policy_file.conditions, &mut errors, &mut warnings); - self.validate_roles(&context.policy_file.roles, &mut errors, &mut warnings); - self.validate_complexity(&context.policy_file, &mut errors, &mut warnings); - self.validate_mode_specific(context, &mut warnings); - - let is_valid = errors.is_empty(); - let message = if is_valid { - "Policy validation successful".to_string() - } else { - format!("Policy validation failed with {} errors", errors.len()) - }; - - let mut result = if is_valid { - ValidationResult::valid(&message) - } else { - ValidationResult::invalid(&message, errors) - }; - - for warning in warnings { - result = result.with_warning(warning); - } - - result = result - .with_context("token_id", &context.token_id) - .with_context("policy_name", &context.policy_file.name) - .with_context("policy_version", &context.policy_file.version); - - Ok(result) - } - - fn validate_basic_structure(&self, policy: &PolicyFile, errors: &mut Vec) { - if policy.name.is_empty() { - errors.push(ValidationError::MissingField( - "Policy name is required".to_string(), - )); - } - if policy.version.is_empty() { - errors.push(ValidationError::MissingField( - "Policy version is required".to_string(), - )); - } - if policy.author.is_empty() { - errors.push(ValidationError::MissingField( - "Policy author is required".to_string(), - )); - } - - if !policy.version.is_empty() && !self.is_valid_version(&policy.version) { - errors.push(ValidationError::InvalidValue( - "version".to_string(), - "Version must follow semantic versioning (e.g., 1.0.0)".to_string(), - )); - } - } - - fn validate_conditions( - &self, - conditions: &[PolicyCondition], - errors: &mut Vec, - warnings: &mut Vec, - ) { - if conditions.len() > self.max_conditions { - errors.push(ValidationError::PolicyTooComplex(format!( - "Too many conditions: {} > {}", - conditions.len(), - self.max_conditions - ))); - } - - for (index, condition) in conditions.iter().enumerate() { - match condition { - PolicyCondition::IdentityConstraint { - allowed_identities, - allow_derived: _, - } => { - if allowed_identities.is_empty() { - errors.push(ValidationError::InvalidIdentityConstraint(format!( - "Condition {index}: No allowed identities specified" - ))); - } - - let mut uniq = HashSet::new(); - for identity in allowed_identities { - if !uniq.insert(identity) { - warnings.push(ValidationWarning::BestPractice(format!( - "Condition {index}: Duplicate identity: {identity}" - ))); - } - if identity.is_empty() { - errors.push(ValidationError::InvalidIdentityConstraint(format!( - "Condition {index}: Empty identity string" - ))); - } - } - } - - PolicyCondition::SupplyCap { max_supply } => { - if *max_supply == 0 { - errors.push(ValidationError::InvalidValue( - format!("condition[{index}].SupplyCap.max_supply"), - "a zero supply is not a token (SoFi §50)".into(), - )); - } - } - - PolicyCondition::OperationRestriction { allowed_operations } => { - if allowed_operations.is_empty() { - errors.push(ValidationError::InvalidOperationRestriction(format!( - "Condition {index}: allowed_operations is empty" - ))); - } else { - let mut uniq = HashSet::new(); - for op in allowed_operations { - if op.is_empty() { - errors.push(ValidationError::InvalidOperationRestriction(format!( - "Condition {index}: operation name cannot be empty" - ))); - } - if !uniq.insert(op) { - warnings.push(ValidationWarning::BestPractice(format!( - "Condition {index}: Duplicate operation: {op}" - ))); - } - } - } - } - - PolicyCondition::Custom { - constraint_type, - parameters, - } => { - if constraint_type.is_empty() { - errors.push(ValidationError::InvalidCustomConstraint(format!( - "Condition {index}: Custom constraint type cannot be empty" - ))); - } - if parameters.is_empty() { - warnings.push(ValidationWarning::BestPractice(format!( - "Condition {index}: Custom constraint has no parameters" - ))); - } - } - - PolicyCondition::EmissionsSchedule { - total_supply, - shard_depth, - schedule_steps, - initial_step_emissions: _, - initial_step_amount: _, - } => { - if *total_supply == 0 { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "Total supply must be positive".to_string(), - )); - } - if *shard_depth == 0 || *shard_depth > 32 { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "Shard depth must be between 1 and 32".to_string(), - )); - } - if *schedule_steps == 0 { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "Schedule steps must be positive".to_string(), - )); - } - } - - PolicyCondition::CreditBundlePolicy { - bundle_size, - debit_rule, - refill_rule, - } => { - if *bundle_size == 0 { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "Bundle size must be positive".to_string(), - )); - } - if debit_rule.is_empty() { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "Debit rule cannot be empty".to_string(), - )); - } - if refill_rule.is_empty() { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "Refill rule cannot be empty".to_string(), - )); - } - } - - PolicyCondition::BitcoinTapConstraint { - max_successor_depth, - min_vault_balance_sats, - dust_floor_sats, - min_confirmations, - } => { - if *max_successor_depth == 0 { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "max_successor_depth must be positive".to_string(), - )); - } - if *dust_floor_sats == 0 { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "dust_floor_sats must be positive (Bitcoin consensus minimum)" - .to_string(), - )); - } - if *min_vault_balance_sats < *dust_floor_sats { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - format!( - "min_vault_balance_sats ({}) must be >= dust_floor_sats ({})", - min_vault_balance_sats, dust_floor_sats - ), - )); - } - if *min_confirmations == 0 { - errors.push(ValidationError::InvalidValue( - format!("Condition {index}"), - "min_confirmations must be positive".to_string(), - )); - } - } - } - } - - self.check_condition_conflicts(conditions, errors); - } - - fn validate_roles( - &self, - roles: &[PolicyRole], - errors: &mut Vec, - warnings: &mut Vec, - ) { - if roles.len() > self.max_roles { - errors.push(ValidationError::PolicyTooComplex(format!( - "Too many roles: {} > {}", - roles.len(), - self.max_roles - ))); - } - - let mut role_ids = HashSet::new(); - for (index, role) in roles.iter().enumerate() { - if role.id.is_empty() { - errors.push(ValidationError::MissingField(format!( - "Role {index}: Role ID is required" - ))); - } - if role.name.is_empty() { - errors.push(ValidationError::MissingField(format!( - "Role {index}: Role name is required" - ))); - } - if !role.id.is_empty() && !role_ids.insert(&role.id) { - errors.push(ValidationError::InvalidValue( - "role_id".to_string(), - format!("Role {index}: Duplicate role ID: {}", role.id), - )); - } - if role.permissions.is_empty() { - warnings.push(ValidationWarning::SecurityRecommendation(format!( - "Role {index}: Role has no permissions" - ))); - } - } - } - - fn validate_complexity( - &self, - policy: &PolicyFile, - errors: &mut Vec, - warnings: &mut Vec, - ) { - let score = self.calculate_complexity_score(policy); - - if score > self.max_complexity { - errors.push(ValidationError::PolicyTooComplex(format!( - "Policy complexity {} exceeds maximum {}", - score, self.max_complexity - ))); - } else if u64::from(score) * 5 > u64::from(self.max_complexity) * 4 { - warnings.push(ValidationWarning::PerformanceConcern(format!( - "High policy complexity: {score} (over 80% of limit)" - ))); - } else if score > self.max_complexity / 2 { - warnings.push(ValidationWarning::PerformanceConcern(format!( - "Moderate policy complexity: {score}" - ))); - } - } - - fn calculate_complexity_score(&self, policy: &PolicyFile) -> u32 { - let mut score = 10; - score += policy.conditions.len() as u32 * 5; - score += policy.roles.len() as u32 * 3; - score += policy.metadata.len() as u32; - - for c in &policy.conditions { - match c { - PolicyCondition::Custom { .. } => score += 10, - PolicyCondition::OperationRestriction { allowed_operations } => { - score += allowed_operations.len() as u32 * 2; - } - PolicyCondition::IdentityConstraint { - allowed_identities, .. - } => { - score += allowed_identities.len() as u32; - } - _ => score += 2, - } - } - - score - } - - fn validate_mode_specific( - &self, - context: &ValidationContext, - warnings: &mut Vec, - ) { - match context.validation_mode { - ValidationMode::Production => { - if context.policy_file.description.is_none() { - warnings.push(ValidationWarning::BestPractice( - "Policy description recommended in production".to_string(), - )); - } - if context.policy_file.conditions.is_empty() { - warnings.push(ValidationWarning::SecurityRecommendation( - "No policy conditions in production mode".to_string(), - )); - } - } - ValidationMode::Development => { - if context.policy_file.conditions.len() > 20 { - warnings.push(ValidationWarning::PerformanceConcern( - "Many conditions may impact development performance".to_string(), - )); - } - } - ValidationMode::Strict => { - // Strict mode: ensure policy is not “empty allow-all” unless explicitly intended. - if context.policy_file.conditions.is_empty() && context.policy_file.roles.is_empty() - { - warnings.push(ValidationWarning::SecurityRecommendation( - "Strict mode with no conditions/roles results in allow-all".to_string(), - )); - } - } - ValidationMode::Permissive => {} - } - } - - fn check_condition_conflicts( - &self, - conditions: &[PolicyCondition], - errors: &mut Vec, - ) { - // Minimal, deterministic conflict detection: - // - Multiple OperationRestriction conditions must have a non-empty intersection. - let mut op_sets: Vec> = Vec::new(); - for c in conditions { - if let PolicyCondition::OperationRestriction { allowed_operations } = c { - let set: HashSet = allowed_operations - .iter() - .map(|s| s.to_ascii_lowercase()) - .collect(); - op_sets.push(set); - } - } - if op_sets.len() >= 2 { - let mut it = op_sets.into_iter(); - let mut inter = it.next().unwrap_or_default(); - for s in it { - inter = inter.intersection(&s).cloned().collect(); - } - if inter.is_empty() { - errors.push(ValidationError::ConflictingConditions( - "OperationRestriction conditions have empty intersection (token becomes unusable)" - .to_string(), - )); - } - } - } - - fn is_valid_version(&self, version: &str) -> bool { - let parts: Vec<&str> = version.split('.').collect(); - if parts.len() != 3 { - return false; - } - parts.iter().all(|p| p.parse::().is_ok()) - } -} - -impl Default for PolicyValidator { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::policy_types::PolicyFile; - - #[tokio::test] - async fn test_policy_validation_success() { - let validator = PolicyValidator::new(); - let policy_file = PolicyFile::new("Test Policy", "1.0.0", "test_author"); - let context = ValidationContext::new("test_token", &policy_file) - .with_mode(ValidationMode::Permissive); - - let result = validator.validate_policy(&context).await.unwrap(); - assert!(result.is_valid); - } - - #[tokio::test] - async fn test_policy_validation_missing_fields() { - let validator = PolicyValidator::new(); - let policy_file = PolicyFile::new("", "", ""); - let context = ValidationContext::new("test_token", &policy_file); - - let result = validator.validate_policy(&context).await.unwrap(); - assert!(!result.is_valid); - assert!(result - .errors - .iter() - .any(|e| matches!(e, ValidationError::MissingField(_)))); - } - - /// A zero supply is not a token (SoFi §50), and no supply is unlimited - /// (§54), so a SupplyCap of 0 has no reading that validates. - #[tokio::test] - async fn a_zero_supply_cap_does_not_validate() { - let validator = PolicyValidator::new(); - let cap = |max_supply| { - let mut p = PolicyFile::new("Test Policy", "1.0.0", "test_author"); - p.add_condition(PolicyCondition::SupplyCap { max_supply }); - p - }; - let zero = cap(0); - let result = validator - .validate_policy(&ValidationContext::new("test_token", &zero)) - .await - .unwrap(); - assert!(!result.is_valid); - assert!(result.errors.iter().any(|e| matches!( - e, - ValidationError::InvalidValue(field, _) if field.ends_with("SupplyCap.max_supply") - ))); - - let one = cap(1); - let result = validator - .validate_policy(&ValidationContext::new("test_token", &one)) - .await - .unwrap(); - assert!(result.is_valid, "{:?}", result.errors); - } - - #[tokio::test] - async fn test_policy_validation_invalid_version() { - let validator = PolicyValidator::new(); - let policy_file = PolicyFile::new("Test Policy", "invalid_version", "test_author"); - let context = ValidationContext::new("test_token", &policy_file); - - let result = validator.validate_policy(&context).await.unwrap(); - assert!(!result.is_valid); - assert!(result - .errors - .iter() - .any(|e| matches!(e, ValidationError::InvalidValue(_, _)))); - } -} diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs index c10b7d2c4..8436032ce 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs @@ -12,14 +12,6 @@ use parking_lot::RwLock; use crate::types::error::DsmError; -/// Resolves a `token_id` string to its 32-byte CPTA `policy_commit`. -/// -/// This trait enables hierarchical domain-separated hashing where -/// `policy_commit` serves as the cryptographic sub-domain for each token type. -pub trait PolicyCommitResolver: Send + Sync { - fn resolve(&self, token_id: &str) -> Result<[u8; 32], DsmError>; -} - /// ERA destroyed to create a token. /// /// This lives in CORE, not in the SDK's mutable `fee_schedule` map, because the diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs b/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs index 00a61b9de..ff046dca5 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs @@ -2,33 +2,28 @@ //! Token Policy Types (Protobuf-only transport; binary-only digests). //! -//! Content-Addressed Token Policy Anchors (CTPA). -//! - Canonical hashing: BLAKE3 over a deterministic byte layout (binary). +//! The enforcer's view of a committed token policy ([`PolicyFile`]) and the +//! 32-byte commitment it is keyed by ([`PolicyAnchor`]). A policy's identity +//! is the commitment of its `TokenPolicyV3` bytes (SoFi §47), computed where +//! the bytes are registered (`crate::core::token::policy`); nothing here +//! hashes. //! - No time of any kind in a policy. //! - Absolutely no hex/json/base64/serde in any Rust path. use std::collections::HashMap; -use crate::{crypto::blake3, types::error::DsmError}; +use crate::types::error::DsmError; use prost::Message; /// Fixed-length digest type for anchors and policy-bound hashes. pub type Digest32 = [u8; 32]; -/// PolicyAnchor is the 32-byte identifier (BLAKE3) of a canonical policy file. +/// The 32-byte commitment a token policy is keyed by: its `policy_commit`, +/// the hash of its committed `TokenPolicyV3` bytes. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PolicyAnchor(pub Digest32); impl PolicyAnchor { - /// Create a new policy anchor from a policy file (content-addressed). - /// - /// NOTE: `PolicyFile::canonical_bytes()` is stable across platforms/runs. - pub fn from_policy(policy: &PolicyFile) -> Result { - let bytes = policy.canonical_bytes()?; - let h = blake3::domain_hash(crate::common::domain_tags::TAG_DSM_CPTA, &bytes); - Ok(PolicyAnchor(*h.as_bytes())) - } - /// Borrow the raw 32-byte anchor. #[inline] pub fn as_bytes(&self) -> &Digest32 { @@ -117,40 +112,15 @@ impl PolicyAnchor { } } -/// Policy-level conditions that constrain token behavior. +/// What a committed policy constrains, as the enforcer evaluates it: the +/// operations its flags permit and the supply it was created with (SoFi +/// §47–§54), derived from the parsed blob by +/// `crate::core::token::policy::enforced_policy` and stated by nothing else. #[derive(Debug, Clone, PartialEq)] pub enum PolicyCondition { - /// Only allow listed identities (optionally including their derivatives). - IdentityConstraint { - allowed_identities: Vec, - allow_derived: bool, - }, - /// Restrict allowed operation types (interpreted as a set). OperationRestriction { allowed_operations: Vec }, - /// Emissions schedule parameters (DJTE). - EmissionsSchedule { - total_supply: u64, - shard_depth: u8, - schedule_steps: u8, - initial_step_emissions: u64, - initial_step_amount: u64, - }, - - /// Credit bundle policy (sender-pays economic rate limiting). - CreditBundlePolicy { - bundle_size: u64, - debit_rule: String, - refill_rule: String, - }, - - /// Custom constraints with string parameters. - Custom { - constraint_type: String, - parameters: HashMap, - }, - /// Bitcoin tap safety constraints (dBTC §12). /// Protocol law — frozen into policy_commit via canonical bytes. /// Any modification produces a distinct GT (different token). @@ -170,16 +140,7 @@ pub enum PolicyCondition { SupplyCap { max_supply: u128 }, } -/// Role-based access control for token policies. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PolicyRole { - pub id: String, - pub name: String, - /// Interpreted as a set; canonicalized by sorted textual form. - pub permissions: Vec, -} - -/// Immutable policy file content. Its canonical bytes are content-addressed. +/// The enforcer's view of one committed policy. #[derive(Debug, Clone)] pub struct PolicyFile { /// Human-friendly name (UI/ops only; not on wire hashing). @@ -192,8 +153,6 @@ pub struct PolicyFile { pub description: Option, /// Constraining conditions. pub conditions: Vec, - /// Roles and permissions. - pub roles: Vec, /// Extra key/value metadata (UI/ops only). pub metadata: HashMap, } @@ -207,7 +166,6 @@ impl PolicyFile { author: author.to_string(), description: None, conditions: Vec::new(), - roles: Vec::new(), metadata: HashMap::new(), } } @@ -217,11 +175,6 @@ impl PolicyFile { self } - pub fn add_role(&mut self, role: PolicyRole) -> &mut Self { - self.roles.push(role); - self - } - pub fn add_metadata(&mut self, key: &str, value: &str) -> &mut Self { self.metadata.insert(key.to_string(), value.to_string()); self @@ -232,18 +185,14 @@ impl PolicyFile { self } - /// Derive the CTPA anchor for this file. - pub fn generate_anchor(&self) -> Result { - PolicyAnchor::from_policy(self) - } - - /// Canonical deterministic serialization for hashing (binary). + /// Canonical deterministic serialization (binary): the `CanonicalPolicy` + /// proto, the shape [`Self::from_canonical_bytes`] reads. /// /// Design: /// - **Excluded**: `metadata`, `description`, `name`, `version` /// (UI/ops only; avoid non-semantic drift). - /// - **Included**: `author`, `conditions`, `roles` (semantic). - /// - Set-like fields are **sorted** (regions, identities, operations, role perms). + /// - **Included**: `author`, `conditions` (semantic). + /// - Set-like fields are **sorted** (operations). /// - Output is a compact binary layout (no text encodings). pub fn canonical_bytes(&self) -> Result, DsmError> { let proto: crate::types::proto::CanonicalPolicy = self.into(); @@ -254,22 +203,11 @@ impl PolicyFile { Ok(buf) } - pub fn to_bytes(&self) -> Result, DsmError> { - let proto: crate::types::proto::StoredPolicy = self.into(); - let mut buf = Vec::new(); - proto - .encode(&mut buf) - .map_err(|e| DsmError::SerializationError(format!("Protobuf encode failed: {}", e)))?; - Ok(buf) - } - /// Deserialize from canonical binary format (CanonicalPolicy proto). /// - /// The canonical encoding contains only the semantic fields: `author`, - /// `conditions`, and `roles`. Non-semantic fields (`name`, `version`, + /// The canonical encoding contains only the semantic fields: `author` + /// and `conditions`. Non-semantic fields (`name`, `version`, /// `description`, `metadata`) are empty. - /// This is used when fetching policies from storage nodes, which store - /// canonical bytes for content-addressed integrity. pub fn from_canonical_bytes(bytes: &[u8]) -> Result { let proto = crate::types::proto::CanonicalPolicy::decode(bytes).map_err(|e| { DsmError::SerializationError(format!("CanonicalPolicy decode failed: {}", e)) @@ -280,7 +218,6 @@ impl PolicyFile { .iter() .map(|c| c.try_into()) .collect::, _>>()?; - let roles = proto.roles.iter().map(|r| r.into()).collect(); Ok(Self { name: String::new(), @@ -288,51 +225,9 @@ impl PolicyFile { author: proto.author, description: None, conditions, - roles, metadata: std::collections::HashMap::new(), }) } - - /// Deserialize from full StoredPolicy binary format. - pub fn from_bytes(bytes: &[u8]) -> Result { - let proto = crate::types::proto::StoredPolicy::decode(bytes) - .map_err(|e| DsmError::SerializationError(format!("Protobuf decode failed: {}", e)))?; - - let conditions = proto - .conditions - .iter() - .map(|c| c.try_into()) - .collect::, _>>()?; - let roles = proto.roles.iter().map(|r| r.into()).collect(); - - Ok(Self { - name: proto.name, - version: proto.revision, - author: proto.author, - description: if proto.description.is_empty() { - None - } else { - Some(proto.description) - }, - conditions, - roles, - metadata: proto.metadata, - }) - } -} - -impl From<&PolicyFile> for crate::types::proto::StoredPolicy { - fn from(file: &PolicyFile) -> Self { - Self { - name: file.name.clone(), - revision: file.version.clone(), - author: file.author.clone(), - description: file.description.clone().unwrap_or_default(), - conditions: file.conditions.iter().map(|c| c.into()).collect(), - roles: file.roles.iter().map(|r| r.into()).collect(), - metadata: file.metadata.clone(), - } - } } impl From<&PolicyFile> for crate::types::proto::CanonicalPolicy { @@ -340,7 +235,6 @@ impl From<&PolicyFile> for crate::types::proto::CanonicalPolicy { Self { author: file.author.clone(), conditions: file.conditions.iter().map(|c| c.into()).collect(), - roles: file.roles.iter().map(|r| r.into()).collect(), } } } @@ -351,17 +245,6 @@ impl From<&PolicyCondition> for crate::types::proto::PolicyConditionProto { use crate::types::proto::*; let kind = match cond { - PolicyCondition::IdentityConstraint { - allowed_identities, - allow_derived, - } => { - let mut sorted = allowed_identities.clone(); - sorted.sort(); - Kind::IdentityConstraint(IdentityConstraintProto { - allowed_identities: sorted, - allow_derived: *allow_derived, - }) - } PolicyCondition::OperationRestriction { allowed_operations } => { let mut sorted = allowed_operations.clone(); sorted.sort(); @@ -369,59 +252,6 @@ impl From<&PolicyCondition> for crate::types::proto::PolicyConditionProto { allowed_operations: sorted, }) } - PolicyCondition::EmissionsSchedule { - total_supply, - shard_depth, - schedule_steps, - initial_step_emissions, - initial_step_amount, - } => Kind::EmissionsSchedule(EmissionsScheduleProto { - total_supply: *total_supply, - shard_depth: *shard_depth as u32, - schedule_steps: *schedule_steps as u32, - initial_step_emissions: *initial_step_emissions, - initial_step_amount: *initial_step_amount, - }), - PolicyCondition::CreditBundlePolicy { - bundle_size, - debit_rule, - refill_rule, - } => Kind::CreditBundlePolicy(CreditBundlePolicyProto { - bundle_size: *bundle_size, - debit_rule: debit_rule.clone(), - refill_rule: refill_rule.clone(), - }), - PolicyCondition::Custom { - constraint_type, - parameters, - } => { - // Issue #183 Finding 1 fix: `CustomConstraintProto.parameters` - // is a `map` with non-deterministic - // iteration order — the proto schema comment explicitly says - // "UI/interop only (non-deterministic ordering). Do not - // hash." Encoding it into `canonical_bytes()` made the - // policy anchor depend on `HashMap` iteration order, which - // varies across runs/platforms. - // - // The canonical projection emits ONLY `parameters_kv` (the - // sorted `Vec`); `parameters` is left empty. - // Reverse conversion already prefers `parameters_kv` and - // falls back to `parameters` for legacy protos, so - // round-tripping is unaffected. - let mut kv: Vec = parameters - .iter() - .map(|(k, v)| ParamKv { - key: k.clone(), - value: v.clone(), - }) - .collect(); - kv.sort_by(|a, b| a.key.cmp(&b.key)); - Kind::Custom(CustomConstraintProto { - constraint_type: constraint_type.clone(), - parameters: std::collections::HashMap::new(), - parameters_kv: kv, - }) - } PolicyCondition::BitcoinTapConstraint { max_successor_depth, min_vault_balance_sats, @@ -449,36 +279,9 @@ impl TryFrom<&crate::types::proto::PolicyConditionProto> for PolicyCondition { use crate::types::proto::*; match &proto.kind { - Some(Kind::IdentityConstraint(p)) => Ok(PolicyCondition::IdentityConstraint { - allowed_identities: p.allowed_identities.clone(), - allow_derived: p.allow_derived, - }), Some(Kind::OperationRestriction(p)) => Ok(PolicyCondition::OperationRestriction { allowed_operations: p.allowed_operations.clone(), }), - Some(Kind::EmissionsSchedule(p)) => Ok(PolicyCondition::EmissionsSchedule { - total_supply: p.total_supply, - shard_depth: p.shard_depth as u8, - schedule_steps: p.schedule_steps as u8, - initial_step_emissions: p.initial_step_emissions, - initial_step_amount: p.initial_step_amount, - }), - Some(Kind::CreditBundlePolicy(p)) => Ok(PolicyCondition::CreditBundlePolicy { - bundle_size: p.bundle_size, - debit_rule: p.debit_rule.clone(), - refill_rule: p.refill_rule.clone(), - }), - Some(Kind::Custom(p)) => Ok(PolicyCondition::Custom { - constraint_type: p.constraint_type.clone(), - parameters: if !p.parameters_kv.is_empty() { - p.parameters_kv - .iter() - .map(|kv| (kv.key.clone(), kv.value.clone())) - .collect() - } else { - p.parameters.clone() - }, - }), Some(Kind::BitcoinTapConstraint(p)) => Ok(PolicyCondition::BitcoinTapConstraint { max_successor_depth: p.max_successor_depth, min_vault_balance_sats: p.min_vault_balance_sats, @@ -504,29 +307,8 @@ impl TryFrom<&crate::types::proto::PolicyConditionProto> for PolicyCondition { } } -impl From<&PolicyRole> for crate::types::proto::PolicyRoleProto { - fn from(role: &PolicyRole) -> Self { - let mut sorted_permissions = role.permissions.clone(); - sorted_permissions.sort(); - Self { - id: role.id.clone(), - name: role.name.clone(), - permissions: sorted_permissions, - } - } -} - -impl From<&crate::types::proto::PolicyRoleProto> for PolicyRole { - fn from(proto: &crate::types::proto::PolicyRoleProto) -> Self { - Self { - id: proto.id.clone(), - name: proto.name.clone(), - permissions: proto.permissions.clone(), - } - } -} - -/// In-memory, runtime policy bundle + verification state. +/// A policy as the enforcer holds it: its view and the commitment it is +/// keyed by. #[derive(Debug, Clone)] pub struct TokenPolicy { pub file: PolicyFile, @@ -534,62 +316,19 @@ pub struct TokenPolicy { } impl TokenPolicy { - pub fn new(file: PolicyFile) -> Result { - let anchor = file.generate_anchor()?; - Ok(Self::new_with_anchor(file, anchor)) - } - - /// Construct a runtime policy using an already-authoritative anchor. - /// - /// This is used when the canonical policy commitment is obtained from a - /// storage-layer anchor (for example, `DSM/policy` anchored bytes) and - /// must be preserved exactly in runtime mapping. + /// The enforcer's view `file` of the policy committed at `anchor`. The + /// commitment is computed where the bytes are registered + /// (`crate::core::token::policy::TokenPolicySystem::register_policy`) + /// and never from `file`. pub fn new_with_anchor(file: PolicyFile, anchor: PolicyAnchor) -> Self { Self { file, anchor } } - - // Issue #183 Finding 2 fix: `is_condition_satisfied` and - // `are_time_conditions_satisfied` previously returned `true` - // unconditionally — a silent total bypass of policy enforcement. They - // had no production callers and have been removed. Use - // `crate::core::token::policy::policy_enforcement::PolicyEnforcer::enforce_policy(...)` - // for the real condition-evaluation path (whitepaper §9.5). -} - -/// Lightweight policy handle used by some SDK surfaces. -pub struct Policy { - pub name: String, - pub conditions: Vec, } #[cfg(test)] mod tests { use super::*; - #[test] - fn anchor_is_stable_and_ignores_display_fields() { - let mut p1 = PolicyFile::new("Name", "v2", "authorX"); - p1.add_condition(PolicyCondition::OperationRestriction { - allowed_operations: vec!["transfer".into(), "lock".into()], - }); - p1.roles.push(PolicyRole { - id: "admin".into(), - name: "Admin".into(), - permissions: vec![], - }); - - // Clone and perturb UI/ops fields that must not affect canonical hash - let mut p2 = p1.clone(); - p2.metadata.insert("note".into(), "hello".into()); - p2.description = Some("desc".into()); - p2.name = "Other".into(); - p2.version = "v2".into(); - - let a1 = p1.generate_anchor().unwrap(); - let a2 = p2.generate_anchor().unwrap(); - assert_eq!(a1.0, a2.0, "UI/ops fields must not affect anchor"); - } - #[test] fn sets_are_sorted_in_canonical_bytes() { let mut p1 = PolicyFile::new("n", "v", "a"); @@ -679,72 +418,47 @@ mod tests { pf.add_condition(PolicyCondition::OperationRestriction { allowed_operations: vec!["transfer".into()], }); - pf.add_role(PolicyRole { - id: "r1".into(), - name: "Role1".into(), - permissions: vec!["read".into()], - }); assert_eq!(pf.description.as_deref(), Some("A test policy")); assert_eq!(pf.metadata.get("key1").unwrap(), "val1"); assert_eq!(pf.conditions.len(), 1); - assert_eq!(pf.roles.len(), 1); - } - - #[test] - fn policy_file_to_bytes_from_bytes_roundtrip() { - let mut pf = PolicyFile::new("roundtrip", "v2", "bob"); - pf.with_description("desc"); - pf.add_metadata("k", "v"); - pf.add_condition(PolicyCondition::IdentityConstraint { - allowed_identities: vec!["id1".into()], - allow_derived: true, - }); - pf.add_role(PolicyRole { - id: "admin".into(), - name: "Admin".into(), - permissions: vec!["write".into(), "read".into()], - }); - - let bytes = pf.to_bytes().unwrap(); - let restored = PolicyFile::from_bytes(&bytes).unwrap(); - assert_eq!(restored.name, "roundtrip"); - assert_eq!(restored.version, "v2"); - assert_eq!(restored.author, "bob"); - assert_eq!(restored.description.as_deref(), Some("desc")); - assert_eq!(restored.conditions.len(), 1); - assert_eq!(restored.roles.len(), 1); - assert_eq!(restored.roles[0].id, "admin"); } #[test] fn policy_file_canonical_bytes_from_canonical_bytes_roundtrip() { let mut pf = PolicyFile::new("name", "v1", "carol"); - pf.add_condition(PolicyCondition::EmissionsSchedule { - total_supply: 1_000_000, - shard_depth: 4, - schedule_steps: 10, - initial_step_emissions: 500, - initial_step_amount: 100, + pf.add_condition(PolicyCondition::SupplyCap { + max_supply: 1_000_000, + }); + pf.add_condition(PolicyCondition::OperationRestriction { + allowed_operations: vec!["transfer".into()], }); let canonical = pf.canonical_bytes().unwrap(); let restored = PolicyFile::from_canonical_bytes(&canonical).unwrap(); assert_eq!(restored.author, "carol"); - assert_eq!(restored.conditions.len(), 1); + assert_eq!(restored.conditions, pf.conditions); assert!(restored.name.is_empty(), "name excluded from canonical"); } /// A committed policy carrying a vault condition (the reserved /// `vault_enforcement`, field 2) names a fact no verifier derives: it does - /// not decode, so no token under it can be adopted or evaluated. A policy - /// of evaluable conditions still decodes. + /// not decode, so no token under it can be adopted or evaluated. The same + /// for the condition kinds the policy grammar (SoFi §47–§54) does not + /// name — an identity allowlist over caller-stated strings (field 1), an + /// emission schedule (5), a credit bundle (6), a custom constraint (7) — + /// which are reserved. A policy of evaluable conditions still decodes. #[test] - fn a_policy_carrying_a_vault_condition_does_not_decode() { - // CanonicalPolicy { author: "a", conditions: [ { 2: { minimum_balance: 100 } } ] } - let vault_condition = [0x12, 0x02, 0x10, 0x64]; - let mut bytes = vec![0x0A, 0x01, b'a', 0x12, vault_condition.len() as u8]; - bytes.extend_from_slice(&vault_condition); - assert!(PolicyFile::from_canonical_bytes(&bytes).is_err()); + fn a_policy_carrying_a_condition_the_grammar_does_not_name_does_not_decode() { + // CanonicalPolicy { author: "a", conditions: [ { : { 1: 100 } } ] } + for field in [1u8, 2, 5, 6, 7] { + let condition = [(field << 3) | 2, 0x02, 0x08, 0x64]; + let mut bytes = vec![0x0A, 0x01, b'a', 0x12, condition.len() as u8]; + bytes.extend_from_slice(&condition); + assert!( + PolicyFile::from_canonical_bytes(&bytes).is_err(), + "condition kind {field} decoded" + ); + } let mut evaluable = PolicyFile::new("n", "v", "a"); evaluable.add_condition(PolicyCondition::OperationRestriction { @@ -753,67 +467,4 @@ mod tests { let canonical = evaluable.canonical_bytes().expect("canonical"); assert!(PolicyFile::from_canonical_bytes(&canonical).is_ok()); } - - #[test] - fn different_authors_produce_different_anchors() { - let p1 = PolicyFile::new("n", "v", "alice"); - let p2 = PolicyFile::new("n", "v", "bob"); - let a1 = p1.generate_anchor().unwrap(); - let a2 = p2.generate_anchor().unwrap(); - assert_ne!(a1.0, a2.0); - } - - // ──────────────────────────────────────────────────────────────────────── - // Issue #183 Finding 1 regression — Custom policy anchor determinism. - // - // Repeatedly building the same logical Custom policy must produce - // byte-identical `canonical_bytes()` output. The previous code carried - // `parameters: HashMap` into the canonical proto, and - // HashMap iteration order is non-deterministic, so anchors drifted across - // runs. The fix zeroes `parameters` in the canonical projection and keeps - // only the sorted `parameters_kv` Vec. - // ──────────────────────────────────────────────────────────────────────── - - fn make_custom_policy(author: &str) -> PolicyFile { - let mut params = std::collections::HashMap::new(); - // Insert many keys in varied order so HashMap's pseudo-random - // iteration would naturally permute them. If `canonical_bytes` - // depended on iteration order, the digest would differ between - // constructions of the same logical policy. - for k in &["zeta", "alpha", "mu", "beta", "iota", "kappa", "delta"] { - params.insert(k.to_string(), format!("v_{k}")); - } - let mut pf = PolicyFile::new("cust-policy", "v1", author); - pf.add_condition(PolicyCondition::Custom { - constraint_type: "test-constraint".into(), - parameters: params, - }); - pf - } - - #[test] - fn custom_policy_canonical_bytes_are_deterministic_across_constructions() { - let p1 = make_custom_policy("alice"); - let p2 = make_custom_policy("alice"); - let b1 = p1.canonical_bytes().expect("canonical bytes p1"); - let b2 = p2.canonical_bytes().expect("canonical bytes p2"); - assert_eq!( - b1, b2, - "Custom policy canonical_bytes must be byte-identical across constructions" - ); - } - - #[test] - fn custom_policy_anchor_is_deterministic_across_constructions() { - // Repeat a few times — non-deterministic HashMap ordering would - // typically reveal itself within a handful of attempts. - let baseline = make_custom_policy("alice").generate_anchor().unwrap(); - for _ in 0..16 { - let again = make_custom_policy("alice").generate_anchor().unwrap(); - assert_eq!( - baseline.0, again.0, - "Custom policy anchor drifted across construction" - ); - } - } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs index b90238dc6..8530de8c4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs @@ -212,63 +212,6 @@ pub(crate) async fn try_fetch_policy_from_network( .map_err(|e| e.to_string()) } -/// Build the enforcer's `PolicyFile` from a parsed policy. -/// -/// SOLE constructor. It is a pure function of the parsed (and therefore of the -/// anchored) policy, so every device that fetches the same policy bytes -/// reconstructs a byte-identical `PolicyFile`. Creation and restart -/// rehydration both call this — there is no second place that decides what a -/// token's policy means. -pub(crate) fn derive_policy_file( - ticker: &str, - parsed: &ParsedTokenPolicy, -) -> dsm::types::policy_types::PolicyFile { - use dsm::types::policy_types::PolicyCondition; - - // Semantic version — the validator rejects a bare "1". - let mut pf = dsm::types::policy_types::PolicyFile::new(ticker, "1.0.0", "dsm_token_route"); - if let Some(desc) = parsed.description.as_ref() { - pf.description = Some(desc.clone()); - } - - // CONDITIONS, not metadata. `PolicyFile::metadata` is documented as - // "UI/ops only" and is EXCLUDED from `canonical_bytes` — anything put - // there is neither committed in the anchor nor read by the enforcer, which - // is why the previous transferable/allowed_operations metadata was inert. - // Conditions are both committed and evaluated. - // The whole supply exists from creation (SoFi §51): no unit is issued - // after it. - pf.add_condition(PolicyCondition::SupplyCap { - max_supply: parsed.genesis_supply, - }); - // What each flag governs (SoFi §49, §54): `transferable` every transfer - // (vault creation and SoFi legs are refused in Core, at genesis - // acceptance and route validation), `burn_enabled` burns only. Creation - // is always the creator's own (Amendment S8, checked at the genesis - // release). The signer set the blob carries authorizes only what the - // policy's own rules name, and the standard release rule names none - // (§47), so no condition is built from it. - pf.add_condition(PolicyCondition::OperationRestriction { - allowed_operations: permitted_operations(parsed.transferable, parsed.burn_enabled), - }); - - pf.add_metadata("created_by", "dsm_token_route") - .add_metadata("token_name", ticker); - pf -} - -/// The operations a token's policy permits, from its two flags. -pub(crate) fn permitted_operations(transferable: bool, burn_enabled: bool) -> Vec { - let mut ops = vec!["create_token".to_string()]; - if transferable { - ops.extend(["transfer", "lock", "unlock"].map(String::from)); - } - if burn_enabled { - ops.push("burn".to_string()); - } - ops -} - /// Tell the WebView its token set changed. /// /// Emitted from Rust beside the registry write, because the write is what made @@ -393,42 +336,18 @@ impl AppRouterImpl { /// that — any row added later, or any warm-up that skipped a row, /// reproduces it exactly. So the miss itself consults durable storage. /// - /// Resolution uses the SAME pieces as creation and adoption: - /// `load_policy_verified` (which re-derives BLAKE3(TAG_DSM_POLICY, bytes) - /// and treats a mismatch as absent), the one strict `parse_token_policy`, - /// and the one `derive_policy_file` constructor. There is no second parser - /// and no second notion of what a policy is. + /// The resolver answers the durable `TokenPolicyV3` bytes recorded at a + /// commitment (`load_policy_verified`, which re-derives + /// BLAKE3(TAG_DSM_POLICY, bytes) and treats a mismatch as absent); Core + /// re-derives the commitment again and reads the bytes with its one + /// parser. There is no second parser and no second notion of what a + /// policy is. pub fn install_policy_resolver(&self) { self.core_sdk.set_policy_resolver(std::sync::Arc::new( - |identifier: &str| -> Option<( - dsm::types::policy_types::PolicyFile, - dsm::types::policy_types::PolicyAnchor, - )> { - // Accept the canonical id or a registered ticker, same as the - // resolver the send path uses. - let row = crate::storage::client_db::token_registry::get_token(identifier) + |commit: &[u8; 32]| -> Option> { + crate::storage::client_db::token_registry::load_policy_verified(commit) .ok() .flatten() - .or_else(|| { - crate::storage::client_db::token_registry::get_token_by_ticker(identifier) - .ok() - .flatten() - })?; - - // Anchor equality is enforced inside load_policy_verified: a - // row whose bytes do not hash to their recorded commitment is - // reported ABSENT rather than returned. - let raw = crate::storage::client_db::token_registry::load_policy_verified( - &row.policy_commit, - ) - .ok() - .flatten()?; - - let parsed = parse_token_policy(&raw)?; - Some(( - derive_policy_file(&row.ticker, &parsed), - dsm::types::policy_types::PolicyAnchor::from_bytes(row.policy_commit), - )) }, )); } @@ -522,22 +441,28 @@ impl AppRouterImpl { continue; }; - { - let mut cache = self.policy_cache.lock().await; - cache.insert(row.policy_commit, raw_proto); + match self.core_sdk.register_policy_bytes(&raw_proto) { + Ok(commit) if commit == row.policy_commit => {} + Ok(..) => { + log::warn!( + "[token] registry rehydrate: the recorded bytes for {} are a policy at \ + another commitment; it stays unusable", + row.token_id + ); + continue; + } + Err(e) => { + log::warn!( + "[token] registry rehydrate: register failed for {}: {e}", + row.token_id + ); + continue; + } } - let policy_file = derive_policy_file(&row.ticker, &parsed); - if let Err(e) = self - .core_sdk - .register_token_policy_with_anchor(&row.token_id, policy_file, row.policy_commit) - .await { - log::warn!( - "[token] registry rehydrate: register failed for {}: {e}", - row.token_id - ); - continue; + let mut cache = self.policy_cache.lock().await; + cache.insert(row.policy_commit, raw_proto); } // Re-seed the metadata cache so strict policy-commit resolution @@ -1106,11 +1031,16 @@ impl AppRouterImpl { ); }; - // The anchor is the content hash of those exact bytes. - let policy_anchor: [u8; 32] = dsm::crypto::blake3::domain_hash_bytes( - dsm::common::domain_tags::TAG_DSM_POLICY, - &raw_proto, - ); + // The anchor is the content hash of those exact bytes, as + // Core computes it when it registers the policy with the + // enforcer from them. + let policy_anchor: [u8; 32] = match self.core_sdk.register_policy_bytes(&raw_proto) + { + Ok(commit) => commit, + Err(e) => { + return err(format!("token.create: register_token_policy failed: {e}")) + } + }; // A new token may NEVER be issued under an existing asset's // policy commit. The anchor becomes the `policy_commit` on the @@ -1250,19 +1180,6 @@ impl AppRouterImpl { fields, }; - // Single source of truth for what the policy means — the - // same function restart rehydration uses. - let policy_file = derive_policy_file(&ticker, &parsed); - - // Register policy mapping under the derived anchor so - // token_id -> policy_commit stays stable. - if let Err(e) = self - .core_sdk - .register_token_policy_with_anchor(&token_id, policy_file, policy_anchor) - .await - { - return err(format!("token.create: register_token_policy failed: {e}")); - } let policy_commit: [u8; 32] = policy_anchor; // Cache authoritative TokenMetadata (no Generic shim op). @@ -1750,53 +1667,6 @@ mod tests { } } - /// SoFi §49, §54: a token's operation restriction is exactly its two - /// flags — transfers (and the lock operation types) when transferable, - /// burns when burn-enabled, creation always — and the signer set builds - /// no condition, because the standard release rule names it for nothing - /// (§47). - #[test] - fn the_policy_permits_exactly_what_its_flags_name() { - use dsm::types::policy_types::PolicyCondition; - for (transferable, burn_enabled) in - [(true, true), (true, false), (false, true), (false, false)] - { - let parsed = ParsedTokenPolicy { - transferable, - burn_enabled, - ..fungible_fixture() - }; - let pf = derive_policy_file("T", &parsed); - let restrictions: Vec<&Vec> = pf - .conditions - .iter() - .filter_map(|c| match c { - PolicyCondition::OperationRestriction { allowed_operations } => { - Some(allowed_operations) - } - _ => None, - }) - .collect(); - assert_eq!( - restrictions.len(), - 1, - "one restriction ({transferable}, {burn_enabled})" - ); - let ops = restrictions[0]; - let has = |op: &str| ops.iter().any(|o| o == op); - assert!(has("create_token")); - assert_eq!(has("transfer"), transferable); - assert_eq!(has("lock"), transferable); - assert_eq!(has("unlock"), transferable); - assert_eq!(has("burn"), burn_enabled); - assert_eq!( - pf.conditions.len(), - 2, - "the supply cap and the restriction, and nothing built from the signer set" - ); - } - } - // ── The one packer against Core's one parser ───────────────────── #[test] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs index 42ca43186..183148a94 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs @@ -744,9 +744,12 @@ impl CoreSDK { "Initializing CoreSDK (strict/proto-only/clockless) for device {}", crate::util::text_id::encode_base32_crockford(&device_info.device_id) ); + // Policies are registered from their committed bytes as tokens are + // created and adopted (`register_policy_bytes`) and rehydrated from + // the durable store on a miss. ERA has none: its `policy_commit` + // constant has no preimage, so no ERA transfer or burn passes + // enforcement until ERA's policy blob exists. let policy_system = TokenPolicySystem::new(); - // Preload standard token policies (ERA) synchronously - policy_system.preload_standard_policies_blocking()?; let state_machine = Mutex::new(StateMachine::new()); @@ -945,25 +948,6 @@ impl CoreSDK { Ok(state) } - /// Register a CPTA policy for a custom token with the underlying - /// `TokenPolicySystem`. This is the authoritative step that makes the - /// policy visible to `PolicyEnforcer` and binds `policy_commit = - /// PolicyAnchor::from_policy(&policy_file)` for all subsequent balance - /// ops on `token_id`. - /// - /// Must run before any balance-changing op references `token_id`. - pub async fn register_token_policy( - &self, - token_id: &str, - policy_file: dsm::types::policy_types::PolicyFile, - ) -> Result { - self.policy_system - .register_token_policy(token_id, policy_file) - .await - } - - /// Register policy bytes while preserving an externally-authoritative - /// policy anchor (for example, a storage-layer `DSM/policy` commitment). /// Read access to the policy system, for tests that need to ask the /// enforcer directly whether it can see a token's policy. pub fn policy_system_ref(&self) -> &dsm::core::token::policy::TokenPolicySystem { @@ -978,200 +962,68 @@ impl CoreSDK { self.policy_system.set_policy_resolver(resolver); } - pub async fn register_token_policy_with_anchor( - &self, - token_id: &str, - policy_file: dsm::types::policy_types::PolicyFile, - anchor: [u8; 32], - ) -> Result<(), DsmError> { - self.policy_system - .register_token_policy_with_anchor( - token_id, - policy_file, - dsm::types::policy_types::PolicyAnchor::from_bytes(anchor), - ) - .await - } - - fn canonical_token_id_str(token_id: &[u8]) -> Option<&str> { - std::str::from_utf8(token_id) - .ok() - .map(str::trim) - .filter(|s| !s.is_empty()) + /// Register the policy these exact `TokenPolicyV3` bytes are with the + /// enforcer, and answer its commitment: Core recomputes the commitment + /// from the bytes and derives the enforcer's view from what the blob + /// says. Must run before any balance-changing operation names the + /// commitment. + pub fn register_policy_bytes(&self, bytes: &[u8]) -> Result<[u8; 32], DsmError> { + self.policy_system.register_policy(bytes) } - /// What the supply cap is evaluated against: the asset and the amount - /// the operation names. The circulating supply is derived where the chain - /// is reachable (`enforce_policy_for_operation`), never here. - fn insert_supply_witness( - context: &mut HashMap>, - policy_commit: &[u8; 32], - amount: u64, - ) { + /// What the supply cap is evaluated against: the amount the operation + /// names. The circulating supply is derived where the chain is reachable + /// (`enforce_policy_for_operation`), never here. + fn insert_supply_witness(context: &mut HashMap>, amount: u64) { use dsm::core::token::policy::policy_enforcement::witness_keys; - context.insert( - witness_keys::POLICY_COMMIT.to_string(), - policy_commit.to_vec(), - ); context.insert( witness_keys::AMOUNT.to_string(), amount.to_le_bytes().to_vec(), ); } + /// What the enforcer evaluates for `operation`: the commitment of the + /// policy the operation names, the operation type, and what the supply + /// cap is evaluated against. `None` for an operation no token policy + /// gates. A token operation that names no commitment cannot be evaluated + /// against a committed policy and is refused (§9.1: every token + /// operation carries its `policy_commit`). fn build_token_policy_context( operation: &dsm::types::operations::Operation, - state_hash: [u8; 32], - ) -> Result>)>, DsmError> { + ) -> Result>)>, DsmError> { let mut context = HashMap::new(); - context.insert("state_hash".to_string(), state_hash.to_vec()); - match operation { - DsmOperation::Transfer { - token_id, - amount, - recipient, - .. - } => { - let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { - DsmError::invalid_operation( - "Policy enforcement rejected: malformed or empty token_id", - ) - })?; - let amount_u64 = amount.value(); - context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); - context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); - context.insert("recipient".to_string(), recipient.clone()); - Ok(Some(( - token_id.to_string(), - "transfer".to_string(), - context, - ))) + DsmOperation::Transfer { policy_commit, .. } => { + Ok(Some((*policy_commit, "transfer".to_string(), context))) } DsmOperation::Burn { - token_id, amount, policy_commit, .. } => { - let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { - DsmError::invalid_operation( - "Policy enforcement rejected: malformed or empty token_id", - ) - })?; - let amount_u64 = amount.value(); - context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); - context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); // Whether the holder may burn is the policy's burn flag, // expressed as its operation restriction (SoFi §54). - Self::insert_supply_witness(&mut context, policy_commit, amount_u64); - Ok(Some((token_id.to_string(), "burn".to_string(), context))) + Self::insert_supply_witness(&mut context, amount.value()); + Ok(Some((*policy_commit, "burn".to_string(), context))) } - // Creation is gated by the token's own policy: the supply cap and // the operation restriction; who may create is the creator the // policy names (SoFi Amendment S8), checked at the genesis release. DsmOperation::CreateToken { - token_id, initial_supply, policy_commit, .. } => { - let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { - DsmError::invalid_operation( - "Policy enforcement rejected: malformed or empty token_id", - ) - })?; - let amount_u64 = initial_supply.value(); - context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); - context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); - Self::insert_supply_witness(&mut context, policy_commit, amount_u64); - Ok(Some(( - token_id.to_string(), - "create_token".to_string(), - context, - ))) - } - DsmOperation::Lock { - token_id, - amount, - purpose, - owner, - .. - } => { - let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { - DsmError::invalid_operation( - "Policy enforcement rejected: malformed or empty token_id", - ) - })?; - let amount_u64 = amount.value(); - context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); - context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); - context.insert("purpose".to_string(), purpose.clone()); - context.insert("owner".to_string(), owner.clone()); - Ok(Some((token_id.to_string(), "lock".to_string(), context))) - } - DsmOperation::Unlock { - token_id, - amount, - purpose, - owner, - .. - } => { - let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { - DsmError::invalid_operation( - "Policy enforcement rejected: malformed or empty token_id", - ) - })?; - let amount_u64 = amount.value(); - context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); - context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); - context.insert("purpose".to_string(), purpose.clone()); - context.insert("owner".to_string(), owner.clone()); - Ok(Some((token_id.to_string(), "unlock".to_string(), context))) - } - DsmOperation::LockToken { - token_id, - amount, - purpose, - .. - } => { - let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { - DsmError::invalid_operation( - "Policy enforcement rejected: malformed or empty token_id", - ) - })?; - let amount_u64 = u64::try_from(*amount).map_err(|_| { - DsmError::invalid_operation( - "Policy enforcement rejected: LockToken amount must be non-negative", - ) - })?; - context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); - context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); - context.insert("purpose".to_string(), purpose.clone()); - Ok(Some((token_id.to_string(), "lock".to_string(), context))) - } - DsmOperation::UnlockToken { - token_id, - amount, - purpose, - .. - } => { - let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { - DsmError::invalid_operation( - "Policy enforcement rejected: malformed or empty token_id", - ) - })?; - let amount_u64 = u64::try_from(*amount).map_err(|_| { - DsmError::invalid_operation( - "Policy enforcement rejected: UnlockToken amount must be non-negative", - ) - })?; - context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); - context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); - context.insert("purpose".to_string(), purpose.clone()); - Ok(Some((token_id.to_string(), "unlock".to_string(), context))) + Self::insert_supply_witness(&mut context, initial_supply.value()); + Ok(Some((*policy_commit, "create_token".to_string(), context))) } + DsmOperation::Lock { .. } + | DsmOperation::Unlock { .. } + | DsmOperation::LockToken { .. } + | DsmOperation::UnlockToken { .. } => Err(DsmError::invalid_operation( + "Policy enforcement rejected: a lock operation names no policy commitment, so \ + no committed policy can be evaluated for it (§9.1)", + )), _ => Ok(None), } } @@ -1220,39 +1072,33 @@ impl CoreSDK { Some(u64::try_from(circulating).unwrap_or(u64::MAX)) } + /// Whether the policy committed at the commitment `operation` names + /// permits it. A creation's supply cap is evaluated against the + /// circulating supply derived HERE, where the chain is reachable, never + /// in the pure context builder. fn enforce_policy_for_operation( &self, operation: &dsm::types::operations::Operation, - state_hash: [u8; 32], ) -> Result<(), DsmError> { - let Some((token_id, op_type, mut context)) = - Self::build_token_policy_context(operation, state_hash)? + use dsm::core::token::policy::policy_enforcement::witness_keys; + let Some((policy_commit, op_type, mut context)) = + Self::build_token_policy_context(operation)? else { return Ok(()); }; - - // The supply cap is evaluated against canonical history, so the - // derivation happens HERE (where the chain is reachable) rather than - // in the pure context builder. - { - use dsm::core::token::policy::policy_enforcement::witness_keys; - if let Some(pc) = context.get(witness_keys::POLICY_COMMIT).cloned() { - if let Ok(commit) = <[u8; 32]>::try_from(pc.as_slice()) { - // Absent, not zero, when the history is incomplete — the - // enforcer refuses a capped creation it cannot evaluate. - if let Some(circulating) = self.derive_circulating_supply(&commit) { - context.insert( - witness_keys::CIRCULATING.to_string(), - circulating.to_le_bytes().to_vec(), - ); - } - } + if context.contains_key(witness_keys::AMOUNT) { + // Absent, not zero, when the history is incomplete — the + // enforcer refuses a capped creation it cannot evaluate. + if let Some(circulating) = self.derive_circulating_supply(&policy_commit) { + context.insert( + witness_keys::CIRCULATING.to_string(), + circulating.to_le_bytes().to_vec(), + ); } } let result = if tokio::runtime::Handle::try_current().is_ok() { let policy_system = self.policy_system.clone(); - let token_id_for_thread = token_id.clone(); let op_type_for_thread = op_type.clone(); let context_for_thread = context.clone(); let join_res = std::thread::spawn(move || { @@ -1267,11 +1113,7 @@ impl CoreSDK { })?; rt.block_on(async { policy_system - .enforce_policy( - &token_id_for_thread, - &op_type_for_thread, - &context_for_thread, - ) + .enforce_policy(&policy_commit, &op_type_for_thread, &context_for_thread) .await }) }) @@ -1299,14 +1141,14 @@ impl CoreSDK { rt.block_on(async { self.policy_system - .enforce_policy(&token_id, &op_type, &context) + .enforce_policy(&policy_commit, &op_type, &context) .await })? }; if !result.allowed { return Err(DsmError::policy_violation( - token_id, + crate::util::text_id::encode_base32_crockford(&policy_commit), result.reason, None::, )); @@ -1601,14 +1443,14 @@ impl CoreSDK { } } - // Enforce token policy constraints on the operation that will advance - // state. This closes the previous gap where registration existed but - // execution path skipped policy checks. - let current_state_hash = sm - .device_head() - .map(|ds| ds.root()) - .ok_or_else(|| DsmError::state_machine("no device head to execute an operation on"))?; - self.enforce_policy_for_operation(&operation, current_state_hash)?; + // Enforce the token's committed policy on the operation that will + // advance state, under the lock: registration alone gates nothing. + if sm.device_head().is_none() { + return Err(DsmError::state_machine( + "no device head to execute an operation on", + )); + } + self.enforce_policy_for_operation(&operation)?; // ── Admission serialization, UNDER the state-machine lock ────────── // A new admission atomically refuses an existing pending one and // CAS-checks the admitted predecessor it extends. A route-level diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index 195799f12..d4ca82b08 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -22564,58 +22564,11 @@ export class StorageMemberNoCycle extends Message { /** * ============================ POLICY FILE (Canonical) ============================ + * The enforcer's view of a committed token policy: the operations its flags + * permit and the supply it was created with (SoFi §47–§54), derived from the + * committed TokenPolicyV3 bytes. A policy's identity is the commitment of + * those bytes, never of this projection. * - * @generated from message dsm.PolicyRoleProto - */ -export class PolicyRoleProto extends Message { - /** - * @generated from field: string id = 1; - */ - id = ""; - - /** - * @generated from field: string name = 2; - */ - name = ""; - - /** - * sorted - * - * @generated from field: repeated string permissions = 3; - */ - permissions: string[] = []; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.PolicyRoleProto"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "permissions", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): PolicyRoleProto { - return new PolicyRoleProto().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): PolicyRoleProto { - return new PolicyRoleProto().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): PolicyRoleProto { - return new PolicyRoleProto().fromJsonString(jsonString, options); - } - - static equals(a: PolicyRoleProto | PlainMessage | undefined, b: PolicyRoleProto | PlainMessage | undefined): boolean { - return proto3.util.equals(PolicyRoleProto, a, b); - } -} - -/** * @generated from message dsm.PolicyConditionProto */ export class PolicyConditionProto extends Message { @@ -22623,35 +22576,11 @@ export class PolicyConditionProto extends Message { * @generated from oneof dsm.PolicyConditionProto.kind */ kind: { - /** - * @generated from field: dsm.IdentityConstraintProto identity_constraint = 1; - */ - value: IdentityConstraintProto; - case: "identityConstraint"; - } | { /** * @generated from field: dsm.OperationRestrictionProto operation_restriction = 3; */ value: OperationRestrictionProto; case: "operationRestriction"; - } | { - /** - * @generated from field: dsm.EmissionsScheduleProto emissions_schedule = 5; - */ - value: EmissionsScheduleProto; - case: "emissionsSchedule"; - } | { - /** - * @generated from field: dsm.CreditBundlePolicyProto credit_bundle_policy = 6; - */ - value: CreditBundlePolicyProto; - case: "creditBundlePolicy"; - } | { - /** - * @generated from field: dsm.CustomConstraintProto custom = 7; - */ - value: CustomConstraintProto; - case: "custom"; } | { /** * @generated from field: dsm.BitcoinTapConstraintProto bitcoin_tap_constraint = 8; @@ -22678,11 +22607,7 @@ export class PolicyConditionProto extends Message { static readonly runtime: typeof proto3 = proto3; static readonly typeName = "dsm.PolicyConditionProto"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "identity_constraint", kind: "message", T: IdentityConstraintProto, oneof: "kind" }, { no: 3, name: "operation_restriction", kind: "message", T: OperationRestrictionProto, oneof: "kind" }, - { no: 5, name: "emissions_schedule", kind: "message", T: EmissionsScheduleProto, oneof: "kind" }, - { no: 6, name: "credit_bundle_policy", kind: "message", T: CreditBundlePolicyProto, oneof: "kind" }, - { no: 7, name: "custom", kind: "message", T: CustomConstraintProto, oneof: "kind" }, { no: 8, name: "bitcoin_tap_constraint", kind: "message", T: BitcoinTapConstraintProto, oneof: "kind" }, { no: 10, name: "supply_cap", kind: "message", T: SupplyCapProto, oneof: "kind" }, ]); @@ -22744,49 +22669,6 @@ export class SupplyCapProto extends Message { } } -/** - * @generated from message dsm.IdentityConstraintProto - */ -export class IdentityConstraintProto extends Message { - /** - * @generated from field: repeated string allowed_identities = 1; - */ - allowedIdentities: string[] = []; - - /** - * @generated from field: bool allow_derived = 2; - */ - allowDerived = false; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.IdentityConstraintProto"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "allowed_identities", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - { no: 2, name: "allow_derived", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): IdentityConstraintProto { - return new IdentityConstraintProto().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): IdentityConstraintProto { - return new IdentityConstraintProto().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): IdentityConstraintProto { - return new IdentityConstraintProto().fromJsonString(jsonString, options); - } - - static equals(a: IdentityConstraintProto | PlainMessage | undefined, b: IdentityConstraintProto | PlainMessage | undefined): boolean { - return proto3.util.equals(IdentityConstraintProto, a, b); - } -} - /** * @generated from message dsm.OperationRestrictionProto */ @@ -22824,116 +22706,6 @@ export class OperationRestrictionProto extends Message { - /** - * @generated from field: uint64 total_supply = 1; - */ - totalSupply = protoInt64.zero; - - /** - * @generated from field: uint32 shard_depth = 2; - */ - shardDepth = 0; - - /** - * @generated from field: uint32 schedule_steps = 3; - */ - scheduleSteps = 0; - - /** - * @generated from field: uint64 initial_step_emissions = 4; - */ - initialStepEmissions = protoInt64.zero; - - /** - * @generated from field: uint64 initial_step_amount = 5; - */ - initialStepAmount = protoInt64.zero; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.EmissionsScheduleProto"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "total_supply", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, - { no: 2, name: "shard_depth", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, - { no: 3, name: "schedule_steps", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, - { no: 4, name: "initial_step_emissions", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, - { no: 5, name: "initial_step_amount", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): EmissionsScheduleProto { - return new EmissionsScheduleProto().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): EmissionsScheduleProto { - return new EmissionsScheduleProto().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): EmissionsScheduleProto { - return new EmissionsScheduleProto().fromJsonString(jsonString, options); - } - - static equals(a: EmissionsScheduleProto | PlainMessage | undefined, b: EmissionsScheduleProto | PlainMessage | undefined): boolean { - return proto3.util.equals(EmissionsScheduleProto, a, b); - } -} - -/** - * @generated from message dsm.CreditBundlePolicyProto - */ -export class CreditBundlePolicyProto extends Message { - /** - * @generated from field: uint64 bundle_size = 1; - */ - bundleSize = protoInt64.zero; - - /** - * @generated from field: string debit_rule = 2; - */ - debitRule = ""; - - /** - * @generated from field: string refill_rule = 3; - */ - refillRule = ""; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.CreditBundlePolicyProto"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "bundle_size", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, - { no: 2, name: "debit_rule", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "refill_rule", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): CreditBundlePolicyProto { - return new CreditBundlePolicyProto().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): CreditBundlePolicyProto { - return new CreditBundlePolicyProto().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): CreditBundlePolicyProto { - return new CreditBundlePolicyProto().fromJsonString(jsonString, options); - } - - static equals(a: CreditBundlePolicyProto | PlainMessage | undefined, b: CreditBundlePolicyProto | PlainMessage | undefined): boolean { - return proto3.util.equals(CreditBundlePolicyProto, a, b); - } -} - /** * Bitcoin tap safety constraints (dBTC §12). Protocol law — frozen into policy_commit. * @@ -22999,62 +22771,6 @@ export class BitcoinTapConstraintProto extends Message { - /** - * @generated from field: string constraint_type = 1; - */ - constraintType = ""; - - /** - * UI/interop only (non-deterministic ordering). Do not hash. - * - * @generated from field: map parameters = 2; - */ - parameters: { [key: string]: string } = {}; - - /** - * Canonical: sorted by key before hashing/signing. - * - * @generated from field: repeated dsm.ParamKV parameters_kv = 3; - */ - parametersKv: ParamKV[] = []; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.CustomConstraintProto"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "constraint_type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "parameters", kind: "map", K: 9 /* ScalarType.STRING */, V: {kind: "scalar", T: 9 /* ScalarType.STRING */} }, - { no: 3, name: "parameters_kv", kind: "message", T: ParamKV, repeated: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): CustomConstraintProto { - return new CustomConstraintProto().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): CustomConstraintProto { - return new CustomConstraintProto().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): CustomConstraintProto { - return new CustomConstraintProto().fromJsonString(jsonString, options); - } - - static equals(a: CustomConstraintProto | PlainMessage | undefined, b: CustomConstraintProto | PlainMessage | undefined): boolean { - return proto3.util.equals(CustomConstraintProto, a, b); - } -} - /** * @generated from message dsm.CanonicalPolicy */ @@ -23069,11 +22785,6 @@ export class CanonicalPolicy extends Message { */ conditions: PolicyConditionProto[] = []; - /** - * @generated from field: repeated dsm.PolicyRoleProto roles = 3; - */ - roles: PolicyRoleProto[] = []; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -23084,7 +22795,6 @@ export class CanonicalPolicy extends Message { static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "author", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 2, name: "conditions", kind: "message", T: PolicyConditionProto, repeated: true }, - { no: 3, name: "roles", kind: "message", T: PolicyRoleProto, repeated: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): CanonicalPolicy { @@ -23104,79 +22814,6 @@ export class CanonicalPolicy extends Message { } } -/** - * @generated from message dsm.StoredPolicy - */ -export class StoredPolicy extends Message { - /** - * @generated from field: string name = 1; - */ - name = ""; - - /** - * @generated from field: string revision = 2; - */ - revision = ""; - - /** - * @generated from field: string author = 4; - */ - author = ""; - - /** - * @generated from field: string description = 5; - */ - description = ""; - - /** - * @generated from field: repeated dsm.PolicyConditionProto conditions = 6; - */ - conditions: PolicyConditionProto[] = []; - - /** - * @generated from field: repeated dsm.PolicyRoleProto roles = 7; - */ - roles: PolicyRoleProto[] = []; - - /** - * @generated from field: map metadata = 8; - */ - metadata: { [key: string]: string } = {}; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.StoredPolicy"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "revision", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "author", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 5, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 6, name: "conditions", kind: "message", T: PolicyConditionProto, repeated: true }, - { no: 7, name: "roles", kind: "message", T: PolicyRoleProto, repeated: true }, - { no: 8, name: "metadata", kind: "map", K: 9 /* ScalarType.STRING */, V: {kind: "scalar", T: 9 /* ScalarType.STRING */} }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): StoredPolicy { - return new StoredPolicy().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): StoredPolicy { - return new StoredPolicy().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): StoredPolicy { - return new StoredPolicy().fromJsonString(jsonString, options); - } - - static equals(a: StoredPolicy | PlainMessage | undefined, b: StoredPolicy | PlainMessage | undefined): boolean { - return proto3.util.equals(StoredPolicy, a, b); - } -} - /** * @generated from message dsm.BridgeRpcRequest */ diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index e27f1fb68..29a6433df 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -3586,19 +3586,13 @@ message StorageMemberByteCommit { message StorageMemberNoCycle {} // ============================ POLICY FILE (Canonical) ============================ -message PolicyRoleProto { - string id = 1; - string name = 2; - repeated string permissions = 3; // sorted -} - +// The enforcer's view of a committed token policy: the operations its flags +// permit and the supply it was created with (SoFi §47–§54), derived from the +// committed TokenPolicyV3 bytes. A policy's identity is the commitment of +// those bytes, never of this projection. message PolicyConditionProto { oneof kind { - IdentityConstraintProto identity_constraint = 1; OperationRestrictionProto operation_restriction = 3; - EmissionsScheduleProto emissions_schedule = 5; - CreditBundlePolicyProto credit_bundle_policy = 6; - CustomConstraintProto custom = 7; BitcoinTapConstraintProto bitcoin_tap_constraint = 8; // 9 was a signer-set authority over burn and creation: the signer set // authorizes only what the policy's own rules name, and none names it @@ -3614,6 +3608,13 @@ message PolicyConditionProto { // verifier derives the vault facts it names, so it cannot be checked. reserved 2; reserved "vault_enforcement"; + // Condition kinds the policy grammar (SoFi §47–§54) does not name: an + // identity allowlist over caller-stated strings, an emission schedule and + // a credit bundle that were configuration the enforcer never evaluated, + // and a custom constraint keyed by a string. A committed policy states + // its recipient allowlist and its release rule in the TokenPolicyV3 blob. + reserved 1, 5, 6, 7; + reserved "identity_constraint", "emissions_schedule", "credit_bundle_policy", "custom"; } // The whole supply a token is created with. Nothing is minted after genesis @@ -3624,10 +3625,7 @@ message SupplyCapProto { reserved "unlimited"; } -message IdentityConstraintProto { repeated string allowed_identities = 1; bool allow_derived = 2; } message OperationRestrictionProto { repeated string allowed_operations = 1; } -message EmissionsScheduleProto { uint64 total_supply = 1; uint32 shard_depth = 2; uint32 schedule_steps = 3; uint64 initial_step_emissions = 4; uint64 initial_step_amount = 5; } -message CreditBundlePolicyProto { uint64 bundle_size = 1; string debit_rule = 2; string refill_rule = 3; } // Bitcoin tap safety constraints (dBTC §12). Protocol law — frozen into policy_commit. message BitcoinTapConstraintProto { @@ -3637,34 +3635,13 @@ message BitcoinTapConstraintProto { uint64 min_confirmations = 4; // Required block depth for entry/exit anchors (§12.1.3) } -// IMPORTANT: `parameters_kv` is the canonical/deterministic representation. -// `parameters` (map) is UI/interop only and MUST NOT be used in hashed preimages. -message CustomConstraintProto { - string constraint_type = 1; - - // UI/interop only (non-deterministic ordering). Do not hash. - map parameters = 2; - - // Canonical: sorted by key before hashing/signing. - repeated ParamKV parameters_kv = 3; -} - message CanonicalPolicy { string author = 1; repeated PolicyConditionProto conditions = 2; - repeated PolicyRoleProto roles = 3; -} - -message StoredPolicy { - string name = 1; - string revision = 2; + // 3 was role-based access control over caller-stated identities: no + // verifier derives an identity's roles, so it could not be evaluated. reserved 3; - reserved "created_tick"; - string author = 4; - string description = 5; - repeated PolicyConditionProto conditions = 6; - repeated PolicyRoleProto roles = 7; - map metadata = 8; + reserved "roles"; } // ====================== WebView Bridge Messages ====================== diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 357181b2b..e0ab93a79 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -208,7 +208,7 @@ The skeleton (step 6 of the working flow) removes what the specifications forbid | [x] | Every cell write and read (economic-root register, faucet reserve, SoFi cells) | The copy rule and the copy writers | Route-chain writes (`RouteSeats`), Core chain evaluation (`route_chain`) | `dsm_sdk::handlers::faucet_flow_tests::a_full_claim_credits_100_era_and_admits_position_1`; `dsm_sdk::handlers::faucet_flow_tests::a_release_cut_short_after_the_leader_is_carried_by_the_next_claimant_and_bricks_nothing`; `dsm_sdk::handlers::node_e2e_tests::a_sofi_trade_executes_end_to_end`; `dsm::route_chain::tests::only_links_of_one_chain_count_toward_final` | | [x] | SoFi resolution and conformance | The third predicate value; the in-hand check is written (sofi_resolve::refuted_in_hand runs before any read) | Binary predicates, acquisition-layer retries, in-hand checks before any read | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone`: on the nodes, a key held final by an exercise its own bytes refute is skipped, the nodes were asked for the two attempt keys and their ByteCommit material and nothing else, and the next trade takes the next key. `dsm_sdk::handlers::node_e2e_tests::a_trade_cut_short_by_a_refused_write_is_the_network_status_until_it_lands`: the position pair refused at its leader fails the trade on the network with the position fenced; the first leg refused at its leader is `RetriesExhausted` with nothing recorded; when the writes land the position realizes. `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do`. Found by the first test and fixed on the same branch: a trader's second trade against a vault could not resolve (§6.21). | | [x] | Token creation and burn | Mint, signer-authorized issuance | Release at creation (a new credit source replacing the authorized-issuance arm), burn | `dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator`; `dsm_sdk::handlers::sender_admission_tests::token_routes_admit_create_and_burn_end_to_end`; `dsm_sdk::handlers::sender_admission_tests::a_token_creation_is_foreign_walkable`; `dsm_sdk::handlers::sender_admission_tests::an_admitted_burn_advances_the_lineage_and_is_foreign_walkable` | -| [ ] | Transferable check | Not wired before | Token policies in validation evidence; the check at every transfer, vault creation and SoFi leg | Rewired for vaults and SoFi legs; a transfer is checked only on the sending device (derive_policy_file's OperationRestriction), the recipient never checks, and no end-to-end test sends a non-transferable token. | +| [ ] | Transferable check | Not wired before | Token policies in validation evidence; the check at every transfer, vault creation and SoFi leg | Rewired for vaults and SoFi legs; a transfer is checked only on the sending device (`enforced_policy`'s OperationRestriction, at the commitment the transfer names), the recipient never checks, and no end-to-end test sends a non-transferable token. | | [ ] | Frontend: accounts, token creation | Mint, the old policy fields | Burn only; genesis supply, burn flag, threshold | The dialog sends genesis supply, burn flag and threshold (frontend `TokenCreationDialog.test.tsx`); the mint literal went with the dead types of dsm/types.ts and the mint copy with the dev screen's example policy (§6.29). Unticked: this checklist's evidence is cargo tests, and no cargo test drives the frontend. | | [x] | Tests | Fixtures built on removed fields and routes | Rewritten against the new types and the real node | `dsm_sdk::test_support::two_device::tests::two_device_slots_keep_durable_state_isolated`; `dsm_sdk::handlers::recipient_admission_tests::an_outage_holds_the_transfer_cleanly_and_it_recovers`. Not checked: that every scenario of the 31 deleted dsm_sdk/tests files was ported. | | [ ] | SoFi routes (G1) | Nothing reached SoFi | `handlers/sofi_routes.rs` (eight routes, SoFi §27) calling `sdk/sofi_flow.rs` orchestration entries that run the §28–§33 stages; the frontend SoFi screens on those routes | Rewired; createVault, setup, trade and resolve run end to end. findRoute, the multi-hop route, close and relay have no test. | @@ -1085,16 +1085,40 @@ Owner clarification of 2026-09-23 (§6.21): a predicate has two values and the n | `dsm_sdk/src/sdk/sofi_flow.rs` · `create_vault`, `setup`; `economic_admission_flow.rs` · `stage_admission`, `BuiltOn` | `create_vault` computed `create_position` from the validated predecessor, published a genesis naming it, ran the admission and only THEN compared the admitted position with the one the genesis names — after the advance, which is irreversible. The two differ whenever the predecessor moves between the two reads, and `stage_admission` moves it on its way in when a pending admission is resumed: the creation was admitted at a position its genesis does not name, then refused. `setup` had the same two reads and no check at all. | `stage_admission` takes the predecessor the operation's object was built on (`BuiltOn`, position and root) and refuses before the advance unless the staged predecessor is exactly it; `create_vault` and `setup` pass theirs, `token.create` and `token.burn` name none. The post-advance comparison is gone; the created vault reports the admitted position. Test on the nodes: `dsm_sdk::handlers::sender_admission_tests::a_creation_built_on_a_predecessor_the_device_no_longer_stands_on_is_refused_before_its_advance` (a held token creation resumed by staging moves the predecessor from 1 to 2; the vault creation built on 1 is refused naming both, admitted stays 2 with nothing pending; built again it is admitted at 3, the position its genesis names). | | `dsm_sdk/src/sdk/sofi_flow.rs` · `find_route` | A vault whose head could not be established — the fleet not answering, the genesis scan incomplete, the owner's lineage unresolved, this device's record unable to reproduce the head — was logged and left out, and the search answered with whatever remained: an unreachable vault read as "no route", an empty list where the truth was an error. | A vault whose head cannot be established is an error: a search that could not see every vault this device is set up with has not searched. Over established heads, no route is still an empty list. Test on the nodes: `dsm_sdk::handlers::node_e2e_tests::a_route_search_that_cannot_see_a_vault_is_an_error_not_an_empty_route`. | +### 6.32 The ERA policy stand-in deleted, the hole left; the enforcer keyed by the commitment (placeholder sweep, `fix/era-policy-stand-in-deleted`, 2026-09-26) + +Owner decision (plan of 2026-09-25): delete the fake ERA policy and leave the hole. The last commit of the sweep, after the clean checkpoint (#1015's board): every test it turns red is named below, and from here a board is judged against that list — a red outside it is a regression, and a listed test that goes green without ERA's policy is a finding. + +**Finding.** `CoreSDK::new_with_device` preloaded an "ERA Token Policy" `PolicyFile` of three metadata strings, registered under the ticker `"ERA"` at an anchor that is the hash of that projection — not `ERA_POLICY_COMMIT`, which has no preimage: no `TokenPolicyV3` bytes hash to it, because ERA's release rule is its emission schedule (SoFi §51) and no such blob exists. The enforcer was keyed by the operation's `token_id` string; `register_token_policy_with_anchor` took a `PolicyFile` and an anchor from its caller and bound them on the caller's word; the validator checked semver strings over that projection; and four condition kinds the policy grammar (§47–§54) does not name — an identity allowlist over caller-stated strings, an emission schedule, a credit bundle, a custom constraint — plus role-based access control existed in the type, the enforcer and the proto, three of them "configuration-only" arms that allowed unconditionally. + +**Resolved** + +| Location | Finding | State | +|---|---|---| +| `dsm/src/core/token/policy/mod.rs` · `TokenPolicySystem` | The ERA stand-in (`preload_standard_policies`, `create_root_token_policy`); registration keyed by ticker, binding a caller-built `PolicyFile` to a caller-supplied anchor; a ticker-keyed `PolicyCommitResolver`. | Deleted. `register_policy(bytes)` recomputes the commitment from the `TokenPolicyV3` bytes, reads them with Core's one parser and derives the enforcer's view (`enforced_policy`, moved in from the SDK's `derive_policy_file`); `policy_at(commit)` takes the durable bytes the SDK resolver answers only when they re-hash to the commitment asked for; `enforce_policy(commit, ..)` denies where no policy is committed. Tests: `dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone`, `bytes_at_another_commitment_are_not_the_policy_asked_for`, `a_cache_miss_takes_the_durable_bytes_that_re_hash_to_the_commitment`, `an_operation_naming_a_commitment_without_a_policy_is_denied`. | +| `dsm_sdk/src/sdk/core_sdk.rs` · `enforce_policy_for_operation`, `build_token_policy_context`; `handlers/token_routes.rs` | Enforcement was keyed by the operation's `token_id` string; creation and rehydration registered a projection under the token id; the resolver mapped a string (canonical id or ticker) to bytes. | Enforcement is keyed by the `policy_commit` the operation carries (Transfer, Burn, CreateToken); a lock operation, which carries none, is refused for it (§9.1). Creation and rehydration hand Core the bytes (`register_policy_bytes`) and take the commitment Core answers; the resolver answers `load_policy_verified(commit)`. `PolicyCommitResolver` deleted. | +| `dsm/src/types/policy_types.rs`, `core/token/policy/policy_enforcement.rs`, `policy_validation.rs`, `policy_cache.rs`; `proto/dsm_app.proto` | `IdentityConstraint` (caller-stated identity strings, "derived identity" by path suffix), `EmissionsSchedule`, `CreditBundlePolicy` and `Custom` conditions, roles and role permissions, the semver validator, the ticker index of the cache; `StoredPolicy`. | Deleted; `PolicyConditionProto` fields 1, 5, 6, 7 and `CanonicalPolicy.roles` reserved (`PolicyRoleProto`, `IdentityConstraintProto`, `EmissionsScheduleProto`, `CreditBundlePolicyProto`, `CustomConstraintProto`, `StoredPolicy` gone; TypeScript bindings regenerated). A committed policy states its recipient allowlist and its release rule in the blob. `BitcoinTapConstraint` stays untouched (Bitcoin is out of this round): its arm allows unconditionally and is recorded below. Test: `dsm::types::policy_types::tests::a_policy_carrying_a_condition_the_grammar_does_not_name_does_not_decode`. | + +**Open** + +| Location | Hole | +|---|---| +| `dsm/src/core/token/token_state_manager.rs` · `ERA_POLICY_COMMIT` | ERA has no committed policy: no `TokenPolicyV3` bytes hash to its commitment, and the policy grammar has no encoding for its release rule (the emission schedule, §51 — emissions are out of this round). Until the blob exists, every ERA transfer and burn is refused at enforcement ("no policy is committed at the commitment the operation names"). The tests this turns red are listed in the expected-red manifest below. | +| `dsm/src/sofi/validation.rs` · `market_legs_permitted` | The same absence at SoFi validation: ERA and dBTC are exempted as "pre-rooted" and read no policy bytes, so a SoFi leg in ERA is checked against nothing where every other token is checked against its committed policy. Recorded; not changed in this cut. | +| `dsm/src/core/token/policy/policy_enforcement.rs` · `BitcoinTapConstraint` | A configuration-only arm that allows unconditionally, kept because Bitcoin is not touched in this round. | + +**Expected-red manifest.** Recorded from the board of this commit; see the pull request body. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 272 | 66 | 116 | 34 | 9 | 29 | 18 | -| SoFi (MR-SOFI) | 333 | 204 | 86 | 18 | 8 | 17 | 0 | +| SoFi (MR-SOFI) | 333 | 206 | 84 | 18 | 8 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 32 | 41 | 50 | 16 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **909** | **311** | **244** | **103** | **33** | **64** | **154** | +| **All** | **909** | **313** | **242** | **103** | **33** | **64** | **154** | ## 8 Per-requirement results @@ -1290,7 +1314,7 @@ Owner clarification of 2026-09-23 (§6.21): a predicate has two values and the n | MR-DSM-0186 | Partial | dsm · types/device_state.rs · `advance` (per-mechanism checks only) | no test found | K∉Σ is enforced per-mechanism (signature presence), not via a general authority-vs-consumption abstraction. | | MR-DSM-0187 | Partial | `dsm::sofi::resolution::resolve_position` | — | The cited BitcoinHTLC refund and abort branches no longer exist. All-or-none settlement is SoFi route-wide resolution, but no test shows a leg's precommitted refund or abort as the remedy for separately settled bilateral legs. | | MR-DSM-0188 | Partial | dsm · types/device_state.rs · `advance`; vault/dlv_manager.rs · `DLVManager` (no cross-receipt consumption key) | no test found | No cross-receipt consumption key exists in `dlv_manager.rs`. | -| MR-DSM-0189 | Met | `dsm::core::token::policy::policy_enforcement::PolicyEnforcer::enforce_policy`; `dsm::core::token::policy::policy_enforcement::check_condition` | `dsm::core::token::policy::policy_enforcement::tests::identity_constraint_denies` | — | +| MR-DSM-0189 | Met | `dsm::core::token::policy::TokenPolicySystem::register_policy`; `dsm::core::token::policy::TokenPolicySystem::enforce_policy`; `dsm::core::token::policy::policy_enforcement::PolicyEnforcer::check_condition` | `dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone`; `dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for`; `dsm::core::token::policy::policy_enforcement::tests::operation_restriction_is_case_sensitive` | Every token operation is evaluated at the policy commitment it names, against the view Core derives from the committed bytes at that commitment; the policy is read and checked, never stated by a caller (§6.32). | | MR-DSM-0190 | Met | `dsm::sofi::validation::validate`; `dsm::sofi::lineage::advance_resolved`; `dsm::economic::write_set::verify_operation_write_set` | `dsm::sofi::validation::tests::a_well_formed_swap_is_valid`; `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg`; `dsm::sofi::validation::tests::an_intermediate_token_must_permit_transfer_too`; `dsm::sofi::validation::tests::a_token_outside_the_vaults_pair_is_invalid`; `dsm::sofi::validation::tests::an_off_by_one_output_is_invalid`; `dsm::sofi::lineage::tests::advance_is_refused_on_each_missing_conjunct` | policy_enforcement::check_vault_condition no longer exists; SoFi validation conjoins the vault's market policy, both token policies and the reserve arithmetic, and the lineage advance requires the registered claims, at library level (section 3 G1). | | MR-DSM-0191 | Partial | same enforcement funnel as 0190 | no exhaustive test found | The enforcement funnel exists for the paths exercised; nothing shows that no operation (including wrap and vault placement) bypasses it. | | MR-DSM-0192 | Met | `dsm::economic::lineage::advance_validated`; `dsm::economic::register::RegisteredEconomicRoot`; `dsm_sdk::sdk::economic_admission_flow::build_dsm_admission` | `dsm::economic_lineage_register::registering_an_arbitrary_root_yields_nothing_validated`; `dsm::economic_admission_lifecycle::a_registered_root_disagreeing_with_the_witness_is_refused`; `dsm::economic::register::registered_root_construction_tests::a_registered_root_is_a_projection_of_a_verified_claim` | The producer builds the write set before staging, publishing and registering; a registered root yields nothing validated, and the verifier refuses a registered root that disagrees with the write set. | @@ -1678,25 +1702,25 @@ Owner clarification of 2026-09-23 (§6.21): a predicate has two values and the n | MR-SOFI-0297 | Not code | — | — | Dependency-boundary | | MR-SOFI-0298 | Missing | dsm · common/domain_tags/dsm/misc/sofi.rs:165 (`TAG_DSM_SOFI_MEMBERSHIP_HANDOVER`, "Reserved... Not ruled") | — | Verified: tag allocated, zero derivations use it anywhere | | MR-SOFI-0299 | Partial | dsm · types/policy_types.rs (`PolicyAnchor`); ccb/state.rs:221-263 | no test found | Plausible from the content-addressed `PolicyAnchor` design; not verified line by line and no test named. | -| MR-SOFI-0300 | Partial | dsm_sdk · handlers/token_routes.rs (`token.create` anchors and locks out issuer); dsm · core/token/policy/mod.rs:246 (`update_token_policy`, dead — zero callers anywhere in repo, verified by grep) | e2e_token_create_lifecycle.rs (creation path); no test needed for dead code | — | +| MR-SOFI-0300 | Partial | dsm_sdk · handlers/token_routes.rs (`token.create` anchors and locks out issuer); dsm · core/token/policy/mod.rs (`register_policy`: content-addressed, no update path exists) | e2e_token_create_lifecycle.rs (creation path) | — | | MR-SOFI-0301 | Partial | dsm · ccb/state.rs (MarketPolicy names token policy commits, no own token policy) | no test found | Not independently re-verified in depth | | MR-SOFI-0302 | Met | `dsm_sdk::handlers::token_routes::build_policy_v3_bytes`; `dsm::economic::token_policy::parse_token_policy_blob` | `dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from`; `dsm_sdk::handlers::token_routes::tests::a_multi_signer_threshold_round_trips` | token_create_anchor_integrity.rs was deleted in #977; the packer is the only production packer, and its in-file tests round-trip every field through Core's big-endian parser. | | MR-SOFI-0303 | Partial | `dsm::economic::token_policy::parse_token_policy_blob`; `dsm_sdk::handlers::token_routes::build_policy_v3_bytes` | `dsm_sdk::handlers::token_routes::tests::a_bad_ticker_or_decimals_is_refused`; `dsm_sdk::handlers::token_routes::tests::an_empty_or_oversized_signer_set_is_refused`; `dsm_sdk::handlers::token_routes::tests::an_unsatisfiable_threshold_is_refused`; `dsm::economic::token_policy::tests::every_rule_refuses_its_violation` | The bounds are enforced, and tests refuse 0 and 17 signers, a 1-character ticker and 19 decimals, but no test refuses a 9-character ticker, and the signer-set authorization clause is violated (see MR-SOFI-0310). | -| MR-SOFI-0304 | Partial | dsm · types/policy_types.rs (`PolicyAnchor`, content hash) | no test found | — | +| MR-SOFI-0304 | Met | `dsm::core::token::policy::TokenPolicySystem::register_policy`; `dsm::core::token::policy::TokenPolicySystem::commitment_of`; `dsm::sofi::validation::market_legs_permitted` | `dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone`; `dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for`; `dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from` | A token's identity is `BLAKE3(TAG_DSM_POLICY, TokenPolicyV3 bytes)`, recomputed by Core wherever the bytes are registered or read: the enforcer is keyed by it, and bytes at another commitment are no policy (§6.32). | | MR-SOFI-0305 | Violated | `dsm_sdk::storage::client_db::token_registry::get_token_by_ticker`; `dsm_sdk::handlers::token_routes` | — | Core identity is policy_commit, but the SDK token registry has a UNIQUE ticker index, and token.create and tokens.addByAnchor refuse a second token whose ticker is already held (TICKER_CONFLICT), so two tokens cannot share a ticker on one device. | -| MR-SOFI-0306 | Met | `dsm::types::policy_types::PolicyCondition`; `dsm::economic::token_policy::parse_token_policy_blob`; `dsm::core::token::policy::policy_validation::PolicyValidator` | `dsm::core::token::policy::policy_validation::tests::a_zero_supply_cap_does_not_validate`; `dsm::economic::token_policy::tests::every_rule_refuses_its_violation`; `dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it` | The supply class is a field of the committed blob, parsed strictly; `SupplyCap` carries only `max_supply` (the unlimited flag is a reserved proto field since a592900a) and a zero cap does not validate. The earlier Violated stood on `POLICY_FLAG_UNLIMITED_SUPPLY` and `check_issuance_permitted`, both deleted. | +| MR-SOFI-0306 | Met | `dsm::types::policy_types::PolicyCondition`; `dsm::economic::token_policy::parse_token_policy_blob` | `dsm::economic::token_policy::tests::a_zero_genesis_supply_is_refused`; `dsm::economic::token_policy::tests::every_rule_refuses_its_violation`; `dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it` | The supply class is a field of the committed blob, parsed strictly; `SupplyCap` carries only `max_supply` (the unlimited flag is a reserved proto field since a592900a), derived from the parsed blob whose zero genesis supply does not parse (the `PolicyFile` validator is deleted, §6.32). The earlier Violated stood on `POLICY_FLAG_UNLIMITED_SUPPLY` and `check_issuance_permitted`, both deleted. | | MR-SOFI-0307 | Partial | `dsm::economic::write_set::build_write_set`; `dsm::types::device_state::validate_conservation`; `dsm::economic::provenance::verify_genesis_release` | `dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator`; `dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage`; `dsm::economic_write_set::a_burn_round_trips_and_removes_a_zeroed_balance` | No mint operation exists: the whole genesis supply is released to the creator in the creating transition (so nothing is unreleased), a creation happens once per lineage, and a burn is a debit under the conservation guard. The identity genesis = unreleased + balances + burned is not asserted as a sum by any test, and emission as a release under a policy other than AllAtCreation has no producer. The earlier Violated stood on `token.mint` and `handle_token_mint`, deleted in #976/#977. | | MR-SOFI-0308 | Missing | dsm · economic/issuance.rs (module doc explicitly: "DOES NOT PROVE... redeemable for, or collateralized by... no backing condition in the token-policy vocabulary") | — | Confirmed by direct read: no backing/lock/reserve concept anywhere | | MR-SOFI-0309 | Missing | dsm · economic/issuance.rs (module doc explicitly: "DOES NOT PROVE... redeemable for, or collateralized by... no backing condition in the token-policy vocabulary") | — | Confirmed by direct read: no backing/lock/reserve concept anywhere | -| MR-SOFI-0310 | Met | `dsm_sdk::handlers::token_routes::permitted_operations`; `dsm_sdk::handlers::token_routes::derive_policy_file`; `dsm::core::token::policy::policy_enforcement::PolicyEnforcer::check_condition` | `dsm_sdk::handlers::token_routes::tests::the_policy_permits_exactly_what_its_flags_name`; `dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn` | The signer set authorizes nothing in beta: the standard release rule names it for nothing (§47). A token's conditions are its supply cap and the operation restriction its two flags define; the signer-set condition and its witness are deleted (§6.17). | +| MR-SOFI-0310 | Met | `dsm::core::token::policy::policy_enforcement::permitted_operations`; `dsm::core::token::policy::policy_enforcement::enforced_policy`; `dsm::core::token::policy::policy_enforcement::PolicyEnforcer::check_condition` | `dsm::core::token::policy::policy_enforcement::tests::the_policy_permits_exactly_what_its_flags_name`; `dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn` | The signer set authorizes nothing in beta: the standard release rule names it for nothing (§47). A token's conditions are its supply cap and the operation restriction its two flags define; the signer-set condition and its witness are deleted (§6.17). | | MR-SOFI-0311 | Partial | dsm · economic/issuance.rs · `check_market_leg_permitted` | none — confirmed by grep this function has **zero callers anywhere in the repo** (only its own definition) | No transfer, vault-creation or SoFi-leg path calls it. | | MR-SOFI-0312 | Partial | `dsm::economic::issuance::check_market_leg_permitted`; `dsm::economic::token_policy::parse_token_policy_blob` | — | check_issuance_permitted was deleted in #976, and nothing now reads allowlist_device_ids on issuance (verify_genesis_release ignores it); only the no-market-meaning half holds, because check_market_leg_permitted never consults the allowlist, and no test covers either half. | -| MR-SOFI-0313 | Partial | dsm_sdk · handlers/token_routes.rs · `parse_token_policy` (fail-closed on every field) | inline parse checks verified | No explicit supply-class field (only the unlimited flag), no separate native/backed sub-shapes | +| MR-SOFI-0313 | Partial | dsm_sdk · handlers/token_routes.rs · `parse_token_policy` (fail-closed on every field) | inline parse checks verified | No explicit supply-class field (only the unlimited flag), no separate native/backed sub-shapes. ERA states no policy at all: `ERA_POLICY_COMMIT` has no preimage, so no ERA transfer or burn passes enforcement (§6.32). | | MR-SOFI-0314 | Met | `dsm_sdk::handlers::token_routes::build_policy_v3_bytes`; `dsm::economic::token_policy::parse_token_policy_blob` | `dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from`; `dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator` | The genesis supply is a field of the blob whose hash is the policy commit, and the creating transition releases exactly it. The earlier Partial stood on the capped-creation refusal, deleted in a592900a. | -| MR-SOFI-0315 | Partial | dsm · core/token/policy/mod.rs:246 · `update_token_policy` (dead, no callers — confirmed by grep) | none needed (uncalled) | — | +| MR-SOFI-0315 | Met | `dsm::core::token::policy::TokenPolicySystem::register_policy`; `dsm::core::token::policy::TokenPolicySystem::enforce_policy`; `dsm::types::device_state::validate_conservation` | `dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for`; `dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn` | No path changes a committed policy: the enforcer's view is derived from the bytes at the commitment and from nothing a caller states, and units move only under its operation restriction and the conservation guard. | | MR-SOFI-0316 | Partial | `dsm::economic::token_policy::ReleaseRule`; `dsm::economic::write_set::build_write_set`; `dsm::economic::native_reserve::release_constructible` | `dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator`; `dsm::economic::native_reserve::tests::a_release_of_any_amount_but_the_payout_never_holds_the_cell`; `dsm_sdk::handlers::faucet_flow_tests::a_release_of_the_whole_supply_holds_nothing_and_the_claim_lands`; `dsm::native_reserve_wire::credits_funded_along_a_lineage_sum_to_what_the_reserve_released`; `dsm::economic::native_reserve::tests::a_release_signed_by_any_key_but_the_recipient_devices_never_holds_the_cell`; `dsm_sdk::handlers::faucet_flow_tests::a_release_naming_a_device_its_key_does_not_derive_holds_nothing` | The only token release rule is `AllAtCreation`, under which no unit stays unreleased; a release formula for any other rule, recomputable by anyone, has no producer. The earlier citation of `handle_token_mint` names code deleted in #977. The native reserve's construction predicate enforces the beta claim policy's amount: a faucet release is constructible only for `ERA_FAUCET_PAYOUT`, so a release the policy does not allow never holds a cell (§6.24). The signer is bound to the recipient device at the cell (`derive_devid(claimant_public_key, AttA) == recipient_devid`, §6.26); that the device belongs to `recipient_genesis` is proven by P0–P6 when a credit consumes the release. | | MR-SOFI-0317 | Met | `dsm::economic::write_set::build_write_set`; `dsm::economic::provenance::verify_genesis_release` | `dsm::economic_write_set::a_burn_round_trips_and_removes_a_zeroed_balance`; `dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage` | There is no mint operation and no unreleased pool, a burn is a debit, and the whole supply is released once because a second creation of the same commit cannot build its write set. | -| MR-SOFI-0318 | Met | `dsm::sofi::validation::market_legs_permitted`; `dsm::economic::token_policy::parse_token_policy`; `dsm::economic::provenance::verify_genesis_release` | `dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing`; `dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg`; `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg` | parse_issuance_policy was deleted in #976; each non-builtin token's policy bytes are re-hashed to its own commit and parsed strictly, while ERA and dBTC are pre-rooted and read no policy bytes. | +| MR-SOFI-0318 | Met | `dsm::sofi::validation::market_legs_permitted`; `dsm::economic::token_policy::parse_token_policy`; `dsm::economic::provenance::verify_genesis_release` | `dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing`; `dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg`; `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg` | parse_issuance_policy was deleted in #976; each non-builtin token's policy bytes are re-hashed to its own commit and parsed strictly, while ERA and dBTC are pre-rooted and read no policy bytes — the same absence the enforcer refuses on (§6.32). | | MR-SOFI-0319 | Missing | — | — | No backing/lock-consumption concept — same absence as 0308 | | MR-SOFI-0320 | Missing | — | — | No backing/lock-consumption concept — same absence as 0308 | | MR-SOFI-0321 | Missing | — | — | No redemption transition anywhere | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index f545ddb2b..e644e5920 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -37,7 +37,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0166, MR-DSM-0170: a second child of a consumed parent is refused | `dsm` · types/receipt_types.rs · `ParentConsumptionTracker` in `verify_stitched_receipt` rule 5 | `dsm::verification::receipt_verification::tests::test_parent_uniqueness_enforcement`; vertical validation: double_spend_second_child, receipt_replay, fork_exclusion, receipt_verifier_tripwire | Not run. | TLA+ `DSM_Tripwire` (literal and direct replay pass) | | MR-DSM-0024: a debit never exceeds the head's balance | `dsm` · types/device_state.rs · `DeviceState::advance` (checked debit) | `dsm::types::device_state::tests::advance_rejects_balance_underflow`; vertical validation: balance_underflow, overspend_refused, token_manager_overspend_rejection | Not run. | — | | A transfer names its token exactly | `dsm_sdk` · handlers/app_router_impl.rs · `wallet.send`; handlers/wallet_routes.rs · `sendSmart` | `dsm_sdk::handlers::sender_admission_tests::a_transfer_naming_no_token_or_a_misspelled_one_is_refused_and_nothing_moves` | Empty-token default restored → red (2026-09-24). | — | -| A token policy decodes only conditions its enforcer evaluates | `dsm` · types/policy_types.rs · `PolicyCondition` decode | `dsm::types::policy_types::tests::a_policy_carrying_a_vault_condition_does_not_decode` | Lenient decode → red (2026-09-24). | — | +| A token policy decodes only conditions its enforcer evaluates; the kinds the policy grammar does not name (a vault condition, an identity allowlist, an emission schedule, a credit bundle, a custom constraint) are reserved and do not decode | `dsm` · types/policy_types.rs · `PolicyCondition` decode; `proto` · `PolicyConditionProto` reserved 1, 2, 4, 5, 6, 7, 9 | `dsm::types::policy_types::tests::a_policy_carrying_a_condition_the_grammar_does_not_name_does_not_decode` | Lenient decode → red (2026-09-24). | — | | A corrupt reserve memo row is an error, never a release | `dsm_sdk` · storage/client_db/native_reserve.rs · `release_at` | `dsm_sdk::storage::client_db::native_reserve::tests::a_corrupt_memo_row_is_an_error_not_a_release` | Error mapping removed → red (2026-09-24). | — | | An unreadable token archive is an error, never absence | `dsm_sdk` · sdk/token_sdk.rs · `token_exists` | `dsm_sdk::sdk::token_sdk::tests::a_token_lookup_over_an_unreadable_archive_is_an_error_not_absence` | Error swallowed → red (2026-09-24). | — | | A malformed storage-node CA list is an error | `dsm_sdk` · sdk/storage_node_sdk.rs · `read_ca_certs` | `dsm_sdk::sdk::storage_node_sdk::tests::custom_ca_certs_is_a_list_of_readable_paths_or_an_error` | Skip restored → red (2026-09-24). | — | @@ -49,16 +49,16 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | Ruling #7: an operation signs an amount's value and lock, never a state reference | `dsm` · types/operations.rs · `Operation::to_bytes` (amount encoding), `dec_balance` | `dsm::types::operations::tests::balance_encoding::an_amounts_state_reference_is_not_signed`; `dsm::types::operations::tests::balance_encoding::an_amount_carrying_a_state_reference_does_not_decode` | State reference re-encoded → red; 48-byte amount accepted → red (2026-09-24). | — | | MR-DSM-0079, MR-STOR-0094: a ByteCommit backs a route-chain link only when its chain link holds | `dsm` · route_chain.rs · `CommittedAt::chain_link_holds` in `commits`; `dsm_sdk` · sdk/route_seats.rs · `committed_at` (parent fetched) | `dsm::route_chain::tests::a_byte_commit_backs_a_link_only_when_it_follows_its_parent` | Chain-link check removed → red (2026-09-24). | — | | MR-DSM-0083, MR-DSM-0213: a carrier writes to the leader only the values it does not hold | `dsm_sdk` · sdk/route_seats.rs · `from_leader` | `dsm_sdk::sdk::route_seats::tests::a_value_the_leader_already_holds_is_not_written_there_again` | Full batch restored → red (2026-09-24). | — | -| MR-SOFI-0306, MR-DSM-0197: no supply is unlimited or zero, and only creation brings supply into being | `dsm` · core/token/policy/policy_validation.rs (`SupplyCap`); policy_enforcement.rs (`SupplyCap`) | `dsm::core::token::policy::policy_validation::tests::a_zero_supply_cap_does_not_validate`; `dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it`; `dsm_sdk::supply_cap_enforcement::supply_cap_gates_only_creation` | Zero check removed → red; gate opened → `supply_cap_partial_history` red; gate over-applied → `supply_cap_gates_only_creation` red (2026-09-24). | — | +| MR-SOFI-0306, MR-DSM-0197: no supply is unlimited or zero, and only creation brings supply into being | `dsm` · economic/token_policy.rs · `parse_token_policy_blob` (zero genesis supply refused); core/token/policy/policy_enforcement.rs (`SupplyCap`) | `dsm::economic::token_policy::tests::a_zero_genesis_supply_is_refused`; `dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it`; `dsm_sdk::supply_cap_enforcement::supply_cap_gates_only_creation` | Gate opened → `supply_cap_partial_history` red; gate over-applied → `supply_cap_gates_only_creation` red (2026-09-24). The parser's zero check removed (the validator's, red on removal 2026-09-24, is deleted with the validator) → `a_zero_genesis_supply_is_refused` red (2026-09-26, restored). | — | | MR-STOR-0021: a storage fact not established from the reads in hand is never read as its negation | `dsm` · sofi/storage.rs · `keep_verifying`, `keep_all_verifying`; `dsm_sdk` · sdk/sofi_evidence.rs · `fetch_vault_genesis`; sdk/sofi_flow.rs · `own_setup_ref` | `dsm::sofi::storage::tests::an_unestablished_candidate_is_never_none`; `dsm::sofi::storage::tests::an_unestablished_candidate_makes_discovery_partial`; `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`; `dsm_sdk::sdk::sofi_flow::tests::a_setup_scan_that_met_an_unestablished_candidate_is_not_a_refusal` | `unestablished` flag removed → red; `complete` flag removed → red; `fetch_vault_genesis` completeness ignored → red; `own_setup_ref` completeness ignored → red (2026-09-24). | `lean4/DSMSofiStorage.lean::an_unestablished_candidate_is_never_none`, `lean4/DSMSofiStorage.lean::an_unestablished_candidate_makes_discovery_partial` | | DSM Amendment A7: a node holds a spool payload only sealed, and the memo the sender wrote is in no node's bytes | `dsm_sdk` · sdk/b0x_sdk.rs · `seal_for` | `dsm_sdk::handlers::node_e2e_tests::a_transfer_reaches_the_nodes_only_sealed_and_arrives` | Memo replaced by one never sent → red (the positive control) (2026-09-24). | — | | MR-STOR-0143, MR-DSM-0270: at most one value has a valid leader link at a cell; a value is final on three links of one chain | `dsm` · route_chain.rs · `evaluate` | `dsm::route_chain::tests::only_links_of_one_chain_count_toward_final`; `dsm::route_chain::tests::three_links_are_final_two_preserved_one_leader_held` | TLA+: `AnyArrivalLeads` and `CountWithoutLeader` violate `ChainUniqueness`; `NodeRemoves` violates `FinalityStable` (2026-09-24). | `tla/DSM_RouteChain.tla::ChainUniqueness`, `tla/DSM_RouteChain.tla::AtMostOneFinal`, `tla/DSM_RouteChain.tla::StatesNest`, `tla/DSM_RouteChain.tla::FinalSurvivesTwoLosses`, `tla/DSM_RouteChain.tla::FinalityStable` | | Ruling #3: a node starts only on an empty database, created at `SCHEMA_VERSION`, or on one at exactly that version and layout | `dsm_storage_node` · db/pg.rs · `init_db`, `verify_schema_layout` | `dsm_storage_node::db::schema_properties::a_database_at_another_version_is_refused`; `dsm_storage_node::db::schema_properties::a_database_whose_layout_is_not_the_versions_is_refused`; `dsm_storage_node::db::schema_properties::a_database_with_tables_and_no_version_is_refused`; `dsm_storage_node::db::schema_properties::an_empty_database_is_created_at_the_schema_version` | Version check, marker check, column check and index check each removed → red (2026-09-24). | — | | Ruling #2: a node's identity, TLS material, trust anchor and database come from its config or it does not start | `dsm_storage_node` · main.rs · `load_server_config` | `dsm_storage_node::main::tests::a_config_missing_a_required_setting_is_refused_by_name`; `dsm_storage_node::main::tests::a_missing_config_file_is_refused` | `tls.ca_path` default restored → red; config file made optional → red (2026-09-24). | — | -| MR-SOFI-0310, SoFi §54: a burn is permitted by the policy's burn flag and nothing else; the signer set authorizes nothing the policy's rules do not name | `dsm_sdk` · handlers/token_routes.rs · `permitted_operations` in `derive_policy_file`; `dsm` · policy_enforcement.rs · `OperationRestriction` | `dsm_sdk::handlers::token_routes::tests::the_policy_permits_exactly_what_its_flags_name`; `dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn` | `burn` added to every policy → both red (2026-09-24). | — | +| MR-SOFI-0310, SoFi §54: a burn is permitted by the policy's burn flag and nothing else; the signer set authorizes nothing the policy's rules do not name | `dsm` · core/token/policy/policy_enforcement.rs · `permitted_operations` in `enforced_policy`, `OperationRestriction` | `dsm::core::token::policy::policy_enforcement::tests::the_policy_permits_exactly_what_its_flags_name`; `dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn` | `burn` added to every policy → both red (2026-09-24, in the SDK); on the projection moved into Core, `burn` permitted by every policy → `the_policy_permits_exactly_what_its_flags_name` red (2026-09-26, restored). | — | | SoFi §18.4: a known bound violation (closure object size, validation fetch size, authenticated envelopes, provenance fan-out) is refused from the evidence alone | `dsm` · sofi/conformance.rs · `conformance_bounds`; economic/claim.rs · `verify_manifest_provenance_index` | `dsm::sofi::conformance::tests::a_closure_object_over_the_bound_is_invalid`; `dsm::sofi::conformance::tests::a_validation_fetch_over_the_bound_is_invalid`; `dsm::sofi::conformance::tests::too_many_auth_envelopes_is_invalid`; `dsm::economic::claim::fanout_bound_tests::a_provenance_fanout_over_the_bound_is_refused` | Each of the four checks removed → its test red (2026-09-24). | — | | MR-DSM-0270, MR-STOR-0127: a leader link, once held, is held on every later read, and a final value stays final as the cell grows | `dsm` · route_chain.rs · `evaluate` over append-only seats | `dsm::route_chain::tests::the_state_is_the_count_of_valid_links`; `dsm::route_chain::tests::the_first_recognized_value_at_the_leader_holds_the_cell` | TLA+ `NodeRemoves` violates `FinalityStable` (2026-09-24). | `lean4/DSMRouteChain.lean::leader_held_stable`, `lean4/DSMRouteChain.lean::final_stable`, `tla/DSM_RouteChain.tla::FinalityStable`, `tla/DSM_RouteChain.tla::LeaderHeldStable` | -| SoFi §50: a token with no genesis supply is not a token; a creation issuing nothing is refused before the fee | `dsm` · types/device_state.rs · `validate_conservation` | `dsm::core::token::policy::policy_validation::tests::a_zero_supply_cap_does_not_validate` | Zero check removed → red (2026-09-24). | `lean4/DSMTokenIssuance.lean::create_with_no_supply_is_refused` | +| SoFi §50: a token with no genesis supply is not a token; a creation issuing nothing is refused before the fee | `dsm` · types/device_state.rs · `validate_conservation`; economic/token_policy.rs · `parse_token_policy_blob` | `dsm::economic::token_policy::tests::a_zero_genesis_supply_is_refused` | The validator's zero check, red on removal 2026-09-24, is deleted with the validator; the parser's zero check removed → `a_zero_genesis_supply_is_refused` red (2026-09-26, restored). | `lean4/DSMTokenIssuance.lean::create_with_no_supply_is_refused` | | MR-DSM-0030: every value-moving advance registers its root at the next economic position; a transfer registers the sender's root and a foreign walk validates it as the transfer | `dsm_sdk` · sdk/economic_admission_flow.rs · `finish_admission` → `register_economic_root` | `dsm_sdk::handlers::sender_admission_tests::a_transfer_registers_the_senders_root_at_the_next_position` | `register_economic_root` call removed from `finish_admission` → red (2026-09-24). | — | | MR-DSM-0041, MR-DSM-0042, SoFi §23–§24: an exercise its own bytes refute is classified with nothing read about it; at a walked key the walk reads the key and nothing else | `dsm` · sofi/resolve.rs · `Verifier::facts_of` (`refuted_in_hand` before any read) | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` | In-hand refutation bypassed → the nodes are asked for registration cells, indexes and objects → red (2026-09-24). | — | | SoFi §30, §44.4: the leaves of the generation an operation was built on are served after this device walked past it; a trader's second trade against a vault resolves | `dsm_sdk` · storage/client_db/sofi_vault_head.rs · `leaves_at`, `record_resolved_with_conn` | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` | `leaves_at` answers the head's generation whatever root is asked → the second trade is `RetriesExhausted` → red (2026-09-24). | — | @@ -111,3 +111,5 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | SoFi §23–§24, storage §9: every fact the ladder reads is bound to the read it came from — the position pair, the attempt key, the chain — and a read of another position or key is no fact | `dsm` · sofi/facts.rs · `establish` (`NotThisExercise`), `Established::refuted`; sofi/resolution.rs · `walk`, `KeyFacts::of` | `dsm::sofi::facts::tests::reads_of_another_position_or_key_establish_nothing`; `dsm::sofi::facts::tests::a_refuted_position_is_bound_to_its_exercise_and_its_registration`; `dsm::sofi::resolution::tests::a_walk_takes_only_facts_bound_to_the_key_it_asks_about` | Run 2026-09-26: the key-binding guard in `walk` removed (facts of any key classified) → `a_walk_takes_only_facts_bound_to_the_key_it_asks_about` red; restored | — | | SoFi §23.6: `AttemptLive(K^(a))` is a walk from the first key of the parent's chain that reached `a`, every earlier key skipped; a walk that started later or stopped short establishes nothing | `dsm` · sofi/resolution.rs · `AttemptWalk::liveness_of`; sofi/facts.rs · `establish` | `dsm::sofi::resolution::tests::a_walk_establishes_liveness_only_as_far_as_it_reached`; `dsm::sofi::facts::tests::liveness_above_attempt_zero_is_the_walk_that_reached_it` | Run 2026-09-26: `AttemptWalk::liveness_of` made to answer for a walk that did not start at key zero → `a_walk_establishes_liveness_only_as_far_as_it_reached` red; restored | — | | SoFi §30, MR-SOFI-0259: a vault's chain is established from the genesis this verifier accepts, one Core-recomputed consumption at a time; what this device recorded is stood on only anchored at that genesis and linked row to row, and a recorded generation is written once | `dsm` · sofi/resolution.rs · `VaultChain::from_recorded`, `extend`; sofi/resolve.rs · `Verifier::chain`; `dsm_sdk` · storage/client_db/sofi_vault_head.rs · `write_root` | `dsm::sofi::resolution::tests::the_memo_becomes_a_chain_only_anchored_at_the_genesis_and_linked_row_to_row`; `dsm_sdk::storage::client_db::sofi_vault_head::tests::an_established_generation_is_never_rewritten`; `dsm_sdk::storage::client_db::sofi_vault_head::tests::the_recorded_generations_are_read_contiguously_with_their_links` | Run 2026-09-26: `from_recorded` taking the rows as recorded (neither anchored nor linked) → `the_memo_becomes_a_chain_only_anchored_at_the_genesis_and_linked_row_to_row` red; `write_root` updating the root on conflict → `an_established_generation_is_never_rewritten` red; restored | — | +| SoFi §47, MR-SOFI-0304, MR-SOFI-0315: a token's policy is the `TokenPolicyV3` bytes at its commitment; the enforcer registers a policy only from bytes it hashed itself and takes durable bytes only when they re-hash to the commitment asked for | `dsm` · core/token/policy/mod.rs · `TokenPolicySystem::register_policy`, `policy_at` | `dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for`; `dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone` | `policy_at` taking the durable bytes without re-hashing them to the commitment asked for → `bytes_at_another_commitment_are_not_the_policy_asked_for` red (2026-09-26, restored). | — | +| SoFi §51, MR-SOFI-0318: an operation is evaluated against the policy committed at the commitment it names and nothing else; where none is committed (ERA today) it is denied, never allowed by a default | `dsm` · core/token/policy/mod.rs · `TokenPolicySystem::enforce_policy`; `dsm_sdk` · sdk/core_sdk.rs · `build_token_policy_context` (the operation's `policy_commit`) | `dsm::core::token::policy::tests::an_operation_naming_a_commitment_without_a_policy_is_denied` | `enforce_policy` allowing where no policy is committed → red (2026-09-26, restored). | — |