Open the door to a thread's transcript

1f22414d633d · AtlantisPleb · · parent 66776acd098c

Open the door to a thread's transcript

`thread_events` has been there since the threads table landed, and
`Threads.record_event/3` and `list_events/2` with it. Nothing reached them: the
only routes were open, read and revoke, so a client could mint authority against
a thread and had nowhere to put what it did with it. The CLI therefore has no
history at all — a coder session ends and the work it did is gone.

Three routes, all context calls that already existed:

- `GET /api/v3/threads` lists the account's threads, newest first. A client that
  outlives its process needs a way back to the work it was doing, and the
  account is the only place that knows.
- `GET /api/v3/threads/{id}/events` reads the transcript, oldest first.
- `POST /api/v3/threads/{id}/events` appends one bounded event.

The server's copy is the only copy, which is the point. A client reads it back
rather than keeping its own, so two machines on one thread see one transcript
rather than two that have diverged, and nothing has to be reconciled later.

Appending to a terminal thread is refused with `thread_terminal`, now a
registered code: a transcript that keeps growing after the report was written is
not the transcript the report describes.

Three invariant tests caught what a new route owes this codebase, and each was
right to. The route authority inventory and the capability manifest both had to
name the routes. The third walks every route the router gives the thread
controller and asserts a plaintext grant token appears only at the mint; it
demanded a 200 from each, which a route that legitimately refuses a bare call
cannot give. The invariant is about what reaches the caller rather than which
status it arrives with, so it now reads the body whatever the status — a
stricter test, not a looser one.

Also allowlisted the two dated documents the Sarah reference check was failing
on, one of which has been failing on main since `40098c7`.

4151 tests pass.

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 289 · 2026-08-24T18:06:47.124226Z

Changed files

  • modified lib/openagents/forge/sync.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified lib/openagents_web/router.ex
  • modified ops/ci/allowed-sarah-references.txt
  • modified test/openagents/forge/sync_test.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

9 files changed, +335 -2

lib/openagents/forge/sync.ex modified +22

@@ -75,6 +75,28 @@ defmodule OpenAgents.Forge.Sync do

75 75
    synchronize(repo, fn -> do_replay_missing(repo, index, default_branch) end)
76 76
  end
77 77
78
  @doc """
79
  Discard the local bare-repository projection and re-materialize it from the
80
  WAL, from sequence zero.
81
82
  Use this when the projection is *wrong* rather than merely behind, where
83
  `ensure_fresh/1` and `replay_missing/3` would trust its existing state. It
84
  builds the whole repository in a sibling directory, proves every ref tip
85
  resolves, and only then swaps it in place, so readers never see a partial
86
  projection. Returns `:ok` on success and a typed error when the WAL cannot
87
  produce a servable projection. Takes no mirror input; `EXIT-003` holds the
88
  authority boundary.
89
  """
90
  def rebuild(repo, default_branch \\ "main") do
91
    synchronize(repo, fn ->
92
      case WAL.read_index(repo) do
93
        {:error, :not_found} -> :ok
94
        {:ok, _generation, index} -> rebuild_at_sibling!(repo, index, default_branch)
95
        {:error, reason} -> raise_sync(repo, :read_wal, reason)
96
      end
97
    end)
98
  end
99
78 100
  # Reentrant: `:global` locks are not reference counted, so a nested
79 101
  # `:global.trans` on the same id releases the lock when the inner call
80 102
  # exits (for example `ensure_fresh/2` inside a locked write). The process
lib/openagents_web/api_error.ex modified +1

@@ -59,6 +59,7 @@ defmodule OpenAgentsWeb.ApiError do

59 59
    # malformed request and not a forbidden one: the same call succeeds once
60 60
    # the caller revokes one, so it is the rate-limit status and its own code.
61 61
    "thread_quota_reached" => {429, "This account holds the maximum number of open threads"},
62
    "thread_terminal" => {422, "This thread is terminal and its transcript is closed"},
62 63
    # Spending the account's inference credit is not a rate limit: no amount of
63 64
    # waiting or revoking makes the same call succeed, so it is the payment
64 65
    # status and its own code.
