• v0.30.0 ecc1f7a8b1

    v0.30.0 — destroying mail becomes its own tool, its own grant, its own decision
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-22 13:01:04 -05:00 | 9 commits to main since this release

    M6. Destroying mail is its own tool, its own grant, and its own decision.
    delete_message(permanent=true) is gone; expunge_message replaces it. Gmail's write phase
    (G2) is what forced the question — its delete_message had to be specified before it was
    written — and the answer applies to the tenant that already shipped one.

    Breaking, and in the way that costs a minute: the mail connector's tool list changed, so
    reconnect it — Claude caches tool descriptions per connector and does not refetch them
    because a container restarted. Per-tool permission toggles are keyed to tool names, so
    expunge_message arrives at its default and delete_message's existing toggle survives.
    Same shape as 0.14.0's rename, and the same reason it ships on its own rather than riding
    along with the next change.

    Changed

    • delete_message moves to trash and does nothing else. No permanent, no
      expected_subject, one argument. It is the tool a model reaches for when a user says
      "delete", and it can no longer do anything a user would not expect from that word.

    Added

    • expunge_message, behind MAIL_EXPUNGE_ENABLED. \Deleted + UID EXPUNGE on one
      message, expected_subject required rather than conditionally required, and no
      message_id in the response because there is nothing left to name.

      A boolean was the weakest available gate on the one operation with no undo — one token
      in a JSON object, on the same tool as the safe operation, described in a paragraph
      appended to a description whose opening sentence was about moving to trash. Splitting buys
      three things a parameter cannot: a description where every sentence is about
      irreversibility, a grant that can be given and withheld separately, and a decision visible
      in the call rather than buried in an argument.

    • The gate nests, and it is the only one here that does. MAIL_EXPUNGE_ENABLED layers
      over MAIL_WRITABLE_FOLDERS rather than sitting parallel to it: that list says which
      folders may be changed, this says whether destroying is permitted at all, and an expunge
      is both. Turning it on alone registers nothing. While it was a boolean, naming a folder
      writable was already consent to destroy what was in it
      , with nothing in the boot log to
      say so — the grants line now carries expunge=on|off.

      MAIL_FOLDER_ADMIN deliberately does not nest, and the two are not inconsistent:
      managing a collection is a different kind of grant, while destroying is a stronger degree
      of the same one.

    Notes

    • CLAUDE.md said not to do this, and now says when to. "Do not split a tool to dodge
      a bad argument"
      delete_event keeps this/following/all in one parameter, and
      that rule stands. The distinction is written down rather than left to whoever remembers
      this release: delete_event's scope is one operation with three equally-undoable targets,
      while delete-versus-expunge is two operations that differ in whether the mail still
      exists. The test is not could this be one argument — it usually could — but does the
      choice change what a wrong call costs, and would an operator grant one side without the
      other.
      Two yeses is two tools.

    • Named expunge_message, not delete_message_permanently. Two names differing only by
      a suffix are the easiest pair to confuse, which defeats the split. Expunge is IMAP's own
      verb, so nothing about "delete this email" reads as selecting it by accident.

    • A test tried to assert the wrong property and the fake refused it. "A delete never
      expunges" is false about a correct implementation: iCloud advertises no MOVE, so a move
      is COPY, flag the original, expunge the original. The COPY is the whole difference —
      one call leaves the message somewhere and the other does not — and that is what is pinned
      instead.

    • The trash-missing error no longer offers the irreversible path as the way out. It read
      "or pass permanent=true to remove the message outright", which is a suggestion to
      destroy mail because a configuration value is unset. It now names TRASH_FOLDER and says
      explicitly not to reach for expunge_message instead.

    • Not yet run live. M6's live pass is written up in docs/operations.md beside M1–M5
      and is owed before this is trusted on real mail — including the two absence checks, which
      no unit test can confirm on the deployed service.

    Downloads