Skip to content

Commit cb0a4e6

Browse files
feat(wallet)!: the gate mints an owned SpendApproval; the signer accepts nothing else
Replace the summary-based custody seam with a ruling + owned-approval shape: - `PolicyAuthorizer::authorize_op(&[CoinSpend], SpendOpClass) -> Result<SpendRuling>` derives the summary itself, so no caller-supplied description exists to disagree with. - `SpendApproval` OWNS the exact coin spends it authorized. Not a digest binding: a comparison can compare the wrong bytes, an owned value cannot be mismatched. - `SpendRuling::RequiresConfirmation` makes "not yet - ask the human" expressible, so a Confirm/Vault spend reaches the ceremony instead of collapsing into a refusal. Removals do the enforcing (the additions are inert without them): - `MoneySigner::sign_coin_spends` DELETED; `sign_approved` is the only signing entry point. - `LocalMoneySigner::sign_unsigned` is now `pub(crate)`. - The `SpendAuthorizer` trait DELETED - a custody gate must not be an interface whose simplest implementation approves everything. - `AccountError::RequireAuth` DELETED: nothing can produce it now that escalation is a ruling. - The tier-disagreement guard DELETED as vacuous - the gate classifies its own derivation. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cf70b99 commit cb0a4e6

9 files changed

Lines changed: 1442 additions & 999 deletions

File tree

src/error.rs

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38,33 +38,34 @@ pub enum AccountError {
3838
#[error("spend refused: {0}")]
3939
Spend(String),
4040

41-
/// The spend policy declined to auto-approve, but a full human authorization ceremony MAY still
42-
/// permit it. This is the ESCALATABLE refusal: a vault move, an over-limit hot-wallet spend, a
43-
/// spend past the rolling period cap, or a spend whose op class is not auto-send-enabled.
41+
/// The spend is FORBIDDEN by a structural custody rule, or the user declined it — no (further)
42+
/// confirmation ceremony can permit it.
4443
///
45-
/// A caller that receives this MUST run the confirm ceremony (and, for a vault move, the
46-
/// password-always unlock) before signing — never treat it as a soft pass.
47-
#[error("spend requires explicit authorization: {0}")]
48-
RequireAuth(String),
49-
50-
/// The spend is FORBIDDEN by a structural custody rule — no confirmation ceremony can permit it.
44+
/// # The escalatable outcome is deliberately NOT an error
45+
///
46+
/// "Not auto-approved, but a human could permit it" is
47+
/// [`SpendRuling::RequiresConfirmation`](crate::wallet::approval::SpendRuling::RequiresConfirmation)
48+
/// — an `Ok` value carrying a
49+
/// [`PendingApproval`](crate::wallet::approval::PendingApproval) — never a variant here. When it
50+
/// WAS an error variant (`RequireAuth`, removed in 0.2.0), the crate's only consumer collapsed
51+
/// every `Err` into one refusal and the confirm ceremony became unreachable for exactly the tiers
52+
/// that exist to require it. Every variant in this enum is now terminal for the spend, so
53+
/// collapsing them can lose detail but can no longer lose a permission.
5154
///
52-
/// Distinct from [`RequireAuth`](Self::RequireAuth) precisely so a caller cannot escalate an
53-
/// outright-forbidden spend into an approved one by prompting the user. The canonical case is a
54-
/// vault outflow to anything other than the profile's own hot wallet (#1504: every vault outflow
55-
/// MUST pass through the 24h clawback window, so it may only ever pay the hot wallet).
55+
/// The canonical structural case is a vault outflow to anything other than the profile's own hot
56+
/// wallet (#1504: every vault outflow MUST pass through the 24h clawback window, so it may only
57+
/// ever pay the hot wallet).
5658
#[error("spend forbidden by custody policy: {0}")]
5759
PolicyDenied(String),
5860

5961
/// The policy COULD NOT BE EVALUATED for this spend — the answer is unknown, not "no".
6062
///
61-
/// Kept separate from [`RequireAuth`](Self::RequireAuth) and
62-
/// [`PolicyDenied`](Self::PolicyDenied) because collapsing "denied by policy" with "could not
63-
/// determine policy" into one refusal loses the only signal that the gate is malfunctioning
64-
/// (an unreadable clock, an undecodable recipient address, a spend whose value is denominated in
65-
/// units no configured limit can bound, a summary whose declared tier disagrees with the profile's
66-
/// custody policy). Every indeterminate outcome refuses the spend, and none of them is
67-
/// escalatable — the condition must be fixed, not confirmed away.
63+
/// Kept separate from [`PolicyDenied`](Self::PolicyDenied) because collapsing "denied by policy"
64+
/// with "could not determine policy" into one refusal loses the only signal that the gate is
65+
/// malfunctioning (an unreadable clock, an undecodable recipient address, a spend whose value is
66+
/// denominated in units no configured limit can bound, a rolling window zero seconds long). Every
67+
/// indeterminate outcome refuses the spend, and none of them is escalatable — the condition must be
68+
/// fixed, not confirmed away.
6869
#[error("spend policy could not be evaluated: {0}")]
6970
PolicyIndeterminate(String),
7071
}
@@ -90,24 +91,18 @@ mod tests {
9091
assert!(e.to_string().contains("disk full"));
9192
}
9293

