Admit transcript-only local-lane threads

768f92707e1b · AtlantisPleb · · parent 6d1eb86471a2

Admit transcript-only local-lane threads

A coder run on the local lane left no record on this server: the CLI
kept its Ollama messages local and sent nothing to the threads API,
because `POST /api/v1/threads` refused the shape twice over — the
model had to be an admitted catalog id with an available provider
(PROVIDER-002), and every open minted a grant. But the server is
where everything rehydrates from — /threads, /export, resume, the
Gym's live transcripts — and a lane that leaves no record is
invisible to all of it (#243).

`"lane": "local"` now opens a transcript-only thread. The model is
the bounded vendor string the local runtime serves
(`ollama:qwen3.8:27b-mtp-q8_0`), checked against no catalog and no
provider, because no grant will ever carry it to one: the open mints
nothing, the response carries no grant and no token, and
`mint_grant/1` refuses the thread with `:thread_local_lane` —
rendered as its own stable code, like `thread_terminal` — the way it
refuses a terminal one. The no-provider-key and metering invariants
therefore hold by construction: a thread that can never be granted
can never spend the account's credit or reach a provider.

Everything else is an ordinary thread. Events append through the
same route and broadcast on the same topic, /threads/:id renders it
(the budget card already hides when there is no grant), the
transparency tier governs its readers, the open-thread cap counts it
— an open thread holds an admission slot whether or not it holds
authority — and DELETE ends it. Its thread.opened event records
`"lane": "local"`, so the transcript itself says no authority backs
it. The reaper leaves it alone: it closes threads that minted and
hold nothing, and a local thread never minted.

The lane is a column with a two-value check constraint, defaulting
to `thread` — the honest backfill, since every earlier thread came
through the granted lane — and the migration is registered in the
lineage map's new_versions_to_run. A create that names no lane is the granted
create it always was, byte for byte: same two response keys, same
mint, same catalog admission, which the regression tests now pin. A
lane outside the pair is refused with a validation error rather than
folded into a default.

Also reattaches two stray @doc attributes (models.ex, threads.ex)
that warned on every clean compile and would have failed the gate's
--warnings-as-errors on a fresh checkout, and picks up a missed
format pass in the proxy controller.

Closes #243.

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

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 368 · 2026-08-25T12:20:36.217229Z

Changed files

  • modified INVARIANTS.md
  • modified docs/taxonomy.md
  • modified lib/openagents/threads.ex
  • modified lib/openagents/threads/thread.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825120000_add_lane_to_threads.exs
  • added test/openagents/threads/local_lane_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

10 files changed, +614 -12

INVARIANTS.md modified +31 -1

@@ -1238,6 +1238,16 @@ Concretely:

1238 1238
  the effective model is attributed on the response as always, so the answer
1239 1239
  still says what produced it.
1240 1240
1241
The thread API's local lane is not an exception to this law, because no
1242
selection happens there. Amended 2026-08-25 (issue #243): `POST
1243
/api/v1/threads` with `"lane": "local"` records a bounded free-form vendor
1244
string as the thread's model without catalog admission — and mints nothing.
1245
No grant ever carries that string, the proxy never pins it, and no provider
1246
is ever asked to serve it; `OpenAgents.Threads.mint_grant/1` refuses the
1247
thread outright (THREAD-001). Every path that mints authority still admits
1248
only the catalog, so a non-catalog model can be recorded but never granted,
1249
and no turn is ever answered by it.
1250
1241 1251
The chat lane keeps the same law through `OpenAgents.Chat.Backends`: an
1242 1252
unsupported `model` on `POST /api/v1/chat/turns` is a typed `422`, and
1243 1253
`GET /api/v1` publishes the supported enum from the same list.

@@ -2165,6 +2175,24 @@ conversation, and a thread is not one.

2165 2175
  thread's active grants inside the transaction that writes the terminal row,
2166 2176
  and `mint_grant/1` refuses a thread that is not open. Deleting a thread — or
2167 2177
  the account, under the DATA-004 cascade — deletes its grants with it.
2178
- **A local-lane thread holds no authority, ever.** Added 2026-08-25 (issue
2179
  #243). `threads.lane` admits `thread` and `local` by check constraint,
2180
  defaulting to `thread` — the granted lane every earlier thread came
2181
  through. `POST /api/v1/threads` with `"lane": "local"` opens a
2182
  transcript-only thread: its `model` is the bounded vendor string a local
2183
  runtime serves (`ollama:...`), admitted against no catalog and no provider,
2184
  and the open mints nothing — the response carries no grant and no token,
2185
  and the fence never moves off generation 0.
2186
  `OpenAgents.Threads.mint_grant/1` refuses a local-lane thread with
2187
  `:thread_local_lane` — rendered as the stable code `thread_lane_local` —
2188
  the way it refuses a terminal one, so the no-provider-key and metering
2189
  invariants hold by construction: a thread that can never be granted can
2190
  never spend the account's credit or reach a provider. Everything else about
2191
  it is an ordinary thread: the transcript appends and broadcasts, the
2192
  transparency tier governs its readers, the admission cap counts it, and
2193
  `DELETE` ends it. Its `thread.opened` event records `"lane": "local"`, so
2194
  the transcript itself says no authority backs it.
2195
  `test/openagents/threads/local_lane_test.exs` proves both halves.
2168 2196
- **A thread is bounded where a bound means something.** The objective is
2169 2197
  capped at 32 KB and the terminal report at 32 KB, both by check constraint;
2170 2198
  every transcript entry is pinned to `openagents.thread.event.v1` with no

@@ -2295,8 +2323,10 @@ Evidence: `OpenAgents.Threads`, `OpenAgents.Threads.Thread`,

2295 2323
`OpenAgentsWeb.ThreadController`,
2296 2324
`priv/repo/migrations/20260823221415_create_threads_and_thread_events.exs`,
2297 2325
`priv/repo/migrations/20260823221416_allow_thread_scoped_inference_grants.exs`,
2326
`priv/repo/migrations/20260825120000_add_lane_to_threads.exs`,
2298 2327
`test/openagents/threads/grant_fence_test.exs`,
2299 2328
`test/openagents/threads/grant_token_reach_test.exs`,
2329
`test/openagents/threads/local_lane_test.exs`,
2300 2330
`test/openagents/threads_test.exs`,
2301 2331
`test/openagents_web/controllers/thread_controller_test.exs`, and
2302 2332
`test/openagents_web/live/thread_show_live_test.exs`.

@@ -5303,7 +5333,7 @@ contract; the invariant prose above defines the assertion, not the filename.

5303 5333
| WORK-001 | `test/openagents/work_job_test.exs`, `test/openagents/deep_work_tool_loop_test.exs` |
5304 5334
| SELF-EDIT-001 | `test/openagents/tools/repository_mutation_tools_test.exs`, `test/openagents/coding_job_test.exs`, `test/openagents/dependency_boundary_test.exs` |
5305 5335
| SCV-001 | `test/openagents/scv/deployments_test.exs`, `test/openagents/dependency_boundary_test.exs` |
5306
| THREAD-001 | `test/openagents/threads/grant_fence_test.exs`, `test/openagents/threads/grant_token_reach_test.exs`, `test/openagents/threads_test.exs`, `test/openagents/threads/credit_race_test.exs` |
5336
| THREAD-001 | `test/openagents/threads/grant_fence_test.exs`, `test/openagents/threads/grant_token_reach_test.exs`, `test/openagents/threads_test.exs`, `test/openagents/threads/credit_race_test.exs`, `test/openagents/threads/local_lane_test.exs` |
5307 5337
| THREAD-002 | `test/openagents/threads/visibility_test.exs`, `test/openagents_web/thread_visibility_test.exs`, `test/openagents/threads/grant_token_reach_test.exs` |
5308 5338
| THREAD-003 | `test/openagents/threads_test.exs`, `test/openagents/threads/visibility_test.exs` |
5309 5339
| OUTCOME-001 | `test/openagents/accepted_outcome_test.exs`, `test/openagents/issues/completion_claims_test.exs`, `test/openagents_web/controllers/issue_completion_claim_controller_test.exs` |
docs/taxonomy.md modified +4 -1

@@ -332,7 +332,10 @@ is `OpenAgents.Threads.Thread`, and the transcript entry is

332 332
`OpenAgents.Threads.Event`. A caller opens one with `POST /api/v1/threads`,
333 333
reads what it has spent with `GET /api/v1/threads/{thread_id}`, and revokes it
334 334
with `DELETE /api/v1/threads/{thread_id}`, all behind the `chat:account` scope
335
and served by `OpenAgentsWeb.ThreadController`. The CLI that stops writing to
335
and served by `OpenAgentsWeb.ThreadController`. A thread opened with
336
`"lane": "local"` is transcript-only: its model is the vendor string a local
337
runtime serves, and it is never granted authority — the server records the run
338
without paying for it (THREAD-001, issue #243). The CLI that stops writing to
336 339
the conversation is still to come; see the audit in
337 340
`docs/2026-08-23-thread-primitive-audit.md`.
338 341
lib/openagents/threads.ex modified +28 -6

@@ -104,6 +104,14 @@ defmodule OpenAgents.Threads do

104 104
  `OpenAgents.Threads.Thread.default_visibility/0`, owner-only. A wider tier
105 105
  given here is recorded in the transcript as `thread.visibility_set`
106 106
  (THREAD-002).
107
108
  `:lane` defaults to the granted `thread` lane. The `local` lane admits a
109
  transcript-only thread: its `:model` is the bounded vendor string a local
110
  runtime serves rather than a catalog id, its `thread.opened` event names the
111
  lane, and no grant is ever minted for it — `mint_grant/1` refuses it with
112
  `:thread_local_lane`. It still counts against the open-thread cap, because
113
  an open thread holds an admission slot whether or not it holds authority
114
  (issue #243).
107 115
  """
108 116
  @spec open(User.t() | Visitor.t(), String.t(), keyword()) ::
109 117
          {:ok, Thread.t()} | {:error, :thread_quota_reached | Ecto.Changeset.t()}

@@ -130,6 +138,7 @@ defmodule OpenAgents.Threads do

130 138
          | {:error,
131 139
             :thread_quota_reached
132 140
             | :thread_terminal
141
             | :thread_local_lane
133 142
             | :credit_exhausted
134 143
             | :parent_authority_exhausted
135 144
             | Ecto.Changeset.t()}

@@ -162,6 +171,7 @@ defmodule OpenAgents.Threads do

162 171
      objective: objective,
163 172
      repository: Keyword.get(options, :repository),
164 173
      model: Keyword.get(options, :model) || Models.default_id(),
174
      lane: Keyword.get(options, :lane, Thread.default_lane()),
165 175
      reasoning_effort:
166 176
        OpenRouter.reasoning_effort(Keyword.get(options, :reasoning, @default_reasoning)),
167 177
      permission_profile: Keyword.get(options, :permission_profile, @default_permission_profile),

@@ -217,12 +227,13 @@ defmodule OpenAgents.Threads do

217 227
      Thread.open_changeset(attributes, visitor_id, now)
218 228
    end)
219 229
    |> Multi.run(:opened_event, fn _repo, %{thread: thread} ->
220
      insert_event(
221
        thread,
222
        "thread.opened",
223
        %{"objective_bytes" => byte_size(objective), "visibility" => thread.visibility},
224
        now
225
      )
230
      # The lane rides in the record only when it is the exceptional one: a
231
      # granted thread's opened event is byte-for-byte what it always was, and
232
      # a local thread's transcript says up front that no authority backs it.
233
      payload = %{"objective_bytes" => byte_size(objective), "visibility" => thread.visibility}
234
      payload = if Thread.local?(thread), do: Map.put(payload, "lane", thread.lane), else: payload
235
236
      insert_event(thread, "thread.opened", payload, now)
226 237
    end)
227 238
    |> Multi.run(:spawn_event, fn _repo, %{admission: parent, thread: thread} ->
228 239
      if parent do

@@ -564,6 +575,13 @@ defmodule OpenAgents.Threads do

564 575
  tokens, or cost, is refused `:parent_authority_exhausted` rather than minted
565 576
  authority it cannot use.
566 577
578
  A local-lane thread is refused `:thread_local_lane` the way a terminal
579
  thread is refused `:thread_terminal`. Its model is a vendor string a local
580
  runtime serves, not an admitted catalog id, so a grant naming it would be
581
  authority no provider here can honor — and the lane's whole contract is that
582
  it holds none (issue #243). The refusal is what keeps the no-provider-key
583
  and metering invariants true by construction rather than by review.
584
567 585
  This is the fence. In one transaction: the thread is locked and refused
568 586
  unless it is open, every active grant naming it is revoked, `generation` is
569 587
  bumped, and a fresh grant is minted against the thread — never against a

@@ -573,12 +591,16 @@ defmodule OpenAgents.Threads do

573 591
          {:ok, Thread.t(), Grant.t(), String.t()}
574 592
          | {:error,
575 593
             :thread_terminal
594
             | :thread_local_lane
576 595
             | :credit_exhausted
577 596
             | :parent_authority_exhausted
578 597
             | Ecto.Changeset.t()}
579 598
  def mint_grant(%Thread{} = thread) do
580 599
    Repo.transaction(fn ->
581 600
      case locked(thread.id) do
601
        %Thread{status: "open", lane: "local"} ->
602
          Repo.rollback(:thread_local_lane)
603
582 604
        %Thread{status: "open"} = current ->
583 605
          # Concurrent mints for one account serialize on the owner row
584 606
          # (locked after the thread row, always in that order), so each mint
lib/openagents/threads/thread.ex modified +29

@@ -33,6 +33,14 @@ defmodule OpenAgents.Threads.Thread do

33 33
  @objective_bytes 32_768
34 34
  @repository_bytes 200
35 35
36
  # Which lane opened the thread. `thread` is the granted lane: the model is a
37
  # catalog id and the open mints a grant. `local` is the transcript-only lane:
38
  # the model is the vendor string a local runtime serves, and no grant is ever
39
  # minted (issue #243).
40
  @lanes ~w(thread local)
41
  @default_lane "thread"
42
  @local_lane "local"
43
36 44
  # The disclosure vocabulary is `OpenAgents.Transparency`'s — `dark`, `pulse`,
37 45
  # `ledger`, `glass` (`docs/taxonomy.md`) — and a thread offers the two rungs
38 46
  # this surface can enforce, not a fifth word of its own.

@@ -45,6 +53,7 @@ defmodule OpenAgents.Threads.Thread do

45 53
    field :objective, :string, redact: true
46 54
    field :repository, :string
47 55
    field :visibility, :string, default: "dark"
56
    field :lane, :string, default: "thread"
48 57
    field :status, :string, default: "open"
49 58
    field :model, :string
50 59
    field :reasoning_effort, :string

@@ -84,6 +93,23 @@ defmodule OpenAgents.Threads.Thread do

84 93
  @doc "The tier a thread takes when its opener names none: owner-only."
85 94
  def default_visibility, do: @default_visibility
86 95
96
  @doc """
97
  The lanes a thread may be opened on.
98
99
  `thread` is the granted lane — the model is admitted against the catalog and
100
  the open mints a grant. `local` is the transcript-only lane — the model is a
101
  bounded vendor string and no grant is ever minted, so the thread records a
102
  run whose model calls never touch this server (issue #243).
103
  """
104
  def lanes, do: @lanes
105
106
  @doc "The lane a thread takes when its opener names none: the granted one."
107
  def default_lane, do: @default_lane
108
109
  @doc "Whether `thread` is on the transcript-only local lane."
110
  @spec local?(t()) :: boolean()
111
  def local?(%__MODULE__{lane: lane}), do: lane == @local_lane
112
87 113
  @doc """
88 114
  The tiers that admit a reader who is not the account that opened the thread.
89 115

@@ -124,6 +150,7 @@ defmodule OpenAgents.Threads.Thread do

124 150
      :permission_profile,
125 151
      :repository,
126 152
      :visibility,
153
      :lane,
127 154
      :parent_thread_id,
128 155
      :issue_id
129 156
    ])

@@ -144,6 +171,7 @@ defmodule OpenAgents.Threads.Thread do

144 171
    |> validate_inclusion(:reasoning_effort, @reasoning_efforts)
145 172
    |> validate_inclusion(:permission_profile, @permission_profiles)
146 173
    |> validate_inclusion(:visibility, @visibilities)
174
    |> validate_inclusion(:lane, @lanes)
147 175
    |> foreign_key_constraint(:owner_visitor_id)
148 176
    |> foreign_key_constraint(:parent_thread_id)
149 177
    |> foreign_key_constraint(:issue_id)

@@ -152,6 +180,7 @@ defmodule OpenAgents.Threads.Thread do

152 180
    |> check_constraint(:objective, name: :threads_objective_bound_check)
153 181
    |> check_constraint(:repository, name: :threads_repository_bound_check)
154 182
    |> check_constraint(:visibility, name: :threads_visibility_check)
183
    |> check_constraint(:lane, name: :threads_lane_check)
155 184
    |> check_constraint(:reasoning_effort, name: :threads_reasoning_effort_check)
156 185
    |> check_constraint(:permission_profile, name: :threads_permission_profile_check)
157 186
  end
lib/openagents_web/api_error.ex modified +6

@@ -73,6 +73,12 @@ defmodule OpenAgentsWeb.ApiError do

73 73
    # and its own code — never a silent substitution.
74 74
    "model_unavailable" => {503, "The model's provider is not configured on this deployment"},
75 75
    "thread_terminal" => {422, "This thread is terminal and its transcript is closed"},
76
    # The local lane (THREAD-001, issue #243). A local-lane thread is
77
    # transcript-only by contract: its model runs on the caller's own machine,
78
    # so there is no authority to mint and no amount of waiting changes that.
79
    # Its own code, like `thread_terminal`, because a client that asked for a
80
    # grant must learn the thread can never hold one, not that it should retry.
81
    "thread_lane_local" => {422, "This thread is on the local lane and holds no model authority"},
76 82
    # Thread disclosure (THREAD-002). Widening a transcript is a consent
77 83
    # decision, so a tier that did not take is its own code rather than a field
78 84
    # message inside the generic 422: a client that meant to publish and did
lib/openagents_web/controllers/thread_controller.ex modified +120 -3

@@ -31,6 +31,17 @@ defmodule OpenAgentsWeb.ThreadController do

31 31
  a coding session run its own turns on one model and its delegated children on
32 32
  another: it opens a second thread on `ox-alpha` and gets authority for
33 33
  `ox-alpha`, with its own budget, rather than borrowing the first thread's.
34
35
  The exception is the `local` lane, and it is an exception to the grant, not
36
  to the record. `"lane": "local"` opens a transcript-only thread: the model is
37
  the bounded vendor string a local runtime serves (`ollama:...`), checked
38
  against no catalog and no provider, and the open mints nothing — the response
39
  carries no grant, and `POST /grants` on the thread is refused with
40
  `thread_lane_local`. Everything else is an ordinary thread: events append,
41
  the transcript streams, the tier governs its readers, the open-thread cap
42
  counts it, `DELETE` ends it. The lane exists so a run whose model calls never
43
  touch this server still leaves the durable transcript everything rehydrates
44
  from (issue #243).
34 45
  """
35 46
36 47
  use OpenAgentsWeb, :controller

@@ -45,11 +56,15 @@ defmodule OpenAgentsWeb.ThreadController do

45 56
  @extension "thread.openagents"
46 57
47 58
  def create(conn, params) do
48
    with {:ok, objective} <- objective(params),
59
    with {:ok, lane} <- lane(params),
60
         {:ok, objective} <- objective(params),
49 61
         {:ok, repository} <- repository(params),
50 62
         {:ok, visibility} <- visibility(params),
51
         {:ok, options} <- execution_shape(params) do
52
      open(conn, objective, options ++ repository ++ visibility)
63
         {:ok, options} <- shape_for(lane, params) do
64
      case lane do
65
        "local" -> open_local(conn, objective, options ++ repository ++ visibility)
66
        _granted -> open(conn, objective, options ++ repository ++ visibility)
67
      end
53 68
    else
54 69
      {:refused, field, message} -> ApiError.validation_failed(conn, %{field => [message]})
55 70
      {:unavailable, model_id} -> unavailable_model(conn, model_id)

@@ -190,6 +205,18 @@ defmodule OpenAgentsWeb.ThreadController do

190 205
            errors: %{"thread" => [sentence]}
191 206
          )
192 207
208
        {:error, :thread_local_lane} ->
209
          sentence =
210
            "This thread is on the local lane and holds no model authority to mint: " <>
211
              "its model runs on the caller's own machine, and this server only " <>
212
              "records the transcript. Open a thread without \"lane\": \"local\" " <>
213
              "for a grant."
214
215
          ApiError.refuse(conn, "thread_lane_local",
216
            message: sentence,
217
            errors: %{"thread" => [sentence]}
218
          )
219
193 220
        {:error, :credit_exhausted} ->
194 221
          credit_exhausted(conn)
195 222

@@ -302,6 +329,26 @@ defmodule OpenAgentsWeb.ThreadController do

302 329
    end
303 330
  end
304 331
332
  # The transcript-only open. No mint, so no token, no grant key, and no
333
  # `credit_exhausted` — an account with nothing left can still record a run
334
  # its own machine paid for. The quota refusal is the same one the granted
335
  # lane gets, because the cap counts open threads, not granted ones.
336
  defp open_local(conn, objective, options) do
337
    case Threads.open(conn.assigns.current_user, objective, options) do
338
      {:ok, thread} ->
339
        conn
340
        |> put_extension_header()
341
        |> put_status(:created)
342
        |> json(%{"thread" => thread_view(thread)})
343
344
      {:error, :thread_quota_reached} ->
345
        quota_reached(conn)
346
347
      {:error, %Ecto.Changeset{} = changeset} ->
348
        ApiError.changeset(conn, changeset)
349
    end
350
  end
351
305 352
  # The refusal names the ceiling and the account's own count, so a client that
306 353
  # scripts several checkouts learns what to close rather than what to retry.
307 354
  defp quota_reached(conn) do

@@ -567,6 +614,76 @@ defmodule OpenAgentsWeb.ThreadController do

567 614
    )
568 615
  end
569 616
617
  # The lane decides which door the model goes through. Absent means the
618
  # granted lane, exactly as every create before the field existed; a value
619
  # outside the admitted pair is refused rather than folded into a default,
620
  # because a caller that asked for a transcript-only thread and was minted a
621
  # grant — or the reverse — has no way to tell.
622
  defp lane(%{"lane" => value}) when is_binary(value) do
623
    trimmed = String.trim(value)
624
625
    if trimmed in Thread.lanes() do
626
      {:ok, trimmed}
627
    else
628
      {:refused, "lane",
629
       "#{inspect(trimmed)} is not an admitted lane. " <>
630
         "Admitted: #{Enum.join(Thread.lanes(), ", ")}."}
631
    end
632
  end
633
634
  defp lane(%{"lane" => value}) when not is_nil(value) do
635
    {:refused, "lane", "#{inspect(value)} is not a string."}
636
  end
637
638
  defp lane(_params), do: {:ok, Thread.default_lane()}
639
640
  defp shape_for("local", params), do: local_execution_shape(params)
641
  defp shape_for(_granted, params), do: execution_shape(params)
642
643
  # The local lane's model is the vendor string a local runtime serves —
644
  # `ollama:qwen3.8:27b-mtp-q8_0` — so it is bounded, not admitted: no catalog
645
  # membership and no provider availability, because no grant will ever carry
646
  # it to a provider (issue #243). It is required rather than defaulted: the
647
  # catalog default is a model this thread deliberately does not use, and
648
  # recording it would put a lie at the top of the transcript. The 200-byte
649
  # bound is the changeset's, the same one every thread's model column holds.
650
  defp local_execution_shape(params) do
651
    with {:ok, model} <- local_model(params),
652
         {:ok, reasoning} <-
653
           admitted(params, "reasoning", Thread.reasoning_efforts(), Threads.default_reasoning()),
654
         {:ok, profile} <-
655
           admitted(
656
             params,
657
             "permission_profile",
658
             Thread.permission_profiles(),
659
             Threads.default_permission_profile()
660
           ) do
661
      {:ok, [lane: "local", model: model, reasoning: reasoning, permission_profile: profile]}
662
    end
663
  end
664
665
  defp local_model(%{"model" => model}) when is_binary(model) do
666
    case String.trim(model) do
667
      "" ->
668
        {:refused, "model",
669
         "A local-lane thread records the model its local runtime serves, " <>
670
           "and the string cannot be blank."}
671
672
      trimmed ->
673
        {:ok, trimmed}
674
    end
675
  end
676
677
  defp local_model(%{"model" => model}) do
678
    {:refused, "model", "#{inspect(model)} is not a string."}
679
  end
680
681
  defp local_model(_params) do
682
    {:refused, "model",
683
     "A local-lane thread requires a model: the vendor string its local " <>
684
       "runtime serves, for example ollama:qwen3.8:27b-mtp-q8_0."}
685
  end
686
570 687
  defp execution_shape(params) do
571 688
    with {:ok, model} <- admitted(params, "model", Models.ids(), Models.default_id()),
572 689
         :ok <- serving(model),
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -302,7 +302,8 @@

302 302
    20260825101934,
303 303
    20260825102408,
304 304
    20260825113454,
305
    20260825113459
305
    20260825113459,
306
    20260825120000
306 307
  ],
307 308
  "required_tables": [
308 309
    "users",
priv/repo/migrations/20260825120000_add_lane_to_threads.exs added +30

@@ -0,0 +1,30 @@

1
defmodule OpenAgents.Repo.Migrations.AddLaneToThreads do
2
  @moduledoc """
3
  Which lane opened the thread, and therefore whether it can hold authority.
4
5
  Two lanes, by check constraint:
6
7
    * `thread` — the granted lane. The model is an admitted catalog id and the
8
      open mints a grant against the account's credit. Default.
9
    * `local`  — the transcript-only lane. The model is the vendor string a
10
      local runtime serves (`ollama:...`), bounded but never admitted against
11
      the catalog, and no grant is ever minted: the thread exists so a local
12
      run leaves the same durable transcript a granted run does.
13
14
  The default is `thread` rather than `NULL` because it is the honest history:
15
  every thread written before this column existed came through the granted
16
  lane — its model was catalog-admitted and its open minted a grant — so
17
  backfilling `thread` records what actually happened rather than an absence
18
  (issue #243).
19
  """
20
21
  use Ecto.Migration
22
23
  def change do
24
    alter table(:threads) do
25
      add :lane, :text, null: false, default: "thread"
26
    end
27
28
    create constraint(:threads, :threads_lane_check, check: "lane IN ('thread', 'local')")
29
  end
30
end
test/openagents/threads/local_lane_test.exs added +153

@@ -0,0 +1,153 @@

1
defmodule OpenAgents.Threads.LocalLaneTest do
2
  @moduledoc """
3
  The transcript-only local lane (THREAD-001, issue #243).
4
5
  A local-lane thread records a run whose model calls never touch this server:
6
  its model is the vendor string a local runtime serves, and it holds no
7
  authority, ever — `mint_grant/1` refuses it the way it refuses a terminal
8
  thread. Everything else about it is an ordinary thread: the transcript
9
  appends and broadcasts, the open-thread cap counts it, and cancelling ends
10
  it. Both halves are proved here, because the lane's contract is exactly that
11
  pair.
12
  """
13
  use OpenAgents.DataCase, async: false
14
15
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
16
17
  alias OpenAgents.Threads
18
  alias OpenAgents.Threads.Event
19
  alias OpenAgents.Threads.Thread
20
21
  @vendor_model "ollama:qwen3.8:27b-mtp-q8_0"
22
23
  defp owner(key), do: github_user("local-lane-#{key}")
24
25
  defp open_local(user, objective) do
26
    Threads.open(user, objective, lane: "local", model: @vendor_model)
27
  end
28
29
  describe "open/3 on the local lane" do
30
    test "records the vendor model string and mints nothing" do
31
      user = owner("open")
32
33
      assert {:ok, thread} = open_local(user, "Record a local run")
34
35
      assert thread.lane == "local"
36
      assert thread.status == "open"
37
      assert thread.model == @vendor_model
38
      # No mint means no fence bump and no grant row: the thread has never
39
      # held authority, not merely lost it.
40
      assert thread.generation == 0
41
      assert Threads.active_grants(thread) == []
42
      assert Threads.latest_grant(thread) == nil
43
    end
44
45
    test "the opened event names the lane" do
46
      user = owner("opened-event")
47
48
      {:ok, thread} = open_local(user, "Name the lane up front")
49
50
      assert [%Event{event_type: "thread.opened", payload: payload}] = Threads.list_events(thread)
51
      assert payload["lane"] == "local"
52
    end
53
54
    test "a thread-lane open records no lane in its opened event" do
55
      user = owner("granted-event")
56
57
      {:ok, thread} = Threads.open(user, "The granted lane is unmarked")
58
59
      assert thread.lane == Thread.default_lane()
60
      assert [%Event{event_type: "thread.opened", payload: payload}] = Threads.list_events(thread)
61
      refute Map.has_key?(payload, "lane")
62
    end
63
64
    test "a lane outside the admitted pair is refused" do
65
      user = owner("unknown")
66
67
      assert {:error, changeset} = Threads.open(user, "Objective", lane: "gym")
68
      assert %{lane: _} = errors_on(changeset)
69
    end
70
71
    test "the model string is bounded like every thread's model column" do
72
      user = owner("bound")
73
74
      assert {:error, changeset} =
75
               Threads.open(user, "Objective",
76
                 lane: "local",
77
                 model: String.duplicate("a", 201)
78
               )
79
80
      assert %{model: _} = errors_on(changeset)
81
    end
82
  end
83
84
  describe "mint_grant/1" do
85
    test "refuses a local-lane thread with :thread_local_lane" do
86
      user = owner("mint")
87
      {:ok, thread} = open_local(user, "Ask for authority the lane forbids")
88
89
      assert {:error, :thread_local_lane} = Threads.mint_grant(thread)
90
91
      # The refusal leaves no trace of an attempt: no fence bump, no grant.
92
      refused = Threads.get_for_user(user, thread.id)
93
      assert refused.generation == 0
94
      assert Threads.active_grants(refused) == []
95
    end
96
  end
97
98
  describe "the transcript" do
99
    test "events record and broadcast exactly as thread-lane events do" do
100
      user = owner("transcript")
101
      {:ok, thread} = open_local(user, "Stream the local run")
102
103
      :ok = Threads.subscribe(thread)
104
105
      assert {:ok, updated} =
106
               Threads.record_event(thread, "turn.user", %{"text" => "Fix the bug"})
107
108
      assert updated.event_count == 2
109
110
      assert_receive {:thread_event,
111
                      %Event{event_type: "turn.user", payload: %{"text" => "Fix the bug"}}}
112
    end
113
  end
114
115
  describe "admission and the reaper" do
116
    test "a local-lane thread counts against the open-thread cap" do
117
      user = owner("cap")
118
      previous = Application.get_env(:openagents, :maximum_open_threads_per_account)
119
      Application.put_env(:openagents, :maximum_open_threads_per_account, 1)
120
121
      on_exit(fn ->
122
        Application.put_env(:openagents, :maximum_open_threads_per_account, previous)
123
      end)
124
125
      {:ok, _local} = open_local(user, "Hold the one slot")
126
127
      assert {:error, :thread_quota_reached} = Threads.open(user, "One too many")
128
    end
129
130
    test "the reaper leaves an open local-lane thread open" do
131
      user = owner("reap")
132
      {:ok, thread} = open_local(user, "Nothing to spend, nothing to reap")
133
134
      # The authority-spent sweep closes threads that minted and hold nothing.
135
      # A local thread never minted, so it is not abandoned authority — it is
136
      # a transcript still being written.
137
      _reaped = Threads.reap_expired(user)
138
139
      assert Threads.get_for_user(user, thread.id).status == "open"
140
    end
141
  end
142
143
  test "cancelling ends a local-lane thread like any other" do
144
    user = owner("cancel")
145
    {:ok, thread} = open_local(user, "End the record")
146
147
    assert {:ok, cancelled} = Threads.cancel(thread)
148
    assert cancelled.status == "cancelled"
149
150
    assert {:error, :thread_terminal} =
151
             Threads.record_event(cancelled, "turn.user", %{"text" => "late"})
152
  end
153
end
test/openagents_web/controllers/thread_controller_test.exs modified +211

@@ -1025,4 +1025,215 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

1025 1025
      assert stranger |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(404)
1026 1026
    end
1027 1027
  end
1028
1029
  describe "the local lane" do
1030
    # The transcript-only lane (THREAD-001, issue #243): the model is the
1031
    # vendor string a local runtime serves, checked against no catalog and no
1032
    # provider, and no grant is ever minted. The other half of the contract is
1033
    # below: a create that names no lane is byte-for-byte the granted create
1034
    # it always was.
1035
    @vendor_model "ollama:qwen3.8:27b-mtp-q8_0"
1036
1037
    test "opens a transcript-only thread: no grant, no token, no catalog check",
1038
         %{conn: conn} do
1039
      body =
1040
        conn
1041
        |> put_chat_api_token("thread-local-open")
1042
        |> post(~p"/api/v1/threads", %{
1043
          "objective" => "Record a local run.",
1044
          "lane" => "local",
1045
          "model" => @vendor_model
1046
        })
1047
        |> json_response(201)
1048
1049
      # The response carries the thread and nothing that spends: no grant key,
1050
      # no token anywhere in the body.
1051
      assert Map.keys(body) == ["thread"]
1052
      refute inspect(body) =~ "sig_"
1053
1054
      thread = body["thread"]
1055
      assert thread["status"] == "open"
1056
      # Never minted, so the fence never moved: generation 0, where a granted
1057
      # create returns 1.
1058
      assert thread["generation"] == 0
1059
1060
      stored = Repo.get!(OpenAgents.Threads.Thread, thread["id"])
1061
      assert stored.lane == "local"
1062
      assert stored.model == @vendor_model
1063
      assert Repo.get_by(Grant, thread_id: thread["id"]) == nil
1064
    end
1065
1066
    test "a local-lane thread requires a model, and a blank one is refused", %{conn: conn} do
1067
      authenticated = put_chat_api_token(conn, "thread-local-no-model")
1068
1069
      absent =
1070
        authenticated
1071
        |> post(~p"/api/v1/threads", %{"objective" => "No model named.", "lane" => "local"})
1072
        |> json_response(422)
1073
1074
      assert absent["code"] == "validation_failed"
1075
      assert Map.has_key?(absent["errors"], "model")
1076
1077
      blank =
1078
        authenticated
1079
        |> post(~p"/api/v1/threads", %{
1080
          "objective" => "Blank model.",
1081
          "lane" => "local",
1082
          "model" => "   "
1083
        })
1084
        |> json_response(422)
1085
1086
      assert Map.has_key?(blank["errors"], "model")
1087
    end
1088
1089
    test "the vendor string is bounded, not admitted", %{conn: conn} do
1090
      body =
1091
        conn
1092
        |> put_chat_api_token("thread-local-model-bound")
1093
        |> post(~p"/api/v1/threads", %{
1094
          "objective" => "Bound the vendor string.",
1095
          "lane" => "local",
1096
          "model" => String.duplicate("a", 201)
1097
        })
1098
        |> json_response(422)
1099
1100
      assert Map.has_key?(body["errors"], "model")
1101
    end
1102
1103
    test "a lane outside the admitted pair is refused with a validation error", %{conn: conn} do
1104
      body =
1105
        conn
1106
        |> put_chat_api_token("thread-local-unknown-lane")
1107
        |> post(~p"/api/v1/threads", %{"objective" => "Wrong lane.", "lane" => "gym"})
1108
        |> json_response(422)
1109
1110
      assert body["code"] == "validation_failed"
1111
      assert [message] = body["errors"]["lane"]
1112
      assert message =~ "thread, local"
1113
1114
      not_a_string =
1115
        conn
1116
        |> put_chat_api_token("thread-local-lane-shape")
1117
        |> post(~p"/api/v1/threads", %{"objective" => "Wrong shape.", "lane" => 7})
1118
        |> json_response(422)
1119
1120
      assert Map.has_key?(not_a_string["errors"], "lane")
1121
    end
1122
1123
    test "requesting a grant for a local-lane thread is refused", %{conn: conn} do
1124
      authenticated = put_chat_api_token(conn, "thread-local-mint")
1125
1126
      created =
1127
        authenticated
1128
        |> post(~p"/api/v1/threads", %{
1129
          "objective" => "Ask for authority the lane forbids.",
1130
          "lane" => "local",
1131
          "model" => @vendor_model
1132
        })
1133
        |> json_response(201)
1134
1135
      id = created["thread"]["id"]
1136
1137
      refused = authenticated |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(422)
1138
      assert refused["code"] == "thread_lane_local"
1139
      refute inspect(refused) =~ "sig_"
1140
1141
      # The refusal changed nothing: still open, still ungranted.
1142
      assert Repo.get!(OpenAgents.Threads.Thread, id).status == "open"
1143
      assert Repo.get_by(Grant, thread_id: id) == nil
1144
    end
1145
1146
    test "events record and read back exactly as thread-lane events do", %{conn: conn} do
1147
      authenticated = put_chat_api_token(conn, "thread-local-events")
1148
1149
      created =
1150
        authenticated
1151
        |> post(~p"/api/v1/threads", %{
1152
          "objective" => "Write the transcript here.",
1153
          "lane" => "local",
1154
          "model" => @vendor_model
1155
        })
1156
        |> json_response(201)
1157
1158
      id = created["thread"]["id"]
1159
1160
      recorded =
1161
        authenticated
1162
        |> post(~p"/api/v1/threads/#{id}/events", %{
1163
          "event_type" => "turn.user",
1164
          "payload" => %{"text" => "Fix the bug"}
1165
        })
1166
        |> json_response(201)
1167
1168
      assert recorded["event"]["event_type"] == "turn.user"
1169
1170
      body = authenticated |> get(~p"/api/v1/threads/#{id}/events") |> json_response(200)
1171
1172
      assert Enum.map(body["events"], & &1["event_type"]) == ["thread.opened", "turn.user"]
1173
      # The transcript itself says no authority backs it.
1174
      assert [opened | _rest] = body["events"]
1175
      assert opened["payload"]["lane"] == "local"
1176
    end
1177
1178
    test "DELETE ends a local-lane thread like any other", %{conn: conn} do
1179
      authenticated = put_chat_api_token(conn, "thread-local-delete")
1180
1181
      created =
1182
        authenticated
1183
        |> post(~p"/api/v1/threads", %{
1184
          "objective" => "End the record.",
1185
          "lane" => "local",
1186
          "model" => @vendor_model
1187
        })
1188
        |> json_response(201)
1189
1190
      id = created["thread"]["id"]
1191
1192
      ended = authenticated |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
1193
      assert ended["thread"]["status"] == "cancelled"
1194
    end
1195
1196
    test "a create without a lane is the granted create it always was", %{conn: conn} do
1197
      authenticated = put_chat_api_token(conn, "thread-local-regression")
1198
1199
      body =
1200
        authenticated
1201
        |> post(~p"/api/v1/threads", %{"objective" => "Unchanged for existing callers."})
1202
        |> json_response(201)
1203
1204
      # The same two keys, the same mint: a grant with a plaintext token, the
1205
      # fence at 1, and the stored lane is the granted one.
1206
      assert Map.keys(body) == ["grant", "thread"]
1207
      assert String.starts_with?(body["grant"]["token"], "sig_")
1208
      assert body["thread"]["generation"] == 1
1209
1210
      assert Repo.get!(OpenAgents.Threads.Thread, body["thread"]["id"]).lane == "thread"
1211
1212
      # And the lane-less create still admits only the catalog: a vendor
1213
      # string that opens a local-lane thread is refused here (PROVIDER-002).
1214
      refused =
1215
        authenticated
1216
        |> post(~p"/api/v1/threads", %{
1217
          "objective" => "A vendor string without the lane.",
1218
          "model" => @vendor_model
1219
        })
1220
        |> json_response(422)
1221
1222
      assert Map.has_key?(refused["errors"], "model")
1223
    end
1224
1225
    test "naming the thread lane explicitly is the granted create too", %{conn: conn} do
1226
      body =
1227
        conn
1228
        |> put_chat_api_token("thread-local-explicit")
1229
        |> post(~p"/api/v1/threads", %{
1230
          "objective" => "The default lane, spelled out.",
1231
          "lane" => "thread"
1232
        })
1233
        |> json_response(201)
1234
1235
      assert String.starts_with?(body["grant"]["token"], "sig_")
1236
      assert Repo.get!(OpenAgents.Threads.Thread, body["thread"]["id"]).lane == "thread"
1237
    end
1238
  end
1028 1239
end

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