Let a thread's owner decide who reads it

33114bca04b7 · AtlantisPleb · · parent 35da41ff8171

Let a thread's owner decide who reads it

A thread's transcript is the substrate the registry is meant to learn
from, so the consent gate goes in before the collection volume rather
than after it. A thread now carries a transparency tier, defaulting to
the account that opened it and nobody else.

The tier vocabulary is the one this application already has —
dark/pulse/ledger/glass, from OpenAgents.Transparency and
OpenAgents.Forge.Visibility — not a second public/private enum beside
it. A thread admits the two rungs it can enforce: dark, and ledger,
which opens the transcript to any signed-in reader holding the thread
id. The pulse and glass tiers have no thread read path behind them, so
the column refuses them and POST /api/v3/threads answers
thread_visibility_unsupported rather than storing a tier nothing
applies.

Widening leaves a record. A tier reaches a thread only by being named
at open, and naming one appends thread.visibility_set to the transcript
in the same transaction as the insert, so the consent decision sits in
the same append-only log as the work it governs.

A tier widens reading and nothing else. fetch_readable/2 serves the two
reads — the API's thread and events, and the web viewer — while every
write and the mint stay on the owner-scoped get_for_user/2. A reader
admitted by the tier is served no grant: the ladder discloses the
transcript, not what the account is spending. A dark thread still
answers a stranger with the plain 404 an unknown id gets.