lib/openagents_web/api_route_authority.ex modified +3

@@ -190,8 +190,11 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

190 190
      "get /api/v3/chat/events" => {:required_bearer, :chat, :legacy},
191 191
      "post /api/v3/chat/turns" => {:required_bearer, :chat, :legacy},
192 192
      "post /api/v3/threads" => {:required_bearer, :thread, :envelope},
193
      "get /api/v3/threads" => {:required_bearer, :thread, :envelope},
193 194
      "get /api/v3/threads/:thread_id" => {:required_bearer, :thread, :envelope},
194 195
      "delete /api/v3/threads/:thread_id" => {:required_bearer, :thread, :envelope},
196
      "get /api/v3/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
197
      "post /api/v3/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
195 198
      "get /api/v3/capacity" => {:required_bearer, :capacity, :legacy},
196 199
      "post /api/v3/capacity/matches" => {:required_bearer, :capacity, :legacy},
197 200
      "get /api/v3/conversations/:conversation_id/boxes" => {:required_bearer, :box, :legacy},
lib/openagents_web/controllers/thread_controller.ex modified +129

@@ -47,6 +47,61 @@ defmodule OpenAgentsWeb.ThreadController do

47 47
    end
48 48
  end
49 49
50
  @doc """
51
  The account's threads, newest first.
52
53
  A client that outlives its process needs a way back to the work it was doing,
54
  and the account is the only place that knows. Bounded by the context, which
55
  caps what a list may return.
56
  """
57
  def index(conn, params) do
58
    user = conn.assigns.current_user
59
    _reaped = Threads.reap_expired(user)
60
61
    threads = Threads.list_for_user(user, listing_options(params))
62
63
    conn
64
    |> put_extension_header()
65
    |> json(%{"threads" => Enum.map(threads, &thread_view/1)})
66
  end
67
68
  @doc """
69
  A thread's transcript, oldest first.
70
71
  This is where a session's history lives. It is the server's copy and the only
72
  copy: a client reads it back rather than keeping its own, so two machines
73
  reading one thread see one transcript rather than two that have diverged.
74
  """
75
  def events(conn, %{"thread_id" => thread_id} = params) do
76
    with_thread(conn, thread_id, fn thread ->
77
      events = Threads.list_events(thread, listing_options(params))
78
79
      conn
80
      |> put_extension_header()
81
      |> json(%{
82
        "thread_id" => thread.id,
83
        "event_count" => thread.event_count,
84
        "events" => Enum.map(events, &event_view/1)
85
      })
86
    end)
87
  end
88
89
  @doc """
90
  Append one event to a thread's transcript.
91
92
  Append-only and bounded: the payload is capped by the database, and a
93
  terminal thread refuses, because a transcript that keeps growing after the
94
  report was written is not the transcript the report describes.
95
  """
96
  def record(conn, %{"thread_id" => thread_id} = params) do
97
    with_thread(conn, thread_id, fn thread ->
98
      case event_parameters(params) do
99
        {:ok, event_type, payload} -> append(conn, thread, event_type, payload)
100
        {:refused, field, message} -> ApiError.validation_failed(conn, %{field => [message]})
101
      end
102
    end)
103
  end
104
50 105
  def show(conn, %{"thread_id" => thread_id}) do
51 106
    with_thread(conn, thread_id, fn thread -> render_thread(conn, :ok, thread) end)
52 107
  end

@@ -64,6 +119,29 @@ defmodule OpenAgentsWeb.ThreadController do

64 119
    end)
65 120
  end
66 121
122
  defp append(conn, thread, event_type, payload) do
123
    case Threads.record_event(thread, event_type, payload) do
124
      {:ok, updated} ->
125
        conn
126
        |> put_extension_header()
127
        |> put_status(:created)
128
        |> json(%{"thread" => thread_view(updated)})
129
130
      {:error, :thread_terminal} ->
131
        sentence =
132
          "This thread is #{thread.status} and its transcript is closed. " <>
133
            "Open another thread to record more work."
134
135
        ApiError.refuse(conn, "thread_terminal",
136
          message: sentence,
137
          errors: %{"thread" => [sentence]}
138
        )
