Let a resumed coder session re-mint its thread's authority

f6723c80c858 · AtlantisPleb · · parent 5125d3f0a0f4

Let a resumed coder session re-mint its thread's authority

POST /api/v3/threads/{id}/grants exposes Threads.mint_grant/1: revoke
every active grant, bump the generation fence, and hand back fresh
authority on the same thread, with the plaintext token appearing
exactly once in the response, like the open route. A terminal thread
refuses with thread_terminal; credit exhaustion and foreign threads
answer like their siblings. Found by the CLI resume work
(OpenAgentsInc/openagents#24), which already calls this route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 301 · 2026-08-24T20:13:46.155239Z

Changed files

  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

4 files changed, +93 -0

lib/openagents_web/api_route_authority.ex modified +1

@@ -196,6 +196,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

196 196
      "delete /api/v3/threads/:thread_id" => {:required_bearer, :thread, :envelope},
197 197
      "get /api/v3/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
198 198
      "post /api/v3/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
199
      "post /api/v3/threads/:thread_id/grants" => {:required_bearer, :thread, :envelope},
199 200
      "get /api/v3/capacity" => {:required_bearer, :capacity, :legacy},
200 201
      "post /api/v3/capacity/matches" => {:required_bearer, :capacity, :legacy},
201 202
      "get /api/v3/conversations/:conversation_id/boxes" => {:required_bearer, :box, :legacy},
lib/openagents_web/controllers/thread_controller.ex modified +38

@@ -120,6 +120,44 @@ defmodule OpenAgentsWeb.ThreadController do

120 120
    end)
121 121
  end
122 122
123
  def mint(conn, %{"thread_id" => thread_id}) do
124
    with_thread(conn, thread_id, fn thread ->
125
      # Re-minting is the resume fence: it revokes every active grant, bumps
126
      # the generation, and hands back fresh authority on the same thread, so
127
      # a resumed session can never race a zombie of its former self
128
      # (THREAD-001). The plaintext token exists exactly once, in this
129
      # response, like the one `POST /api/v3/threads` returns.
130
      case Threads.mint_grant(thread) do
131
        {:ok, minted, grant, token} ->
132
          conn
133
          |> put_extension_header()
134
          |> put_status(:created)
135
          |> json(%{"thread" => thread_view(minted), "grant" => minted_view(grant, token)})
136
137
        {:error, :thread_terminal} ->
138
          sentence =
139
            "This thread is #{thread.status} and holds no authority to re-mint. " <>
140
              "Open another thread instead."
141
142
          ApiError.refuse(conn, "thread_terminal",
143
            message: sentence,
144
            errors: %{"thread" => [sentence]}
145
          )
146
147
        {:error, :credit_exhausted} ->
148
          credit_exhausted(conn)
149
150
        {:error, %Ecto.Changeset{} = changeset} ->
151
          ApiError.changeset(conn, changeset)
152
153
        {:error, _reason} ->
154
          ApiError.validation_failed(conn, %{
155
            "thread" => ["The grant could not be minted. Try again."]
156
          })
157
      end
158
    end)
159
  end
160
123 161
  defp append(conn, thread, event_type, payload) do
124 162
    case Threads.record_event(thread, event_type, payload) do
125 163
      {:ok, updated} ->
lib/openagents_web/router.ex modified +1

@@ -570,6 +570,7 @@ defmodule OpenAgentsWeb.Router do

570 570
    delete "/threads/:thread_id", ThreadController, :delete
571 571
    get "/threads/:thread_id/events", ThreadController, :events
572 572
    post "/threads/:thread_id/events", ThreadController, :record
573
    post "/threads/:thread_id/grants", ThreadController, :mint
573 574
  end
574 575
575 576
  scope "/api/v3/conversations/:conversation_id/boxes", OpenAgentsWeb do
test/openagents_web/controllers/thread_controller_test.exs modified +53

@@ -700,4 +700,57 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

700 700
      assert body["threads"] == []
701 701
    end
702 702
  end
703
704
  describe "POST /api/v3/threads/:thread_id/grants" do
705
    test "re-mints authority on the same thread and revokes the old grant", %{conn: conn} do
706
      authenticated = put_chat_api_token(conn, "thread-remint")
707
708
      created =
709
        authenticated
710
        |> post(~p"/api/v3/threads", %{"objective" => "Resume me."})
711
        |> json_response(201)
712
713
      id = created["thread"]["id"]
714
      old_token = created["grant"]["token"]
715
      assert {:ok, _usable} = Inference.resolve(old_token)
716
717
      body = authenticated |> post(~p"/api/v3/threads/#{id}/grants") |> json_response(201)
718
719
      assert body["thread"]["id"] == id
720
      assert body["thread"]["status"] == "open"
721
      new_token = body["grant"]["token"]
722
      assert is_binary(new_token) and new_token != old_token
723
      assert {:ok, _usable} = Inference.resolve(new_token)
724
      assert {:error, :grant_revoked} = Inference.resolve(old_token)
725
    end
726
727
    test "a terminal thread refuses with thread_terminal", %{conn: conn} do
728
      authenticated = put_chat_api_token(conn, "thread-remint-terminal")
729
730
      created =
731
        authenticated
732
        |> post(~p"/api/v3/threads", %{"objective" => "Close me first."})
733
        |> json_response(201)
734
735
      id = created["thread"]["id"]
736
      _cancelled = authenticated |> delete(~p"/api/v3/threads/#{id}") |> json_response(200)
737
738
      refused = authenticated |> post(~p"/api/v3/threads/#{id}/grants") |> json_response(422)
739
      assert refused["code"] == "thread_terminal"
740
    end
741
742
    test "another account's thread is not found", %{conn: conn} do
743
      owner = put_chat_api_token(conn, "thread-remint-owner")
744
745
      created =
746
        owner
747
        |> post(~p"/api/v3/threads", %{"objective" => "Mine alone."})
748
        |> json_response(201)
749
750
      id = created["thread"]["id"]
751
752
      stranger = put_chat_api_token(recycle(conn), "thread-remint-stranger")
753
      assert stranger |> post(~p"/api/v3/threads/#{id}/grants") |> json_response(404)
754
    end
755
  end
703 756
end

This page updates live while a promote is in flight · changelog