The account export carries each thread's visibility beside its
repository and its events, so the terms leave with the data. A plugin
run is a tool.ran event on its thread and needs no separate gate today;
THREAD-002 records where the registry's usage counter must join
threads.visibility when it lands (#206).

Closes #205.
Closes
#205

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 309 · 2026-08-24T20:56:39.339949Z

Changed files

  • modified INVARIANTS.md
  • modified docs/taxonomy.md
  • modified lib/openagents/data_rights/account_export.ex
  • modified lib/openagents/data_rights/export_inventory.ex
  • modified lib/openagents/threads.ex
  • modified lib/openagents/threads/thread.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified lib/openagents_web/live/thread_show_live.ex
  • added priv/repo/migrations/20260824210500_add_visibility_to_threads.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • added test/openagents/threads/visibility_test.exs
  • added test/openagents_web/thread_visibility_test.exs

Diff

14 files changed, +850 -29

INVARIANTS.md modified +90

@@ -2107,6 +2107,15 @@ conversation, and a thread is not one.

2107 2107
  every route the router gives that controller and requires a token in the body
2108 2108
  only at the mint. A second route that renders a grant fails there.
2109 2109
2110
  Amended 2026-08-24 (issue #205): "the account that opened the thread" is now
2111
  the rule for every **write** and for the mint, and the default rule for
2112
  reads. A thread carries a transparency tier, and a thread its owner opened
2113
  at a wider tier is readable by the audience that tier names — see
2114
  THREAD-002, which states what widens and what does not. The token clause is
2115
  untouched: `fetch_readable/2` returns no grant, the reader's view of a thread
2116
  carries `"grant": null`, and the mint still resolves through
2117
  `get_for_user/2`.
2118
2110 2119
Evidence: `OpenAgents.Threads`, `OpenAgents.Threads.Thread`,
2111 2120
`OpenAgents.Threads.Event`, `OpenAgents.Inference.mint/1`,
2112 2121
`OpenAgents.Inference.expire_elapsed_for_owner/1`,

@@ -2119,6 +2128,86 @@ Evidence: `OpenAgents.Threads`, `OpenAgents.Threads.Thread`,

2119 2128
`test/openagents_web/controllers/thread_controller_test.exs`, and
2120 2129
`test/openagents_web/live/thread_show_live_test.exs`.
2121 2130
2131
### THREAD-002 — A thread is private until its owner widens it, and widening is a recorded act
2132
2133
Status: Current
2134
2135
A thread's transcript is the substrate the registry is meant to learn from
2136
(`docs/2026-08-24-registry-network-strategy.md`, §5). The consent gate lands
2137
before the collection volume, not after, so a transcript discloses nothing
2138
until the account that opened it says so.
2139
2140
- **The vocabulary is the one this application already has.** A thread's
2141
  visibility is a tier of the `dark`/`pulse`/`ledger`/`glass` ladder that
2142
  `OpenAgents.Transparency` and `OpenAgents.Forge.Visibility` already use
2143
  (`docs/taxonomy.md`). No parallel `public`/`private` enum is introduced, and
2144
  `OpenAgents.Threads.VisibilityTest` holds `Thread.visibilities/0` to a subset
2145
  of `Transparency.tier_atoms/0`, so a fifth word cannot enter through this
2146
  door.
2147
- **Only a tier with a read path behind it is offered.** `threads.visibility`
2148
  admits `dark` and `ledger` and nothing else, by check constraint and by
2149
  `Thread.open_changeset/3`. `pulse` would need a metadata-only projection of a
2150
  transcript and `glass` a capability beyond reading one; neither exists, so
2151
  neither is stored. `POST /api/v3/threads` refuses any other value —
2152
  a tier of the vocabulary this surface cannot enforce, or a word that is not a
2153
  tier — with the stable code `thread_visibility_unsupported`, because a client
2154
  that meant to publish and did not must learn it from the code it branches on
2155
  rather than from prose.
2156
- **The default is owner-only.** The column defaults to `dark` in PostgreSQL
2157
  and in the schema, so a thread opened by a caller that names no tier — and
2158
  every thread written before the column existed — is readable by its account
2159
  and nobody else.
2160
- **Widening is explicit and recorded.** A wider tier reaches a thread only by
2161
  being named at `open/3`; nothing derives one, and no other caller's action
2162
  raises it. When one is named, `OpenAgents.Threads.open/3` appends
2163
  `thread.visibility_set` to the transcript in the same transaction as the
2164
  insert, carrying the tier and the default it replaced. The consent decision
2165
  is therefore a fact in the same append-only log as the work it governs, not
2166
  a column somebody can find changed with no account of when or why.
2167
- **A tier widens reading and nothing else.** `fetch_readable/2` is the only
2168
  lookup a tier reaches, and it serves exactly two surfaces: `GET
2169
  /api/v3/threads/{thread_id}` with its `/events`, and `/threads/:id`. Every
2170
  write — the transcript append, the cancel, the re-mint — resolves through the
2171
  owner-scoped `get_for_user/2`, so a published transcript is never a thread a
2172
  stranger may append to, end, or spend.
2173
  `OpenAgents.Threads.GrantTokenReachTest` asserts the caller set of each
2174
  lookup from the compiled import tables, so a write that starts resolving
2175
  through the tier-scoped lookup fails there.
2176
- **A tier does not disclose the owner's money.** A reader admitted by the
2177
  thread's tier is served `"grant": null` by the API and no budget card in the
2178
  viewer. The ladder is about the transcript; what the account is spending is
2179
  not on it.
2180
- **A refused reader learns nothing.** A thread at `dark` answers a stranger
2181
  with the same plain `not_found` — and the same `PublicNotFoundError` in the
2182
  browser — that an unknown id gets, so the tier withholds the thread's
2183
  existence and not merely its contents.
2184
- **The audience is the one the surface can enforce.** `ledger` admits *any
2185
  signed-in account holding the thread's id*. Both read surfaces require an
2186
  authenticated principal — the API route is bearer-authenticated and
2187
  `/threads/:id` sits in the `:authenticated` live session — so no anonymous
2188
  audience is claimed. A genuinely public thread would need a route outside
2189
  that session, and until one exists the tier does not promise one.
2190
- **The consent record leaves with the data.** `GET /data/export/account`
2191
  carries each thread's `visibility` beside its objective, its repository, and
2192
  its events, so a recipient reading the document offline has the terms
2193
  alongside the transcript rather than only the transcript (DATA-004).
2194
2195
Plugin usage inherits this and adds nothing: a plugin run is recorded as a
2196
`tool.ran` event on the thread that ran it, so the owning thread's tier already
2197
governs it through the same read paths. No aggregate over `tool.ran` exists
2198
today. When the registry's usage counter lands (issue #206), it must aggregate
2199
by joining `threads.visibility` and counting only rows at a tier that permits
2200
it; a counter that read `thread_events` without that join would republish, in
2201
aggregate, transcripts their owners kept `dark`.
2202
2203
Evidence: `OpenAgents.Threads.fetch_readable/2`,
2204
`OpenAgents.Threads.Thread.visibilities/0`, `OpenAgentsWeb.ThreadController`,
2205
`OpenAgentsWeb.ThreadShowLive`, `OpenAgents.DataRights.AccountExport`,
2206
`priv/repo/migrations/20260824210500_add_visibility_to_threads.exs`,
2207
`test/openagents/threads/visibility_test.exs`,
2208
`test/openagents_web/thread_visibility_test.exs`, and
2209
`test/openagents/threads/grant_token_reach_test.exs`.
2210
2122 2211
## Tenant deployment control plane
2123 2212
2124 2213
### DEPLOYPLANE-001 — A deployment intent carries no authority

@@ -4957,6 +5046,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4957 5046
| SELF-EDIT-001 | `test/openagents/tools/repository_mutation_tools_test.exs`, `test/openagents/coding_job_test.exs`, `test/openagents/dependency_boundary_test.exs` |
4958 5047
| SCV-001 | `test/openagents/scv/deployments_test.exs`, `test/openagents/dependency_boundary_test.exs` |
4959 5048
| 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` |
5049
| THREAD-002 | `test/openagents/threads/visibility_test.exs`, `test/openagents_web/thread_visibility_test.exs`, `test/openagents/threads/grant_token_reach_test.exs` |
4960 5050
| 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` |
4961 5051
| DEPLOYPLANE-001 | `test/openagents/deployments_test.exs`, `test/openagents_web/controllers/deployment_controller_test.exs`, `test/openagents_web/api_route_authority_test.exs` |
4962 5052
| DEPLOYPLANE-002 | `test/openagents/deployments_test.exs` |
docs/taxonomy.md modified +11

@@ -206,6 +206,17 @@ per-field rule about work in progress that the issue tracker's own

206 206
repository-readability gate does not settle. Say "level" for the dial and
207 207
"schedule" for the field rules. Both use the same four tier words.
208 208
209
**Thread visibility** — the third user of those four words, and the only one an
210
account sets for itself: `threads.visibility` is the tier that governs who may
211
read one thread's transcript (THREAD-002). Say "visibility" for the column and
212
"tier" for the value, the way an `ArtifactLink` carries a `tier`. It admits
213
`dark` (the default — the account that opened the thread and nobody else) and
214
`ledger` (any signed-in reader holding the thread id), and refuses `pulse` and
215
`glass`, which no thread read path implements. It is not a *level*: a level is
216
operator-owned per-repository configuration, while a thread's visibility is the
217
opener's own decision, recorded in the transcript as `thread.visibility_set`
218
when it widens.
219
209 220
**Trajectory** — the ATIF schema name for the document (`AtifTrajectory`,
210 221
`trajectory.json`, `trajectory_id`). Use it in schema and code. Product copy
211 222
says trace.
lib/openagents/data_rights/account_export.ex modified +6

@@ -405,10 +405,16 @@ defmodule OpenAgents.DataRights.AccountExport do

405 405
    }
406 406
  end
407 407
408
  # The transparency tier travels with the thread. It is the consent record for
409
  # the transcript beneath it — what the account said may be read, and by whom
410
  # — so an export that carried the events without it would hand a recipient
411
  # the data and drop the terms (THREAD-002).
408 412
  defp thread_export(thread, events) do
409 413
    %{
410 414
      "id" => thread.id,
411 415
      "objective" => thread.objective,
416
      "repository" => thread.repository,
417
      "visibility" => thread.visibility,
412 418
      "status" => thread.status,
413 419
      "model" => thread.model,
414 420
      "reasoning_effort" => thread.reasoning_effort,
lib/openagents/data_rights/export_inventory.ex modified +13

@@ -350,6 +350,19 @@ defmodule OpenAgents.DataRights.ExportInventory do

350 350
      issue: nil,
351 351
      note: "The API's own route and extension inventory."
352 352
    },
353
    %{
354
      family: :model,
355
      api?: true,
356
      status: :not_user_data,
357
      mechanism: nil,
358
      proof: nil,
359
      issue: nil,
360
      note:
361
        "The typed model catalog a thread is admitted against. It is the " <>
362
          "deployment's configuration — which models are served and at what " <>
363
          "ceilings — and carries no record an account authors. What an " <>
364
          "account did with a model is its threads, which export whole."
365
    },
353 366
    %{
354 367
      family: :capacity,
355 368
      api?: true,
lib/openagents/threads.ex modified +85 -3

@@ -47,6 +47,18 @@ defmodule OpenAgents.Threads do

47 47
     account's admission slot without anyone asking, so an abandoned thread
48 48
     cannot lock an account out of its own ceiling.
49 49
50
  ## The audience
51
52
  6. **A transcript is private until its owner says otherwise.** A thread
53
     carries a transparency tier from the shared `dark/pulse/ledger/glass`
54
     vocabulary (`OpenAgents.Transparency`, `docs/taxonomy.md`), defaulting to
55
     `dark` — the account that opened it and nobody else. `open/3` accepts a
56
     wider tier and records `thread.visibility_set` in the transcript when one
57
     is given, so widening is an act with a record rather than a column that
58
     drifted. `fetch_readable/2` is the only read that a wider tier reaches;
59
     every write stays on `get_for_user/2`, because publishing a transcript for
60
     reading is not handing anyone the thread's authority (THREAD-002).
61
50 62
  A thread's ceilings are its own: `ceilings/0` reads the `thread_grant_*`
51 63
  settings and passes them to `OpenAgents.Inference.mint/1`, which otherwise
52 64
  applies the delegation ceilings. A delegation is one probe run the server

@@ -85,6 +97,11 @@ defmodule OpenAgents.Threads do

85 97
  slot held by an abandoned thread is released before the count is taken, and
86 98
  an account already at `maximum_open_per_account/0` is refused with
87 99
  `:thread_quota_reached` rather than given a further grant.
100
101
  `:visibility` is the thread's transparency tier and defaults to
102
  `OpenAgents.Threads.Thread.default_visibility/0`, owner-only. A wider tier
103
  given here is recorded in the transcript as `thread.visibility_set`
104
  (THREAD-002).
88 105
  """
89 106
  @spec open(User.t() | Visitor.t(), String.t(), keyword()) ::
90 107
          {:ok, Thread.t()} | {:error, :thread_quota_reached | Ecto.Changeset.t()}

@@ -137,6 +154,7 @@ defmodule OpenAgents.Threads do

137 154
    attributes = %{
138 155
      objective: objective,
139 156
      repository: Keyword.get(options, :repository),
157
      visibility: Keyword.get(options, :visibility) || Thread.default_visibility(),
140 158
      model: Keyword.get(options, :model) || Models.default_id(),
141 159
      reasoning_effort:
142 160
        OpenRouter.reasoning_effort(Keyword.get(options, :reasoning, @default_reasoning)),

@@ -160,10 +178,32 @@ defmodule OpenAgents.Threads do

160 178
    end)
161 179
    |> Multi.insert(:thread, Thread.open_changeset(attributes, visitor_id, now))
162 180
    |> Multi.run(:opened_event, fn _repo, %{thread: thread} ->
163
      insert_event(thread, "thread.opened", %{"objective_bytes" => byte_size(objective)}, now)
181
      insert_event(
182
        thread,
183
        "thread.opened",
184
        %{"objective_bytes" => byte_size(objective), "visibility" => thread.visibility},
185
        now
186
      )
187
    end)
188
    # Widening is an act, so it leaves a record rather than only a column value
189
    # (THREAD-002). The event is written only when the opener asked for a tier
190
    # wider than owner-only: a default thread was never widened, and an event
191
    # saying so on every open would make the record meaningless.
192
    |> Multi.run(:widened_event, fn _repo, %{thread: thread} ->
193
      if Thread.wide?(thread) do
194
        insert_event(
195
          thread,
196
          "thread.visibility_set",
197
          %{"visibility" => thread.visibility, "from" => Thread.default_visibility()},
198
          now
199
        )
200
      else
201
        {:ok, nil}
202
      end
164 203
    end)
165
    |> Multi.update(:counted, fn %{thread: thread} ->
166
      Thread.event_count_changeset(thread, thread.event_count + 1)
204
    |> Multi.update(:counted, fn %{thread: thread, widened_event: widened} ->
205
      appended = if widened, do: 2, else: 1
206
      Thread.event_count_changeset(thread, thread.event_count + appended)
167 207
    end)
168 208
    |> Repo.transaction()
169 209
    |> case do

@@ -192,6 +232,48 @@ defmodule OpenAgents.Threads do

192 232
193 233
  def get_for_user(_user, _thread_id), do: nil
194 234
235
  @doc """
236
  A thread this account may **read**, and whether it is the account's own.
237
238
  Reading is the only thing a transparency tier widens. Every other verb —
239
  appending to the transcript, cancelling the thread, re-minting its
240
  authority — stays on `get_for_user/2`, because a thread its owner published
241
  for reading is not a thread a stranger may write to or spend.
242
243
  Returns `{:ok, thread, :owner}` for the account's own thread, `{:ok, thread,
244
  :reader}` for somebody else's thread at a tier that admits this reader, and
245
  `:error` otherwise. The two cases are distinguished at the query rather than
246
  by a second read, so a caller that must withhold the owner's budget from a
247
  reader has the fact without asking again.
248
249
  The audience of a wide tier is *any signed-in account holding the thread's
250
  id*: both surfaces that call this — `GET /api/v3/threads/{thread_id}` and
251
  `/threads/:id` — require an authenticated principal, and this function does
252
  not invent an anonymous one (THREAD-002).
253
  """
254
  @spec fetch_readable(User.t(), String.t()) :: {:ok, Thread.t(), :owner | :reader} | :error
255
  def fetch_readable(%User{id: user_id}, thread_id) when is_binary(thread_id) do
256
    wide = Thread.wide_visibilities()
257
258
    with {:ok, id} <- Ecto.UUID.cast(thread_id),
259
         {%Thread{} = thread, owner_user_id} <-
260
           Repo.one(
261
             from(t in Thread,
262
               join: v in Visitor,
263
               on: v.id == t.owner_visitor_id,
264
               where: t.id == ^id,
265
               where: v.user_id == ^user_id or t.visibility in ^wide,
266
               select: {t, v.user_id}
267
             )
268
           ) do
269
      {:ok, thread, if(owner_user_id == user_id, do: :owner, else: :reader)}
270
    else
271
      _unreadable -> :error
272
    end
273
  end
274
275
  def fetch_readable(_user, _thread_id), do: :error
276
195 277
  @doc """
196 278
  The account's threads, newest first, bounded.
197 279
lib/openagents/threads/thread.ex modified +55 -2

@@ -32,10 +32,18 @@ defmodule OpenAgents.Threads.Thread do

32 32
  @objective_bytes 32_768
33 33
  @repository_bytes 200
34 34
35
  # The disclosure vocabulary is `OpenAgents.Transparency`'s — `dark`, `pulse`,
36
  # `ledger`, `glass` (`docs/taxonomy.md`) — and a thread offers the two rungs
37
  # this surface can enforce, not a fifth word of its own.
38
  # `ThreadVisibilityTest` proves the set stays a subset of that vocabulary.
39
  @visibilities ~w(dark ledger)
40
  @default_visibility "dark"
41
35 42
  schema "threads" do
36 43
    belongs_to :owner_visitor, Visitor
37 44
    field :objective, :string, redact: true
38 45
    field :repository, :string
46
    field :visibility, :string, default: "dark"
39 47
    field :status, :string, default: "open"
40 48
    field :model, :string
41 49
    field :reasoning_effort, :string

@@ -59,6 +67,31 @@ defmodule OpenAgents.Threads.Thread do

59 67
  def permission_profiles, do: @permission_profiles
60 68
  def reasoning_efforts, do: @reasoning_efforts
61 69
70
  @doc """
71
  The transparency tiers a thread may be opened at, narrowest first.
72
73
  Two rungs of the shared `dark/pulse/ledger/glass` ladder, because two are
74
  what a thread read path enforces. `pulse` would need a metadata-only
75
  projection of the transcript and `glass` would need a capability beyond
76
  reading it; neither exists, so neither is offered (THREAD-002).
77
  """
78
  def visibilities, do: @visibilities
79
80
  @doc "The tier a thread takes when its opener names none: owner-only."
81
  def default_visibility, do: @default_visibility
82
83
  @doc """
84
  The tiers that admit a reader who is not the account that opened the thread.
85
86
  Every rung above the default, derived rather than restated, so adding a rung
87
  to `visibilities/0` cannot leave the read path enforcing the old set.
88
  """
89
  def wide_visibilities, do: @visibilities -- [@default_visibility]
90
91
  @doc "Whether `thread` is readable by somebody other than its owner."
92
  @spec wide?(t()) :: boolean()
93
  def wide?(%__MODULE__{visibility: visibility}), do: visibility in wide_visibilities()
94
62 95
  @spec open?(t()) :: boolean()
63 96
  def open?(%__MODULE__{status: "open"}), do: true
64 97
  def open?(%__MODULE__{}), do: false

@@ -72,24 +105,44 @@ defmodule OpenAgents.Threads.Thread do

72 105
  repository table: a thread may concern a repository the forge does not host,
73 106
  so the field records the opener's `owner/name` string, bounded, with no
74 107
  foreign key and no format rule beyond non-blank.
108
109
  `visibility` is optional and defaults to `dark`, the owner-only rung. It is
110
  the one field here a caller can use to widen who reads the transcript, so it
111
  is cast rather than put: naming it is the explicit act, and omitting it
112
  leaves the thread private (THREAD-002).
75 113
  """
76 114
  def open_changeset(attributes, owner_visitor_id, now) do
77 115
    %__MODULE__{}
78
    |> cast(attributes, [:objective, :model, :reasoning_effort, :permission_profile, :repository])
116
    |> cast(attributes, [
117
      :objective,
118
      :model,
119
      :reasoning_effort,
120
      :permission_profile,
121
      :repository,
122
      :visibility
123
    ])
79 124
    |> put_change(:owner_visitor_id, owner_visitor_id)
80 125
    |> put_change(:status, "open")
81 126
    |> put_change(:generation, 0)
82 127
    |> put_change(:started_at, now)
83
    |> validate_required([:objective, :model, :reasoning_effort, :permission_profile])
128
    |> validate_required([
129
      :objective,
130
      :model,
131
      :reasoning_effort,
132
      :permission_profile,
133
      :visibility
134
    ])
84 135
    |> validate_length(:objective, min: 1, max: @objective_bytes, count: :bytes)
85 136
    |> validate_length(:model, min: 1, max: 200)
86 137
    |> validate_length(:repository, min: 1, max: @repository_bytes, count: :bytes)
87 138
    |> validate_inclusion(:reasoning_effort, @reasoning_efforts)
88 139
    |> validate_inclusion(:permission_profile, @permission_profiles)
140
    |> validate_inclusion(:visibility, @visibilities)
89 141
    |> foreign_key_constraint(:owner_visitor_id)
90 142
    |> check_constraint(:status, name: :threads_status_check)
91 143
    |> check_constraint(:objective, name: :threads_objective_bound_check)
92 144
    |> check_constraint(:repository, name: :threads_repository_bound_check)
145
    |> check_constraint(:visibility, name: :threads_visibility_check)
93 146
    |> check_constraint(:reasoning_effort, name: :threads_reasoning_effort_check)
94 147
    |> check_constraint(:permission_profile, name: :threads_permission_profile_check)
95 148
  end
lib/openagents_web/api_error.ex modified +9

@@ -66,6 +66,15 @@ defmodule OpenAgentsWeb.ApiError do

66 66
    # and its own code — never a silent substitution.
67 67
    "model_unavailable" => {503, "The model's provider is not configured on this deployment"},
68 68
    "thread_terminal" => {422, "This thread is terminal and its transcript is closed"},
69
    # Thread disclosure (THREAD-002). Widening a transcript is a consent
70
    # decision, so a tier that did not take is its own code rather than a field
71
    # message inside the generic 422: a client that meant to publish and did
72
    # not should learn that from the code it branches on. It covers both an
73
    # unknown word and a tier of the shared vocabulary this surface has no read
74
    # path for, because to a caller the effect is identical — the thread stayed
75
    # as private as it was.
76
    "thread_visibility_unsupported" =>
77
      {422, "That transparency tier is not one a thread can be opened at"},
69 78
    # A transcript writer meets two refusals that cannot change: a closed
70 79
    # thread, and an event the server has called invalid. `thread_terminal`
71 80
    # already carries a code; these give the other refusals of the append route
lib/openagents_web/controllers/api_extension_controller.ex modified +19

@@ -449,6 +449,25 @@ defmodule OpenAgentsWeb.ApiExtensionController do

449 449
              "match, so a resume picker filters structurally instead of " <>
450 450
              "parsing the objective back."
451 451
        },
452
        "visibility" => %{
453
          "endpoint" => "POST /api/v3/threads",
454
          "type" => "string",
455
          "enum" => OpenAgents.Threads.Thread.visibilities(),
456
          "default" => OpenAgents.Threads.Thread.default_visibility(),
457
          "description" =>
458
            "The transparency tier that governs who may read the thread's " <>
459
              "transcript, from the same dark/pulse/ledger/glass vocabulary " <>
460
              "the forge uses for disclosure. Optional and owner-only by " <>
461
              "default: `dark` keeps the thread to the account that opened " <>
462
              "it, and `ledger` opens GET /api/v3/threads/{thread_id} and its " <>
463
              "events to any signed-in reader holding the thread id. Widening " <>
464
              "is recorded in the transcript as `thread.visibility_set`. The " <>
465
              "`pulse` and `glass` tiers have no thread read path behind them " <>
466
              "and are refused with `thread_visibility_unsupported`, as is any " <>
467
              "value that is not a tier. A wider tier widens reads only: the " <>
468
              "transcript append, the cancel, and the re-mint stay owner-only, " <>
469
              "and a reader admitted by the tier is served `\"grant\": null`."
470
        },