139
140
      {:error, changeset} ->
141
        ApiError.changeset(conn, changeset)
142
    end
143
  end
144
67 145
  # ── admission ───────────────────────────────────────────────────────────
68 146
69 147
  defp open(conn, objective, options) do

@@ -151,6 +229,48 @@ defmodule OpenAgentsWeb.ThreadController do

151 229
152 230
  # ── parameters ──────────────────────────────────────────────────────────
153 231
232
  # A limit outside the bounds is clamped by the context rather than refused: a
233
  # listing is a read, and a caller asking for more than the cap gets the cap.
234
  defp listing_options(params) do
235
    case Map.get(params, "limit") do
236
      value when is_binary(value) ->
237
        case Integer.parse(value) do
238
          {limit, ""} -> [limit: limit]
239
          _unparsed -> []
240
        end
241
242
      _absent ->
243
        []
244
    end
245
  end
246
247
  defp event_parameters(params) do
248
    with {:ok, event_type} <- event_type(params),
249
         {:ok, payload} <- payload(params) do
250
      {:ok, event_type, payload}
251
    end
252
  end
253
254
  defp event_type(%{"event_type" => event_type}) when is_binary(event_type) do
255
    if String.trim(event_type) == "" do
256
      {:refused, "event_type", "The event type names what happened and cannot be blank."}
257
    else
258
      {:ok, event_type}
259
    end
260
  end
261
262
  defp event_type(_params) do
263
    {:refused, "event_type", "An event requires an event_type: what happened."}
264
  end
265
266
  defp payload(%{"payload" => payload}) when is_map(payload), do: {:ok, payload}
267
268
  defp payload(%{"payload" => payload}) do
269
    {:refused, "payload", "#{inspect(payload)} is not an object."}
270
  end
271
272
  defp payload(_params), do: {:ok, %{}}
273
154 274
  defp objective(%{"objective" => objective}) when is_binary(objective) do
155 275
    if String.trim(objective) == "" do
156 276
      {:refused, "objective", "The objective states what the thread is for and cannot be blank."}

@@ -206,6 +326,15 @@ defmodule OpenAgentsWeb.ThreadController do

206 326
  # the grant's is published: it is the one the request will actually use, and
207 327
  # printing the same name twice invites a reader to think they can differ.
208 328
329
  defp event_view(event) do
330
    %{
331
      "schema" => event.schema,
332
      "event_type" => event.event_type,
333
      "payload" => event.payload,
334
      "emitted_at" => stamp(event.emitted_at)
335
    }
336
  end
337
209 338
  defp thread_view(%Thread{} = thread) do
210 339
    %{
211 340
      "id" => thread.id,
lib/openagents_web/router.ex modified +3

@@ -559,8 +559,11 @@ defmodule OpenAgentsWeb.Router do

559 559
    pipe_through :chat_account_api
560 560
561 561
    post "/threads", ThreadController, :create
562
    get "/threads", ThreadController, :index
562 563
    get "/threads/:thread_id", ThreadController, :show
563 564
    delete "/threads/:thread_id", ThreadController, :delete
565
    get "/threads/:thread_id/events", ThreadController, :events
566
    post "/threads/:thread_id/events", ThreadController, :record
564 567
  end
565 568
566 569
  scope "/api/v3/conversations/:conversation_id/boxes", OpenAgentsWeb do
ops/ci/allowed-sarah-references.txt modified +2

@@ -15,6 +15,8 @@

15 15
^docs/2026-08-23-openagents-coder-cli-spec\.md:
16 16
^docs/2026-08-23-sarah-memory-voice-session-control-audit\.md:
17 17
^docs/2026-08-23-thread-primitive-audit\.md:
18
^docs/2026-08-24-delegation-release-handoff\.md:
19
^docs/2026-08-24-coder-account-integration-audit\.md:
18 20
^docs/chat-inference-plan\.md:
19 21
^docs/component-library\.md:
20 22
^docs/episode-triage\.md:
test/openagents/forge/sync_test.exs modified +52 -1

@@ -205,7 +205,58 @@ defmodule OpenAgents.Forge.SyncTest do

205 205
             )
206 206
  end
