Skip to content

Commit fd49f0f

Browse files
fix(spec): a user's refusal and a structural refusal are distinct outcomes
SPEC.md's wire table gave SPEND_DENIED -33053 to a declined ceremony and SPEND_NOT_AUTHORIZED -33052 to PolicyDenied, while the decline path already returned PolicyDenied -- so the normative mapping named two codes for one value and no conforming host could exist. The table now keys every row on a distinct AccountError variant, and states that it MUST be a function of the outcome alone. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2fa4753 commit fd49f0f

3 files changed

Lines changed: 70 additions & 8 deletions

File tree

SPEC.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ test double drives the real gate with a test policy and the public `FixedClock`.
292292
|---|---|---|
293293
| `Ok(SpendRuling::Approved(approval))` | Auto-approved by policy; the rolling cap is ALREADY charged its real value | — (already permitted) |
294294
| `Ok(SpendRuling::RequiresConfirmation(pending))` | Not auto-approved, but the user MAY permit it. Nothing charged | YES — this IS the escalation |
295-
| `Err(AccountError::PolicyDenied)` | Forbidden by a structural custody rule, or the user declined | NO |
295+
| `Err(AccountError::PolicyDenied)` | Forbidden by a structural custody rule | NO |
296+
| `Err(AccountError::UserDeclined)` | The user was asked and refused | NO — the human has already answered |
296297
| `Err(AccountError::PolicyIndeterminate)` | The policy could not be EVALUATED | NO — the condition must be fixed |
297298

298299
**The escalatable outcome MUST be an `Ok` value, not an error.** A return type that can only say "yes"
@@ -303,8 +304,11 @@ them can lose detail but can no longer lose a permission.
303304

304305
`PendingApproval::confirmed(SpendDecision) -> Result<SpendApproval>` is the ONLY route from "needs a
305306
human" to a signable approval. It MUST consume `self`, so one prompt yields at most one approval, and it
306-
MUST return `PolicyDenied` on `Decline` — the human has already been asked, so re-prompting would turn a
307-
refusal into a prompt-until-mis-click. A declined or escalated spend MUST NOT consume the rolling
307+
MUST return `UserDeclined` on `Decline`, NOT `PolicyDenied` — the human has already been asked, so
308+
re-prompting would turn a refusal into a prompt-until-mis-click, and the two facts have different
309+
deciders. An implementation MUST NOT return one variant for both: a host holding a single variant cannot
310+
tell "you said no" from "the rules say no", cannot render an honest UI, and cannot satisfy §6.3.1's
311+
mapping, which requires each outcome to name exactly one wire code. A declined or escalated spend MUST NOT consume the rolling
308312
allowance; nor does a human-confirmed one, since that cap bounds what moves UNATTENDED.
309313

310314
An implementation MUST NOT collapse "denied by policy" with "could not determine policy": doing so both
@@ -317,12 +321,18 @@ taxonomy itself lives in the host's loopback layer rather than in this crate; th
317321

318322
| Outcome | Wire code | Escalatable? |
319323
|---|---|---|
320-
| `RequiresConfirmation`, then the user declines | `SPEND_DENIED` `-33053` | |
321-
| `Err(PolicyDenied)` | `SPEND_NOT_AUTHORIZED` `-33052` | NO |
324+
| `Err(UserDeclined)` (the ceremony ran and the user refused) | `SPEND_DENIED` `-33053` | NO |
325+
| `Err(PolicyDenied)` (a structural custody rule forbids it) | `SPEND_NOT_AUTHORIZED` `-33052` | NO |
322326
| `Err(PolicyIndeterminate)` | `SPEND_POLICY_INDETERMINATE` `-33056` | NO |
323327
| verify failure (`Err(AccountError::Spend)`) | `SPEND_REFUSED` `-33051` | NO |
324328
| decode failure, or a forbidden field present | `SPEND_BAD_PAYLOAD` `-33050` | NO |
325329