452 471
        "model" => %{
453 472
          "endpoint" => "POST /api/v3/threads",
454 473
          "type" => "string",
lib/openagents_web/controllers/thread_controller.ex modified +86 -5

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

17 17
  - **Revocation does not wait to be asked.** `DELETE` revokes immediately, and
18 18
    every request first retires the account's elapsed authority, so a grant
19 19
    past its expiry stops being live whether or not anyone presents it.
20
  - **Disclosure is opt-in and narrow.** A thread opens `dark` — owner-only —
21
    unless the caller names a wider transparency tier, and a tier this surface
22
    cannot enforce is refused with `thread_visibility_unsupported`. A wider
23
    tier reaches `show/2` and `events/2` and nothing else: the writes and the
24
    mint stay owner-only, and a reader admitted by the tier is not shown the
25
    owner's grant (THREAD-002).
20 26
21 27
  The model is admitted here and nowhere else. A request body sent to the proxy
22 28
  still cannot select a model — the proxy pins the grant's — so the one place a

@@ -41,11 +47,13 @@ defmodule OpenAgentsWeb.ThreadController do

41 47
  def create(conn, params) do
42 48
    with {:ok, objective} <- objective(params),
43 49
         {:ok, repository} <- repository(params),
50
         {:ok, visibility} <- visibility(params),
44 51
         {:ok, options} <- execution_shape(params) do
45
      open(conn, objective, options ++ repository)
52
      open(conn, objective, options ++ repository ++ visibility)
46 53
    else
47 54
      {:refused, field, message} -> ApiError.validation_failed(conn, %{field => [message]})
48 55
      {:unavailable, model_id} -> unavailable_model(conn, model_id)
56
      {:unsupported_visibility, value} -> unsupported_visibility(conn, value)
49 57
    end
50 58
  end
51 59

@@ -76,7 +84,7 @@ defmodule OpenAgentsWeb.ThreadController do

76 84
  reading one thread see one transcript rather than two that have diverged.
77 85
  """
78 86
  def events(conn, %{"thread_id" => thread_id} = params) do
79
    with_thread(conn, thread_id, fn thread ->
87
    with_readable_thread(conn, thread_id, fn thread, _relation ->
80 88
      events = Threads.list_events(thread, listing_options(params))
81 89
82 90
      conn

@@ -131,8 +139,18 @@ defmodule OpenAgentsWeb.ThreadController do

131 139
    end
132 140
  end
133 141
142
  @doc """
143
  One thread, with the grant it holds.
144
145
  This and `events/2` are the two reads a wider transparency tier reaches. The
146
  grant is not part of what a tier discloses: it is the owner's money, so a
147
  reader admitted by the thread's tier gets `"grant": null` rather than the
148
  account's ceilings and spend (THREAD-002).
149
  """
134 150
  def show(conn, %{"thread_id" => thread_id}) do
135
    with_thread(conn, thread_id, fn thread -> render_thread(conn, :ok, thread) end)
151
    with_readable_thread(conn, thread_id, fn thread, relation ->
152
      render_thread(conn, :ok, thread, relation)
153
    end)
136 154
  end
137 155
138 156
  def delete(conn, %{"thread_id" => thread_id}) do

@@ -323,6 +341,10 @@ defmodule OpenAgentsWeb.ThreadController do

323 341
324 342
  # Expiry is retired before the lookup, so a read reports what is true now
325 343
  # rather than what was true when the grant was minted.
344
  #
345
  # Owner-only, and deliberately so: this is what every write and every
346
  # authority-bearing route resolves through. A thread published for reading is
347
  # not a thread a stranger may append to, cancel, or re-mint.
326 348
  defp with_thread(conn, thread_id, continue) do
327 349
    user = conn.assigns.current_user
328 350
    _reaped = Threads.reap_expired(user)

@@ -333,13 +355,27 @@ defmodule OpenAgentsWeb.ThreadController do

333 355
    end
334 356
  end
335 357
336
  defp render_thread(conn, status, %Thread{} = thread) do
358
  # The read half. A thread the account owns, or somebody else's thread at a
359
  # tier that admits this reader; anything else is the same plain 404 a
360
  # non-owner has always received, so a `dark` thread's existence is still not
361
  # confirmed to a stranger.
362
  defp with_readable_thread(conn, thread_id, continue) do
363
    user = conn.assigns.current_user
364
    _reaped = Threads.reap_expired(user)
365
366
    case Threads.fetch_readable(user, thread_id) do
367
      {:ok, thread, relation} -> continue.(thread, relation)
368
      :error -> ApiError.not_found(conn)
369
    end
370
  end
371
372
  defp render_thread(conn, status, %Thread{} = thread, relation \\ :owner) do
337 373
    conn
338 374
    |> put_extension_header()
339 375
    |> put_status(status)
340 376
    |> json(%{
341 377
      "thread" => thread_view(thread),
342
      "grant" => grant_view(Threads.latest_grant(thread))
378
      "grant" => grant_view(thread, relation)
343 379
    })
344 380
  end
345 381

@@ -493,6 +529,44 @@ defmodule OpenAgentsWeb.ThreadController do

493 529
494 530
  defp repository(_params), do: {:ok, []}
495 531
532
  # The consent gate. Absent means owner-only, because the tier a caller did
533
  # not ask for is the narrow one. A value outside the admitted set — a tier
534
  # this surface cannot enforce, or a word that is not a tier at all — is
535
  # refused with its own code rather than folded into the generic 422: a client
536
  # widening a transcript is making a disclosure decision, and it should learn
537
  # that the decision did not take, not guess from a field message.
538
  defp visibility(%{"visibility" => value}) when is_binary(value) do
539
    trimmed = String.trim(value)
540
541
    if trimmed in Thread.visibilities() do
542
      {:ok, [visibility: trimmed]}
543
    else
544
      {:unsupported_visibility, trimmed}
545
    end
546
  end
547
548
  defp visibility(%{"visibility" => value}) when not is_nil(value) do
549
    {:refused, "visibility", "#{inspect(value)} is not a string."}
550
  end
551
552
  defp visibility(_params), do: {:ok, []}
553
554
  defp unsupported_visibility(conn, value) do
555
    sentence =
556
      "#{inspect(value)} is not an admitted thread visibility. " <>
557
        "Admitted: #{Enum.join(Thread.visibilities(), ", ")}. " <>
558
        "A thread's visibility is the transparency tier that governs who may read its " <>
559
        "transcript: #{Thread.default_visibility()} keeps it to the account that opened it, " <>
560
        "and ledger opens it to any signed-in reader holding the thread id. " <>
561
        "The pulse and glass tiers of the shared vocabulary have no thread read path " <>
562
        "behind them, so this surface does not offer them."
563
564
    ApiError.refuse(conn, "thread_visibility_unsupported",
565
      message: sentence,
566
      errors: %{"visibility" => [sentence]}
567
    )
568
  end
569
496 570
  defp execution_shape(params) do
497 571
    with {:ok, model} <- admitted(params, "model", Models.ids(), Models.default_id()),
498 572
         :ok <- serving(model),

@@ -583,6 +657,7 @@ defmodule OpenAgentsWeb.ThreadController do

583 657
      "status" => thread.status,
584 658
      "objective" => thread.objective,
585 659
      "repository" => thread.repository,
660
      "visibility" => thread.visibility,
586 661
      "reasoning_effort" => thread.reasoning_effort,
587 662
      "permission_profile" => thread.permission_profile,
588 663
      "generation" => thread.generation,

@@ -607,6 +682,12 @@ defmodule OpenAgentsWeb.ThreadController do

607 682
    }
608 683
  end
609 684
685
  # A reader admitted by the thread's tier is not admitted to the owner's
686
  # balance. The tier discloses the transcript; the grant is what the account
687
  # is spending, and no rung of the ladder names it.
688
  defp grant_view(%Thread{}, :reader), do: nil
689
  defp grant_view(%Thread{} = thread, :owner), do: grant_view(Threads.latest_grant(thread))
690
610 691
  defp grant_view(nil), do: nil
611 692
612 693
  defp grant_view(%Grant{} = grant) do
lib/openagents_web/live/thread_show_live.ex modified +27 -8

@@ -2,10 +2,17 @@ defmodule OpenAgentsWeb.ThreadShowLive do

2 2
  @moduledoc """
3 3
  One thread's transcript, read-only, live.
4 4
5
  Owner-only: an unknown id and another account's id both raise the plain 404
6
  (`OpenAgentsWeb.PublicNotFoundError`), matching how the API's
7
  `Threads.get_for_user/2` scopes reads — existence is never confirmed to a
8
  non-owner.
5
  Private by default: an unknown id and another account's `dark` thread both
6
  raise the plain 404 (`OpenAgentsWeb.PublicNotFoundError`), matching how the
7
  API scopes the same read — existence is never confirmed to a reader the
8
  thread's transparency tier does not admit.
9
10
  A thread opened at a wider tier is readable here by any signed-in account
11
  holding its id, which is the audience this route can actually enforce: it
12
  sits in the `:authenticated` live session, so there is no anonymous reader to
13
  admit and none is invented. What the tier does not disclose is the owner's
14
  money — the budget card is the account's grant, and it renders for the owner
15
  only (THREAD-002).
9 16
10 17
  The snapshot-to-live order follows the projection protocol the issue names:
11 18
  subscribe to the thread's topic first, then read the snapshot, then let

@@ -34,23 +41,25 @@ defmodule OpenAgentsWeb.ThreadShowLive do

34 41
    user = socket.assigns.current_user
35 42
    _reaped = Threads.reap_expired(user)
36 43
37
    case Threads.get_for_user(user, thread_id) do
38
      nil ->
44
    case Threads.fetch_readable(user, thread_id) do
45
      :error ->
39 46
        raise OpenAgentsWeb.PublicNotFoundError, message: "thread not found"
40 47
41
      thread ->
48
      {:ok, thread, relation} ->
42 49
        # Attach the live subscriber before reading the snapshot: an append
43 50
        # that lands between the two arrives as a buffered message and is
44 51
        # deduped below by id, so the gap cannot lose an event.
45 52
        if connected?(socket), do: Threads.subscribe(thread)
46 53
47 54
        events = transcript(thread)
55
        owner? = relation == :owner
48 56
49 57
        {:ok,
50 58
         socket
51 59
         |> assign(:page_title, "Thread · OpenAgents")
52 60
         |> assign(:thread, thread)
53
         |> assign(:grant, Threads.latest_grant(thread))
61
         |> assign(:owner?, owner?)
62
         |> assign(:grant, owner? && Threads.latest_grant(thread))
54 63
         |> assign(:last_event_id, last_id(events))
55 64
         |> assign(:events_empty?, events == [])
56 65
         |> stream(:events, events)}

@@ -102,6 +111,10 @@ defmodule OpenAgentsWeb.ThreadShowLive do

102 111
              <dt>Permissions</dt>
103 112
              <dd>{@thread.permission_profile}</dd>
104 113
            </div>
114
            <div class="flex gap-1.5">
115
              <dt>Visibility</dt>
116
              <dd id="thread-visibility">{visibility_label(@thread.visibility)}</dd>
117
            </div>
105 118
            <div class="flex gap-1.5">
106 119
              <dt>Events</dt>
107 120
              <dd id="thread-event-count" class="tabular-nums">{@thread.event_count}</dd>

@@ -390,6 +403,12 @@ defmodule OpenAgentsWeb.ThreadShowLive do

390 403
391 404
  defp dollars(microusd), do: "$#{:erlang.float_to_binary(microusd / 1_000_000, decimals: 2)}"
392 405
406
  # The tier word plus what it means for a reader, because "dark" alone tells
407
  # the owner nothing about who can see the page they are looking at.
408
  defp visibility_label("dark"), do: "dark · only you"
409
  defp visibility_label("ledger"), do: "ledger · anyone signed in with the link"
410
  defp visibility_label(visibility), do: visibility
411
393 412
  defp status_variant("open"), do: :info
394 413
  defp status_variant("succeeded"), do: :success
395 414
  defp status_variant("failed"), do: :danger
priv/repo/migrations/20260824210500_add_visibility_to_threads.exs added +34

@@ -0,0 +1,34 @@

1
defmodule OpenAgents.Repo.Migrations.AddVisibilityToThreads do
2
  @moduledoc """
3
  The transparency tier that governs who may read a thread's transcript.
4
5
  The vocabulary is the one this application already uses for disclosure —
6
  `dark`, `pulse`, `ledger`, `glass` (`OpenAgents.Transparency`,
7
  `OpenAgents.Forge.Visibility`, `docs/taxonomy.md`) — not a second ladder. The
8
  check constraint admits only the two rungs this surface can enforce today:
9
10
    * `dark`   — the account that opened the thread, and nobody else. Default.
11
    * `ledger` — content and metadata: any signed-in reader holding the
12
      thread's id may read the transcript.
13
14
  `pulse` (metadata only) and `glass` (full access) are in the vocabulary and
15
  have no thread surface behind them, so the column refuses them rather than
16
  storing a tier no read path applies. Widening is a deliberate act at open
17
  and is recorded in the transcript as `thread.visibility_set` (THREAD-002).
18
19
  The default is the narrow rung, so every thread written before this migration
20
  becomes owner-only rather than inheriting a widening nobody asked for.
21
  """
22
23
  use Ecto.Migration
24
25
  def change do
26
    alter table(:threads) do
27
      add :visibility, :text, null: false, default: "dark"
28
    end
29
30
    create constraint(:threads, :threads_visibility_check,
31
             check: "visibility IN ('dark', 'ledger')"
32
           )
33
  end
34
end
test/openagents/threads/grant_token_reach_test.exs modified +29 -11

@@ -55,6 +55,7 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

55 55
    {:ceilings, 1} => :no_thread,
56 56
    {:default_permission_profile, 0} => :no_thread,
57 57
    {:default_reasoning, 0} => :no_thread,
58
    {:fetch_readable, 2} => :scoped_by_tier,
58 59
    {:finish, 2} => :thread_struct,
59 60
    {:get_for_user, 2} => :scoped_by_owner,
60 61
    {:latest_grant, 1} => :thread_struct,

@@ -80,12 +81,20 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

80 81
  # One controller, serving one route: the mint.
81 82
  @grant_token_holders [OpenAgentsWeb.ThreadController]
82 83
83
  # The one function that resolves a thread from an identifier, and every
84
  # module that calls it. It takes the acting account, so another account's
85
  # thread id resolves to `nil` (THREAD-001, IDENTITY-002). The web thread
86
  # viewer resolves through the same lookup and renders a `nil` as the plain
87
  # 404 an absent id gets.
88
  @thread_resolver_callers [OpenAgentsWeb.ThreadController, OpenAgentsWeb.ThreadShowLive]
84
  # The two functions that resolve a thread from an identifier, and every
85
  # module that calls each. Both take the acting account, so neither can be
86
  # handed a bare id (THREAD-001, IDENTITY-002), and they differ in exactly one
87
  # way: `get_for_user/2` admits the owner and nobody else, while
88
  # `fetch_readable/2` also admits a reader the thread's transparency tier
89
  # names (THREAD-002).
90
  #
91
  # The split is the enforcement. Every verb that writes to a thread or mints
92
  # its authority resolves through the owner-scoped lookup, so widening a
93
  # transcript for reading can never widen what may be done to it. The one
94
  # controller serves both, because it serves both the reads and the writes;
95
  # the web viewer only reads.
96
  @owner_resolver_callers [OpenAgentsWeb.ThreadController]
97
  @tier_resolver_callers [OpenAgentsWeb.ThreadController, OpenAgentsWeb.ThreadShowLive]
89 98
90 99
  test "the modules that mint a grant token are exactly the set THREAD-001 accounts for" do
91 100
    assert_exact_set(

@@ -130,14 +139,23 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

130 139
           """
131 140
  end
132 141
133
  test "a thread resolves by identifier only through the owner-scoped lookup" do
134
    resolvers = for {key, :scoped_by_owner} <- @threads_api, do: key
135
    assert {:get_for_user, 2} in resolvers
142
  test "a thread resolves by identifier only through the two account-taking lookups" do
143
    owner_scoped = for {key, :scoped_by_owner} <- @threads_api, do: key
144
    tier_scoped = for {key, :scoped_by_tier} <- @threads_api, do: key
145
146
    assert {:get_for_user, 2} in owner_scoped
147
    assert tier_scoped == [{:fetch_readable, 2}]
136 148
137 149
    assert_exact_set(
138 150
      callers_of([{OpenAgents.Threads, :get_for_user, 2}]),
139
      @thread_resolver_callers,
140
      "resolves a thread by identifier"
151
      @owner_resolver_callers,
152
      "resolves a thread by identifier, owner-scoped"
153
    )
154
155
    assert_exact_set(
156
      callers_of([{OpenAgents.Threads, :fetch_readable, 2}]),
157
      @tier_resolver_callers,
158
      "resolves a thread by identifier through its transparency tier"
141 159
    )
142 160
  end
143 161
test/openagents/threads/visibility_test.exs added +176

@@ -0,0 +1,176 @@

1
defmodule OpenAgents.Threads.VisibilityTest do
2
  @moduledoc """
3
  THREAD-002: a thread's transcript is private until its owner says otherwise.
4
5
  The tier vocabulary is not this module's invention — it is the
6
  `dark/pulse/ledger/glass` ladder `OpenAgents.Transparency` and
7
  `OpenAgents.Forge.Visibility` already use (`docs/taxonomy.md`), and the first
8
  test here holds the thread's admitted set to it, so a fifth word cannot enter
9
  through this door.
10
  """
11
  use OpenAgents.DataCase, async: false
12
13
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
14
15
  alias OpenAgents.DataRights.AccountExport
16
  alias OpenAgents.Threads
17
  alias OpenAgents.Threads.Thread
18
  alias OpenAgents.Transparency
19
20
  defp owner(key), do: github_user("thread-visibility-#{key}")
21
22
  defp event_types(thread) do
23
    thread |> Threads.list_events() |> Enum.map(& &1.event_type)
24
  end
25
26
  describe "the vocabulary" do
27
    test "a thread's tiers are rungs of the shared transparency ladder" do
28
      shared = Enum.map(Transparency.tier_atoms(), &Atom.to_string/1)
29
30
      assert Thread.visibilities() -- shared == [],
31
             "a thread must not invent a tier word the transparency ladder does not have"
32
33
      assert Thread.default_visibility() == "dark"
34
      assert Thread.wide_visibilities() == ["ledger"]
35
    end
36
37
    test "the tiers with no thread read path behind them are not offered" do
38
      refute "pulse" in Thread.visibilities()
39
      refute "glass" in Thread.visibilities()
40
    end
41
  end
42
43
  describe "open/3" do
44
    test "a thread defaults to owner-only" do
45
      {:ok, thread} = Threads.open(owner("default"), "Keep it to myself")
46
47
      assert thread.visibility == "dark"
48
      refute Thread.wide?(thread)
49
    end
50
51
    test "an explicit tier is accepted and recorded as an act" do
52
      {:ok, thread} =
53
        Threads.open(owner("explicit"), "Share the transcript", visibility: "ledger")
54
55
      assert thread.visibility == "ledger"
56
      assert Thread.wide?(thread)
57
58
      # Widening leaves a record in the transcript, not only a column value.
59
      assert event_types(thread) == ["thread.opened", "thread.visibility_set"]
60
      assert thread.event_count == 2
61
62
      widened = thread |> Threads.list_events() |> List.last()
63
      assert widened.payload == %{"visibility" => "ledger", "from" => "dark"}
64
    end
65
66
    test "a default thread records no widening" do
67
      {:ok, thread} = Threads.open(owner("unwidened"), "Nothing to declare")
68
69
      assert event_types(thread) == ["thread.opened"]
70
      assert thread.event_count == 1
71
    end
72
73
    test "an unknown tier is refused, and nothing is opened" do
74
      user = owner("unknown")
75
76
      assert {:error, changeset} = Threads.open(user, "Widen me", visibility: "public")
77
      assert "is invalid" in errors_on(changeset).visibility
78
79
      assert Threads.list_for_user(user) == []
80
    end
81
82
    test "a tier the surface cannot enforce is refused like any other" do
83
      for tier <- ~w(pulse glass) do
84
        assert {:error, changeset} =
85
                 Threads.open(owner("unenforceable"), "Widen me", visibility: tier)
86
87
        assert "is invalid" in errors_on(changeset).visibility
88
      end
89
    end
90
  end
91
92
  describe "fetch_readable/2" do
93
    test "the owner reads their own thread at either tier" do
94
      user = owner("owner-reads")
95
96
      {:ok, dark} = Threads.open(user, "Private work")
97
      {:ok, wide} = Threads.open(user, "Shared work", visibility: "ledger")
98
99
      assert {:ok, %Thread{id: id}, :owner} = Threads.fetch_readable(user, dark.id)
100
      assert id == dark.id
101
      assert {:ok, %Thread{}, :owner} = Threads.fetch_readable(user, wide.id)
102
    end
103
104
    test "a stranger reads a wide thread and is named a reader, not an owner" do
105
      {:ok, thread} = Threads.open(owner("wide"), "Shared work", visibility: "ledger")
106
107
      assert {:ok, %Thread{id: id}, :reader} =
108
               Threads.fetch_readable(owner("stranger-wide"), thread.id)
109
110
      assert id == thread.id
111
    end
112
113
    test "a stranger cannot read an owner-only thread" do
114
      {:ok, thread} = Threads.open(owner("dark"), "Private work")
115
116
      assert Threads.fetch_readable(owner("stranger-dark"), thread.id) == :error
117
    end
118
119
    test "an unknown id is the same refusal as somebody else's private thread" do
120
      assert Threads.fetch_readable(owner("missing"), Ecto.UUID.generate()) == :error
121
      assert Threads.fetch_readable(owner("missing"), "not-a-uuid") == :error
122
    end
123
124
    test "writing stays owner-only however wide the tier" do
125
      {:ok, thread} = Threads.open(owner("write-fence"), "Shared work", visibility: "ledger")
126
      stranger = owner("stranger-write")
127
128
      # The read admits the stranger; the owner-scoped lookup every write and
129
      # every mint resolves through does not.
130
      assert {:ok, _thread, :reader} = Threads.fetch_readable(stranger, thread.id)
131
      assert Threads.get_for_user(stranger, thread.id) == nil
132
    end
133
  end
134
135
  describe "the account export" do
136
    test "carries the account's threads, their events, and the tier that governs them" do
137
      user = owner("export")
138
139
      {:ok, thread} =
140
        Threads.open(user, "Export this transcript",
141
          repository: "OpenAgentsInc/openagents.com",
142
          visibility: "ledger"
143
        )
144
145
      {:ok, _thread} =
146
        Threads.record_event(thread, "tool.ran", %{"tool" => "bash", "status" => "ok"})
147
148
      assert {:ok, export} = AccountExport.build(user)
149
      assert %{"records" => [record]} = export["threads"]
150
151
      assert record["id"] == thread.id
152
      assert record["objective"] == "Export this transcript"
153
      assert record["repository"] == "OpenAgentsInc/openagents.com"
154
      # The consent record travels with the data it governs.
155
      assert record["visibility"] == "ledger"
156
157
      types = Enum.map(record["events"], & &1["event_type"])
158
      assert "thread.opened" in types
159
      assert "thread.visibility_set" in types
160
      assert "tool.ran" in types
161
162
      # A plugin run is a thread event, so the export reaches it through the
163
      # thread rather than through a second collection (#206).
164
      tool_ran = Enum.find(record["events"], &(&1["event_type"] == "tool.ran"))
165
      assert tool_ran["payload"] == %{"tool" => "bash", "status" => "ok"}
166
    end
167
168
    test "an owner-only thread exports with its default tier stated" do
169
      user = owner("export-dark")
170
      {:ok, _thread} = Threads.open(user, "Private work")
171
172
      assert {:ok, export} = AccountExport.build(user)
173
      assert [%{"visibility" => "dark"}] = export["threads"]["records"]
174
    end
175
  end
176
end
test/openagents_web/thread_visibility_test.exs added +210

@@ -0,0 +1,210 @@

1
defmodule OpenAgentsWeb.ThreadVisibilityTest do
2
  @moduledoc """
3
  THREAD-002 at the two doors a thread is read through: the API and the web
4
  transcript viewer.
5
6
  Both doors answer the same three questions the same way — an owner reads
7
  their own thread, a reader admitted by the thread's tier reads it without the
8
  owner's budget, and a stranger at an owner-only thread gets the plain 404
9
  that never confirms the thread exists.
10
  """
11
  use OpenAgentsWeb.ConnCase, async: false
12
13
  import Phoenix.LiveViewTest
14
15
  alias OpenAgents.Threads
16
17
  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
18
19
  describe "POST /api/v3/threads" do
20
    test "a thread opens owner-only when the caller names no tier", %{conn: conn} do
21
      thread =
22
        conn
23
        |> put_chat_api_token("visibility-default")
24
        |> post(~p"/api/v3/threads", %{"objective" => "Keep it to myself."})
25
        |> json_response(201)
26
        |> Map.fetch!("thread")
27
28
      assert thread["visibility"] == "dark"
29
    end
30
31
    test "an explicit tier is accepted and published", %{conn: conn} do
32
      thread =
33
        conn
34
        |> put_chat_api_token("visibility-explicit")
35
        |> post(~p"/api/v3/threads", %{
36
          "objective" => "Share the transcript.",
37
          "visibility" => "ledger"
38
        })
39
        |> json_response(201)
40
        |> Map.fetch!("thread")
41
42
      assert thread["visibility"] == "ledger"
43
      assert thread["event_count"] == 2
44
    end
45
46
    test "an unknown tier is refused with its own code, not a bare 422", %{conn: conn} do
47
      body =
48
        conn
49
        |> put_chat_api_token("visibility-unknown")
50
        |> post(~p"/api/v3/threads", %{
51
          "objective" => "Widen me.",
52
          "visibility" => "public"
53
        })
54
        |> assert_api_error(422, nil)
55
56
      assert body["code"] == "thread_visibility_unsupported"
57
      assert body["message"] =~ "\"public\" is not an admitted thread visibility"
58
      assert body["message"] =~ "Admitted: dark, ledger"
59
      assert body["errors"]["visibility"] != nil
60
    end
61
62
    test "a tier this surface cannot enforce is refused the same way", %{conn: conn} do
63
      for tier <- ~w(pulse glass) do
64
        body =
65
          conn
66
          |> put_chat_api_token("visibility-unenforceable-" <> tier)
67
          |> post(~p"/api/v3/threads", %{"objective" => "Widen me.", "visibility" => tier})
68
          |> assert_api_error(422, nil)
69
70
        assert body["code"] == "thread_visibility_unsupported"
71
      end
72
    end
73
74
    test "a non-string tier is refused as a field error", %{conn: conn} do
75
      body =
76
        conn
77
        |> put_chat_api_token("visibility-nonstring")
78
        |> post(~p"/api/v3/threads", %{"objective" => "Widen me.", "visibility" => 3})
79
        |> assert_api_error(422, nil)
80
81
      assert body["code"] == "validation_failed"
82
      assert body["errors"]["visibility"] != nil
83
    end
84
  end
85
86
  describe "GET /api/v3/threads/{thread_id}" do
87
    test "a reader admitted by the tier reads the thread without the owner's grant", %{conn: conn} do
88
      {:ok, thread} =
89
        Threads.open(github_user("api-wide-owner"), "Shared work", visibility: "ledger")
90
91
      body =
92
        conn
93
        |> put_chat_api_token("api-wide-reader")
94
        |> get(~p"/api/v3/threads/#{thread.id}")
95
        |> json_response(200)
96
97
      assert body["thread"]["id"] == thread.id
98
      assert body["thread"]["visibility"] == "ledger"
99
      # The tier discloses the transcript. It does not disclose what the
100
      # owner's account is spending.
101
      assert body["grant"] == nil
102
    end
103
104
    test "the owner still reads their own grant", %{conn: conn} do
105
      conn = put_chat_api_token(conn, "api-owner-grant")
106
107
      thread =
108
        conn
109
        |> post(~p"/api/v3/threads", %{
110
          "objective" => "Shared work.",
111
          "visibility" => "ledger"
112
        })
113
        |> json_response(201)
114
        |> Map.fetch!("thread")
115
116
      body = conn |> get(~p"/api/v3/threads/#{thread["id"]}") |> json_response(200)
117
118
      assert body["grant"]["status"] == "active"
119
    end
120
121
    test "a stranger at an owner-only thread gets the plain 404", %{conn: conn} do
122
      {:ok, thread} = Threads.open(github_user("api-dark-owner"), "Private work")
123
124
      assert conn
125
             |> put_chat_api_token("api-dark-stranger")
126
             |> get(~p"/api/v3/threads/#{thread.id}")
127
             |> api_error_code(404) == "not_found"
128
    end
129
130
    test "the transcript follows the same tier as the thread", %{conn: conn} do
131
      owner = github_user("api-events-owner")
132
      {:ok, wide} = Threads.open(owner, "Shared work", visibility: "ledger")
133
      {:ok, _wide} = Threads.record_event(wide, "tool.ran", %{"tool" => "bash"})
134
      {:ok, dark} = Threads.open(owner, "Private work")
135
136
      reader = put_chat_api_token(conn, "api-events-reader")
137
138
      body = reader |> get(~p"/api/v3/threads/#{wide.id}/events") |> json_response(200)
139
      assert Enum.any?(body["events"], &(&1["event_type"] == "tool.ran"))
140
141
      assert reader |> get(~p"/api/v3/threads/#{dark.id}/events") |> api_error_code(404) ==
142
               "not_found"
143
    end
144
145
    test "a wider tier widens reads only: a reader cannot write, cancel, or re-mint", %{
146
      conn: conn
147
    } do
148
      {:ok, thread} =
149
        Threads.open(github_user("api-fence-owner"), "Shared work", visibility: "ledger")
150
151
      reader = put_chat_api_token(conn, "api-fence-reader")
152
153
      assert reader
154
             |> post(~p"/api/v3/threads/#{thread.id}/events", %{
155
               "event_type" => "turn.user",
156
               "payload" => %{"text" => "not yours"}
157
             })
158
             |> api_error_code(404) == "not_found"
159
160
      assert reader
161
             |> post(~p"/api/v3/threads/#{thread.id}/grants", %{})
162
             |> api_error_code(404) == "not_found"
163
164
      assert reader |> delete(~p"/api/v3/threads/#{thread.id}") |> api_error_code(404) ==
165
               "not_found"
166
167
      assert Threads.get_for_user(github_user("api-fence-owner"), thread.id).status == "open"
168
    end
169
  end
170
171
  describe "/threads/:id" do
172
    test "the owner sees the tier and their own budget", %{conn: conn} do
173
      owner = github_user("live-owner")
174
      {:ok, thread} = Threads.open(owner, "Private work")
175
      {:ok, _thread, _grant, _token} = Threads.mint_grant(thread)
176
177
      {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads/#{thread.id}")
178
179
      assert view |> element("#thread-visibility") |> render() =~ "dark"
180
      assert has_element?(view, "#thread-budget")
181
    end
182
183
    test "a reader admitted by the tier reads the transcript without the budget", %{conn: conn} do
184
      owner = github_user("live-wide-owner")
185
      {:ok, thread} = Threads.open(owner, "Shared work", visibility: "ledger")
186
      {:ok, thread} = Threads.record_event(thread, "turn.user", %{"text" => "Fix the parser"})
187
      {:ok, _thread, _grant, _token} = Threads.mint_grant(thread)
188
189
      reader = github_user("live-wide-reader")
190
      {:ok, view, _html} = live(signed_in(conn, reader), ~p"/threads/#{thread.id}")
191
192
      assert render(view) =~ "Fix the parser"
193
      assert view |> element("#thread-visibility") |> render() =~ "ledger"
194
      refute has_element?(view, "#thread-budget")
195
    end
196
197
    test "a stranger at an owner-only thread gets the same 404 as a missing one", %{conn: conn} do
198
      {:ok, thread} = Threads.open(github_user("live-dark-owner"), "Private work")
199
      stranger = signed_in(conn, github_user("live-dark-stranger"))
200
201
      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
202
        live(stranger, ~p"/threads/#{thread.id}")
203
      end
204
205
      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
206
        live(stranger, ~p"/threads/#{Ecto.UUID.generate()}")
207
      end
208
    end
209
  end
210
end

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