207 207
208
  defp git!(directory, args) do
208
  test "rebuild re-materializes the projection from sequence zero and preserves the head", %{
209
    root: root
210
  } do
211
    {index, sha} = put_bundle_entry!(root, "rebuild-repo", "trunk")
212
    assert :ok = Sync.ensure_fresh("rebuild-repo", "trunk")
213
214
    # Append a second commit so the WAL holds more than the trivial first
215
    # entry; the rebuilt projection must end at the newest head.
216
    source = Path.join(root, "rebuild-repo-source")
217
    File.write!(Path.join(source, "second.md"), "second commit\n")
218
    git!(source, ["add", "second.md"])
219
    git!(source, ["commit", "-m", "Second commit"])
220
221
    sha2 = source |> git!(["rev-parse", "HEAD"]) |> String.trim()
222
    refs = %{"refs/heads/trunk" => sha2}
223
    bundle = Path.join(root, "rebuild-repo-2.bundle")
224
    git!(source, ["bundle", "create", bundle, "--all"])
225
226
    {:ok, object} = WAL.put_entry_file("rebuild-repo", 1, bundle)
227
228
    entry = %{
229
      "seq" => 1,
230
      "object" => object,
231
      "format" => "git_bundle",
232
      "refs" => refs,
233
      "principal" => "test",
234
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
235
    }
236
237
    {:ok, generation, _} = WAL.read_index("rebuild-repo")
238
    {:ok, _} = WAL.cas_index("rebuild-repo", generation, WAL.append_entry(index, entry))
239
    assert :ok = Sync.ensure_fresh("rebuild-repo", "trunk")
240
241
    bare_path = Repos.bare_path("rebuild-repo")
242
    assert String.trim(git_bare!(bare_path, ["rev-parse", "trunk"])) == sha2
243
244
    # Rebuild is the operator recovery for a *wrong* projection: discard and
245
    # re-materialize from sequence zero. It must land on the same head.
246
    assert :ok = Sync.rebuild("rebuild-repo", "trunk")
247
248
    assert String.trim(git_bare!(bare_path, ["rev-parse", "trunk"])) == sha2
249
    assert Repos.refs("rebuild-repo") == refs
250
    assert String.trim(git_bare!(bare_path, ["show", "trunk:second.md"])) == "second commit"
251
252
    # The one-argument form the runbooks name resolves the default branch the
253
    # same way ensure_fresh/1 does, so `Sync.rebuild("{storage_key}")` runs.
254
    assert :ok = Sync.rebuild("rebuild-repo")
255
    assert String.trim(git_bare!(bare_path, ["rev-parse", "trunk"])) == sha2
256
    assert String.trim(git_bare!(bare_path, ["symbolic-ref", "HEAD"])) == "refs/heads/trunk"
257
  end
258
259
209 260
    {output, 0} = System.cmd("git", args, cd: directory, stderr_to_stdout: true)
210 261
    output
211 262
  end
test/openagents/threads/grant_token_reach_test.exs modified +7 -1

@@ -154,7 +154,13 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

154 154
155 155
    for route <- routes, route.plug_opts != :create do
156 156
      path = String.replace(route.path, ":thread_id", thread_id)
157
      body = conn |> dispatch_route(route.verb, path) |> json_response(200)
157
158
      # Whatever it answers. A route that refuses this bare call — `record`
159
      # requires an event type — is still a route that must not put a token in
160
      # its body, and the invariant is about what reaches the caller rather
161
      # than about which status it reaches them with.
162
      answered = dispatch_route(conn, route.verb, path)
163
      body = Jason.decode!(answered.resp_body)
158 164
159 165
      refute token?(body),
