Skip to content

design: settle the four remaining UNDECIDED cases - #36

Merged
jayteemoney merged 1 commit into
mainfrom
design/undecided-cases
Aug 14, 2026
Merged

design: settle the four remaining UNDECIDED cases#36
jayteemoney merged 1 commit into
mainfrom
design/undecided-cases

Conversation

@jayteemoney

@jayteemoney jayteemoney commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #32. Second of Track A (#34). Nothing in behaviour.md is marked UNDECIDED any more — 34 scenarios, up from 31.

The interesting one is case 2, because the issue's premise was wrong

behaviour.md described cancel-after-end as probably harmless: "functionally a no-op cancel (refund = 0, since nothing is unstreamed) would be harmless if allowed."

That holds only when every milestone is resolved. With a milestone still unmet, the tranche has streamed in full but is entirely held, and the unapproved-milestone rule returns the whole thing to the sender:

at now >= end, milestone never approved
  streamed_total = 30,000,000,000   held = 12,000,000,000   withdrawn = 18,000,000,000
  claimable      = 0
  cancel() -> refund to sender = 12,000,000,000     <-- not zero

So it's a real transfer, and the decision matters more than the entry suggested. Permitted, because it is the only in-protocol way to resolve a milestone nobody ever approved — rejecting it would strand the tranche permanently, which is threat-model T3 arriving by a second route. The lifecycle diagram gains the Completed --> Canceled arrow it was missing.

Knock-on: this is a strong argument for creating grant streams cancelable, now stated in concepts.md.

The other three

Case Decision
Double approval No-op, not an error. A duplicate is overwhelmingly a retry after an uncertain outcome, and with Met terminal (#17) there's no state to protect. Erroring wouldn't catch the mistake people actually fear — approving the wrong milestone is a different call that succeeds regardless. Auth still checked first; no second event, so the indexer's fold never sees one approval twice.
Non-standard tokens Store the measured balance delta. True by construction for any asset, one extra read. Fixes fee-on-transfer completely; rebasing stays unsupported, since no creation-time measurement binds a balance that moves afterwards. Resolves threat-model T4.
Archived entry The contract never runs. Called directly, the transaction fails at the host level on the footprint — there is no contract branch to write and none could exist. An SDK obligation, not contract behaviour.

One correction to merged work

The asymmetry note added in #26 justified treating two cases differently on the grounds that the lifecycle diagram drew no arrow for either. That evidence turned out to be worth nothing on its own — the same missing arrow has now produced opposite answers for the two cases that leaned on it. The diagram was incomplete, not eloquent. Note rewritten to say so.

Changes

  • docs/specs/behaviour.md — 3 new scenarios, 2 UNDECIDED placeholders replaced with real ones, Undecided casesResolved cases with the reasoning kept
  • docs/architecture.md — lifecycle arrow; SEP-41 TODO resolved
  • docs/concepts.md — cancel-after-end rule stated
  • docs/research/threat-model.md — T4 status → Mitigated

Next in Track A: #33.

Closes #32. Nothing in behaviour.md is marked UNDECIDED any more.

1. Double approval - no-op, not an error. A duplicate is
   overwhelmingly a retry after an uncertain outcome, and with Met
   terminal there is no state to protect. Erroring would not catch
   the mistake people fear either: approving the wrong milestone is
   a different call that succeeds regardless. Authorization is still
   checked first, and no second event is emitted so the indexer's
   fold never sees one approval twice.

2. Cancel after end - permitted. behaviour.md's own framing of this
   case was wrong: it called such a cancel harmless because nothing
   is unstreamed, which holds only when every milestone is resolved.
   With a milestone unmet the tranche has streamed in full but is
   entirely held, and rule 4 returns it to the sender - 12,000,000,000
   on the worked example, not zero. Permitting the call is what makes
   a never-approved milestone recoverable; rejecting it would strand
   the tranche permanently. Lifecycle diagram gains the missing
   Completed -> Canceled arrow.

3. Non-standard tokens - store the measured balance delta. True by
   construction for any asset, one extra balance read at creation.
   Fixes fee-on-transfer; rebasing stays unsupported because no
   creation-time measurement binds a balance that moves later.

4. Archived entry - the contract never runs. Called directly the
   transaction fails at the host level on the footprint; there is no
   contract branch to write and none could exist. An SDK obligation.

Also corrects the stale asymmetry note, which cited a missing diagram
arrow as evidence. It was worth nothing on its own - the same missing
arrow produced opposite answers for the two cases that leaned on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4KmSPBuq484Zt65qsK177
@jayteemoney
jayteemoney merged commit f1eee23 into main Aug 14, 2026
3 checks passed
@jayteemoney
jayteemoney deleted the design/undecided-cases branch August 14, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

design: settle the four remaining UNDECIDED cases in behaviour.md

1 participant