330+
**This mapping MUST be a function of the crate outcome alone.** Each row's left-hand side is a distinct
331+
`AccountError` variant, so a host maps by matching the variant and never by reconstructing which decider
332+
refused. An earlier revision keyed one row on a SEQUENCE of events ("`RequiresConfirmation`, then the user
333+
declines") while another keyed on `PolicyDenied`, and both resolved to `PolicyDenied` — so the table named
334+
two codes for one value and a conforming host could not exist. `UserDeclined` is what makes it total.
335+
326336
`SPEND_POLICY_INDETERMINATE` `-33056` MUST exist as its own code. Without it, "the policy could not be
327337
evaluated" collapses into `SPEND_NOT_AUTHORIZED` — the very defect §6.3 forbids, one layer up.
328338

src/error.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ pub enum AccountError {
5050
#[error("the user declined the spend: {0}")]
5151
UserDeclined(String),
5252

53-
/// The spend is FORBIDDEN by a structural custody rule, or the user declined it — no (further)
54-
/// confirmation ceremony can permit it.
53+
/// The spend is FORBIDDEN by a structural custody rule — no confirmation ceremony can permit it.
54+
///
55+
/// A user's refusal is NOT this variant; it is [`UserDeclined`](Self::UserDeclined). Both are
56+
/// terminal, but they name different deciders and `SPEC.md` §6.3.1 maps them to different wire
57+
/// codes, so a host must be able to tell them apart.
5558
///
5659
/// # The escalatable outcome is deliberately NOT an error
5760
///

src/wallet/enforcer.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1778,7 +1778,11 @@ mod tests {
17781778
StandardLayer::new(spender().public_key())
17791779
.spend(
17801780
&mut ctx,
1781-
Coin::new(Bytes32::new([9u8; 32]), spender().puzzle_hash(), coin_amount),
1781+
Coin::new(
1782+
Bytes32::new([9u8; 32]),
1783+
spender().puzzle_hash(),
1784+
coin_amount,
1785+
),
17821786
conditions,
17831787
)
17841788
.unwrap();
@@ -1851,4 +1855,49 @@ mod tests {
18511855
);
18521856
let _ = pending(ruling);
18531857
}
1858+
1859+
/// **SPEC §6.3.1 CONFORMANCE: the two refusals a host must distinguish are different variants.**
1860+
///
1861+
/// The wire mapping gives `SPEND_DENIED -33053` to a user's refusal and `SPEND_NOT_AUTHORIZED
1862+
/// -33052` to a structural one. That mapping is only a function if the crate hands back a
1863+
/// different value for each, and for one revision it did not: both arrived as `PolicyDenied`, so
1864+
/// the table named two codes for one value and no conforming host could exist.
1865+
///
1866+
/// Both halves are produced HERE, in one test, from ONE gate. Two separate tests each asserting
1867+
/// its own variant would pass just as happily if a refactor merged the variants and only one of
1868+
/// them was updated — it is the DIFFERENCE that the host depends on, so the difference is what is
1869+
/// asserted.
1870+
#[tokio::test]
1871+
async fn a_user_refusal_and_a_structural_refusal_are_distinguishable_outcomes() {
1872+
// Structural: a vault outflow may only ever pay this profile's own hot wallet.
1873+
let vault = gate_with(vault_custody(), permissive_auto_send());
1874+
let structural = refusal(vault.authorize_op(&pays_third_party(1_000), SpendOpClass::Tip));
1875+
1876+
// Human: the same gate escalates a payment to its OWN hot wallet, and the user says no.
1877+
let escalated = pending(vault.authorize_op(
1878+
&spend_paying(&[(hot_wallet().puzzle_hash(), 1_000)], 0),
1879+
SpendOpClass::Tip,
1880+
));
1881+
let human = denial(
1882+
confirmed(
1883+
escalated,
1884+
crate::auth::provider::SpendDecision::Decline(None),
1885+
)
1886+
.await,
1887+
);
1888+
1889+
assert!(
1890+
matches!(structural, AccountError::PolicyDenied(_)),
1891+
"a structural refusal must map to SPEND_NOT_AUTHORIZED -33052: {structural}"
1892+
);
1893+
assert!(
1894+
matches!(human, AccountError::UserDeclined(_)),
1895+
"a user's refusal must map to SPEND_DENIED -33053: {human}"
1896+
);
1897+
assert_ne!(
1898+
std::mem::discriminant(&structural),
1899+
std::mem::discriminant(&human),
1900+
"the wire mapping is a function only if these are different values; collapsing them leaves a host unable to tell 'you said no' from 'the rules say no'"
1901+
);
1902+
}
18541903
}

0 commit comments

Comments
 (0)