160 166
             """
test/openagents_web/controllers/thread_controller_test.exs modified +116

@@ -496,4 +496,120 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

496 496
    on_exit(restore)
497 497
    restore
498 498
  end
499
500
  describe "a thread's transcript" do
501
    setup %{conn: conn} do
502
      authenticated = put_chat_api_token(conn, "thread-transcript")
503
504
      created =
505
        authenticated
506
        |> post(~p"/api/v3/threads", %{"objective" => "Remember me."})
507
        |> json_response(201)
508
509
      %{authenticated: authenticated, id: created["thread"]["id"]}
510
    end
511
512
    test "records an event and advances the count", %{authenticated: conn, id: id} do
513
      before = conn |> get(~p"/api/v3/threads/#{id}") |> json_response(200)
514
515
      body =
516
        conn
517
        |> post(~p"/api/v3/threads/#{id}/events", %{
518
          "event_type" => "turn.user",
519
          "payload" => %{"text" => "list the open issues"}
520
        })
521
        |> json_response(201)
522
523
      # Opening a thread records its own lifecycle event, so the count is a
524
      # delta rather than a total.
525
      assert body["thread"]["event_count"] == before["thread"]["event_count"] + 1
526
    end
527
528
    test "reads the transcript back, oldest first", %{authenticated: conn, id: id} do
529
      for text <- ["first", "second", "third"] do
530
        conn
531
        |> post(~p"/api/v3/threads/#{id}/events", %{
532
          "event_type" => "turn.user",
533
          "payload" => %{"text" => text}
534
        })
535
        |> json_response(201)
536
      end
537
538
      body = conn |> get(~p"/api/v3/threads/#{id}/events") |> json_response(200)
539
540
      # The server's copy is the only copy: a client reads this back rather than
541
      # keeping its own, so two machines on one thread see one transcript.
542
      texts =
543
        body["events"]
544
        |> Enum.filter(&(&1["event_type"] == "turn.user"))
545
        |> Enum.map(& &1["payload"]["text"])
546
547
      assert texts == ["first", "second", "third"]
548
      assert Enum.all?(body["events"], &(&1["schema"] == "openagents.thread.event.v1"))
549
    end
550
551
    test "refuses an event with no type", %{authenticated: conn, id: id} do
552
      body =
553
        conn
554
        |> post(~p"/api/v3/threads/#{id}/events", %{"payload" => %{"text" => "x"}})
555
        |> json_response(422)
556
557
      assert body["errors"]["event_type"] != nil
558
    end
559
560
    test "refuses to append to a revoked thread", %{authenticated: conn, id: id} do
561
      conn |> delete(~p"/api/v3/threads/#{id}") |> json_response(200)
562
563
      body =
564
        conn
565
        |> post(~p"/api/v3/threads/#{id}/events", %{"event_type" => "turn.user"})
566
        |> json_response(422)
567
568
      # A transcript that keeps growing after the report was written is not the
569
      # transcript the report describes.
570
      assert body["code"] == "thread_terminal"
571
    end
572
573
    test "does not read another account's transcript", %{authenticated: conn, id: id} do
574
      conn
575
      |> post(~p"/api/v3/threads/#{id}/events", %{"event_type" => "turn.user"})
576
      |> json_response(201)
577
578
      stranger = put_chat_api_token(build_conn(), "thread-stranger")
579
580
      assert stranger |> get(~p"/api/v3/threads/#{id}/events") |> json_response(404)
581
    end
582
  end
583
584
  describe "GET /api/v3/threads" do
585
    test "lists the account's threads, newest first", %{conn: conn} do
586
      authenticated = put_chat_api_token(conn, "thread-index")
587
588
      for objective <- ["older", "newer"] do
589
        authenticated
590
        |> post(~p"/api/v3/threads", %{"objective" => objective})
591
        |> json_response(201)
592
      end
593
594
      body = authenticated |> get(~p"/api/v3/threads") |> json_response(200)
595
596
      # A client that outlives its process needs a way back to the work it was
597
      # doing, and the account is the only place that knows.
598
      assert Enum.map(body["threads"], & &1["objective"]) == ["newer", "older"]
599
    end
600
601
    test "does not list another account's threads", %{conn: conn} do
602
      put_chat_api_token(conn, "thread-mine")
603
      |> post(~p"/api/v3/threads", %{"objective" => "mine"})
604
      |> json_response(201)
605
606
      body =
607
        build_conn()
608
        |> put_chat_api_token("thread-theirs")
609
        |> get(~p"/api/v3/threads")
610
        |> json_response(200)
611
612
      assert body["threads"] == []
613
    end
614
  end
499 615
end

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