93-
/// The three custody refusals MUST stay distinguishable by variant, not only by message text: a
94-
/// caller decides whether to escalate to a ceremony (`RequireAuth`), refuse outright
95-
/// (`PolicyDenied`), or surface a malfunctioning gate (`PolicyIndeterminate`) by matching on the
96-
/// variant.
94+
/// The two custody refusals MUST stay distinguishable by variant, not only by message text: a
95+
/// caller decides whether to refuse outright (`PolicyDenied`) or surface a malfunctioning gate
96+
/// (`PolicyIndeterminate`) by matching on the variant. The third outcome — "ask the human" — is a
97+
/// `SpendRuling`, not an error, and `spend_policy_has_no_escalatable_error_variant` pins that.
9798
#[test]
98-
fn the_three_custody_refusals_are_distinct_variants_with_distinct_wording() {
99-
let escalatable = AccountError::RequireAuth("over limit".into());
99+
fn the_two_custody_refusals_are_distinct_variants_with_distinct_wording() {
100100
let forbidden = AccountError::PolicyDenied("vault outflow".into());
101101
let unknown = AccountError::PolicyIndeterminate("clock unreadable".into());
102102

103-
assert!(matches!(escalatable, AccountError::RequireAuth(_)));
104103
assert!(matches!(forbidden, AccountError::PolicyDenied(_)));
105104
assert!(matches!(unknown, AccountError::PolicyIndeterminate(_)));
106105

107-
assert_eq!(
108-
escalatable.to_string(),
109-
"spend requires explicit authorization: over limit"
110-
);
111106
assert_eq!(
112107
forbidden.to_string(),
113108
"spend forbidden by custody policy: vault outflow"

src/lib.rs

Lines changed: 71 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,71 @@
1-
//! # dig-account
2-
//!
3-
//! The DIG Network **user Account** — the fat, strictly-logical (zero-UI, headless-testable)
4-
//! encapsulation of everything an account can do.
5-
//!
6-
//! An **Account** is one master seed plus one or more **Profiles** (exactly one default). A
7-
//! **Profile** is a DID + dig-store + SMT-of-profile-info (dig-social-profile's `IdentityProfile`),
8-
//! minted and signed with the account seed's key at that profile index.
9-
//!
10-
//! This crate owns the object model, the unlock policy + keystore crypto, the in-process
11-
//! identity+money signer, per-profile key/DEK derivation, the DID+dig-store mint, and all wallet
12-
//! ops. It NEVER draws UI or drives an OS auth ceremony — the host harness (dig-app) injects a
13-
//! UI/auth provider that this crate calls back through for unlock and spend-confirm ceremonies.
14-
//!
15-
//! ## Custody split (the harness seam)
16-
//!
17-
//! dig-account is headless: it owns the account STATE machine + the crypto, but it never collects a
18-
//! password, renders a spend prompt, or drives an OS auth ceremony. The host harness (dig-app)
19-
//! implements [`AuthProvider`](auth::provider::AuthProvider) and injects it; dig-account calls back
20-
//! through that seam for every unlock and every spend confirmation. The private key never leaves the
21-
//! crate; the UI never sees a seed.
22-
//!
23-
//! See `SPEC.md` for the normative contract.
24-
//!
25-
//! ## Phase 1 status
26-
//!
27-
//! This is the PUBLIC TYPE SURFACE cut: the object model, keystore (`store`), unlock policy
28-
//! (`auth::policy`), per-profile key/DEK derivation, and the money path (`wallet` — the canonical
29-
//! `WalletKey` + the concrete [`MoneySigner`](wallet::money_signer::LocalMoneySigner) over
30-
//! `dig-wallet-backend`'s `LocalSigner`, with the structured [`SpendSummary`](wallet::summary::SpendSummary))
31-
//! carry real, tested implementations. The identity-signer and mint modules still expose their FINAL
32-
//! public signatures with `todo!()` bodies, filled in a later phase.
33-
34-
// Phase 1 stubs: several modules expose final signatures with `todo!()`/`unimplemented!()` bodies.
35-
#![allow(clippy::todo)]
36-
37-
pub mod auth;
38-
pub mod error;
39-
pub mod id;
40-
pub mod keys;
41-
pub mod model;
42-
pub mod profile_mint;
43-
pub mod session;
44-
pub mod signer;
45-
pub mod store;
46-
pub mod unlocked;
47-
pub mod wallet;
48-
49-
pub use auth::factors::AuthFactors;
50-
pub use auth::policy::{AllOf, AuthPolicy, PasswordOnlyPolicy, UnlockError, UnlockGate};
51-
pub use auth::provider::{AuthProvider, SpendConfirmRequest, SpendDecision, UnlockRequest};
52-
pub use auth::second_factor::SecondFactor;
53-
pub use error::{AccountError, Result};
54-
pub use id::{AccountId, ProfileIx};
55-
pub use keys::dek::profile_dek;
56-
pub use keys::wallet_key::WalletKey;
57-
pub use model::{Account, AccountRecord, Profile};
58-
pub use profile_mint::ProfileMinter;
59-
pub use session::AccountSession;
60-
pub use signer::ProfileSigner;
61-
pub use store::{AccountStore, AccountStoreError};
62-
pub use unlocked::UnlockedAccount;
63-
pub use wallet::authorizer::{SpendAuthorizer, WalletOps};
64-
pub use wallet::autosend::{AutoSendPolicy, OpClassLimits, SpendOpClass, DEFAULT_PERIOD_SECONDS};
65-
pub use wallet::clock::{Clock, FixedClock, SystemClock};
66-
pub use wallet::enforcer::PolicyAuthorizer;
67-
pub use wallet::money_signer::{LocalMoneySigner, MoneySigner};
68-
pub use wallet::policy::{CustodyPolicy, HotWallet, Vault};
69-
pub use wallet::summary::{SpendRecipient, SpendSummary, SpendTier};
70-
pub use wallet::vault_move::VaultMove;
1+
//! # dig-account
2+
//!
3+
//! The DIG Network **user Account** — the fat, strictly-logical (zero-UI, headless-testable)
4+
//! encapsulation of everything an account can do.
5+
//!
6+
//! An **Account** is one master seed plus one or more **Profiles** (exactly one default). A
7+
//! **Profile** is a DID + dig-store + SMT-of-profile-info (dig-social-profile's `IdentityProfile`),
8+
//! minted and signed with the account seed's key at that profile index.
9+
//!
10+
//! This crate owns the object model, the unlock policy + keystore crypto, the in-process
11+
//! identity+money signer, per-profile key/DEK derivation, the DID+dig-store mint, and all wallet
12+
//! ops. It NEVER draws UI or drives an OS auth ceremony — the host harness (dig-app) injects a
13+
//! UI/auth provider that this crate calls back through for unlock and spend-confirm ceremonies.
14+
//!
15+
//! ## Custody split (the harness seam)
16+
//!
17+
//! dig-account is headless: it owns the account STATE machine + the crypto, but it never collects a
18+
//! password, renders a spend prompt, or drives an OS auth ceremony. The host harness (dig-app)
19+
//! implements [`AuthProvider`](auth::provider::AuthProvider) and injects it; dig-account calls back
20+
//! through that seam for every unlock and every spend confirmation. The private key never leaves the
21+
//! crate; the UI never sees a seed.
22+
//!
23+
//! See `SPEC.md` for the normative contract.
24+
//!
25+
//! ## Phase 1 status
26+
//!
27+
//! This is the PUBLIC TYPE SURFACE cut: the object model, keystore (`store`), unlock policy
28+
//! (`auth::policy`), per-profile key/DEK derivation, and the money path (`wallet` — the canonical
29+
//! `WalletKey` + the concrete [`MoneySigner`](wallet::money_signer::LocalMoneySigner) over
30+
//! `dig-wallet-backend`'s `LocalSigner`, with the structured [`SpendSummary`](wallet::summary::SpendSummary))
31+
//! carry real, tested implementations. The identity-signer and mint modules still expose their FINAL
32+
//! public signatures with `todo!()` bodies, filled in a later phase.
33+
34+
// Phase 1 stubs: several modules expose final signatures with `todo!()`/`unimplemented!()` bodies.
35+
#![allow(clippy::todo)]
36+
37+
pub mod auth;
38+
pub mod error;
39+
pub mod id;
40+
pub mod keys;
41+
pub mod model;
42+
pub mod profile_mint;
43+
pub mod session;
44+
pub mod signer;
45+
pub mod store;
46+
pub mod unlocked;
47+
pub mod wallet;
48+
49+
pub use auth::factors::AuthFactors;
50+
pub use auth::policy::{AllOf, AuthPolicy, PasswordOnlyPolicy, UnlockError, UnlockGate};
51+
pub use auth::provider::{AuthProvider, SpendConfirmRequest, SpendDecision, UnlockRequest};
52+
pub use auth::second_factor::SecondFactor;
53+
pub use error::{AccountError, Result};
54+
pub use id::{AccountId, ProfileIx};
55+
pub use keys::dek::profile_dek;
56+
pub use keys::wallet_key::WalletKey;
57+
pub use model::{Account, AccountRecord, Profile};
58+
pub use profile_mint::ProfileMinter;
59+
pub use session::AccountSession;
60+
pub use signer::ProfileSigner;
61+
pub use store::{AccountStore, AccountStoreError};
62+
pub use unlocked::UnlockedAccount;
63+
pub use wallet::approval::{PendingApproval, SpendApproval, SpendRuling};
64+
pub use wallet::authorizer::WalletOps;
65+
pub use wallet::autosend::{AutoSendPolicy, OpClassLimits, SpendOpClass, DEFAULT_PERIOD_SECONDS};
66+
pub use wallet::clock::{Clock, FixedClock, SystemClock};
67+
pub use wallet::enforcer::PolicyAuthorizer;
68+
pub use wallet::money_signer::{LocalMoneySigner, MoneySigner};
69+
pub use wallet::policy::{CustodyPolicy, HotWallet, Vault};
70+
pub use wallet::summary::{SpendRecipient, SpendSummary, SpendTier};
71+
pub use wallet::vault_move::VaultMove;

0 commit comments

Comments
 (0)