Let a thread's transcript hold what happened

cb4d5cc1beb1 · AtlantisPleb · · parent 3d5577e2a7f3

Let a thread's transcript hold what happened

The 16 KB payload ceiling on `thread_events` was inherited, not reasoned. The
same bound sits on `voice_events`, `scv_run_events`, and program receipts, and
the schema said outright that it followed `OpenAgents.SCV.ExecutionEvent`. Where
that bound is justified, it is justified by those tables carrying no content:
the SCV audit records that an event payload is reduced to a fixed key allowlist
and that no file path, tool argument, tool output, or report prose reaches a
row, and `ComputerActivity` states that its events are a projection and never
the authority.

`thread_events` is the authority, and the bar is a full ATIF trajectory. A
ceiling designed to keep content out of a projection is the wrong rule for the
table that holds the content, and the previous change proposed splitting
reasoning across events to satisfy it — solving a wire problem in the store, and
charging every future reader a reassembly step for it. A single reasoning block
observed in a live session is 38,791 characters.

So the ceiling is dropped. The floor stays at "is a JSON object", which an event
carrying nothing but its type satisfies; the schema pin and the append-only
shape are untouched. `INVARIANTS.md` moves with it, and says why the number is
gone rather than only that it is.

Storing and sending are now stated as separate questions. The record holds every
turn, all of the reasoning, and every tool result whole. What a client sends to
a model stays bounded by the client, where a context budget belongs.

One claim corrected on the way: the remaining floor does not mean an event
"says something" — `{}` is two bytes and passes, and the route defaults an
absent payload to exactly that. The test now asserts what the constraint does
rather than what I first said it did.

Also, the precommit gate is scaled to the change. It compiled, audited, built
assets, and ran four thousand tests for a one-line documentation edit, which is
how a gate stops being run. A change touching only Markdown that no code or test
names by path now runs only the checks that read prose; everything else, and
anything ambiguous, takes the whole gate as before. Several documents here are
read by the suite, so the rule is by path rather than by extension.

4155 tests pass.

Deploy story

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

pushed
by user · WAL seq 292 · 2026-08-24T18:57:55.529249Z

Changed files

  • modified INVARIANTS.md
  • modified docs/2026-08-24-coder-account-integration-audit.md
  • modified lib/openagents/threads/event.ex
  • modified mix.exs
  • added ops/dev/precommit.sh
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824184030_relax_thread_event_payload_ceiling.exs
  • modified test/openagents/threads_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

9 files changed, +258 -54

INVARIANTS.md modified +12 -5

@@ -1968,11 +1968,18 @@ conversation, and a thread is not one.

1968 1968
  thread's active grants inside the transaction that writes the terminal row,
1969 1969
  and `mint_grant/1` refuses a thread that is not open. Deleting a thread — or
1970 1970
  the account, under the DATA-004 cascade — deletes its grants with it.
1971
- **A thread is bounded.** The objective is capped at 32 KB and the terminal
1972
  report at 32 KB, both by check constraint; every transcript entry is pinned
1973
  to `openagents.thread.event.v1` with a 16 KB payload ceiling and no
1974
  `updated_at`; and a thread is open with no report or terminal with one,
1975
  never both and never neither.
1971
- **A thread is bounded where a bound means something.** The objective is
1972
  capped at 32 KB and the terminal report at 32 KB, both by check constraint;
1973
  every transcript entry is pinned to `openagents.thread.event.v1` with no
1974
  `updated_at`; and a thread is open with no report or terminal with one, never
1975
  both and never neither. A transcript entry carries no size ceiling, only the
1976
  requirement that it says something (`thread_events_payload_present_check`).
1977
  The 16 KB ceiling it used to carry was inherited from `scv_run_events`, whose
1978
  payloads are a deliberately minimal projection of work stored elsewhere; this
1979
  table is the work, and has to reproduce a session as a full ATIF trajectory. A
1980
  single observed reasoning block is 38,791 characters, so the ceiling bought
1981
  chunking and reassembly on every read rather than any real bound. What a
1982
  client sends to a model is bounded by the client.
1976 1983
1977 1984
- **Authority is capped at admission.** `OpenAgents.Threads.open/3` refuses an
1978 1985
  account already holding `maximum_open_threads_per_account` open threads with
docs/2026-08-24-coder-account-integration-audit.md modified +54 -26

@@ -141,11 +141,13 @@ about taste.

141 141
142 142
### The two limits
143 143
144
**A payload is 2 to 16,384 bytes**, enforced by
145
`thread_events_payload_bound_check` on `octet_length(payload::text)`.
144
**A payload had a 16,384 byte ceiling**, enforced by
145
`thread_events_payload_bound_check`. It was inherited from tables that carry no
146
content and has been removed; the section below says why.
146 147
147 148
**A listing returns at most 50 events**, `@maximum_listed` in
148
`OpenAgents.Threads`.
149
`OpenAgents.Threads`. This one is a pagination bound rather than a storage
150
bound, and the cursor added alongside it is the answer.
149 151
150 152
Measured against four real coder sessions from the same afternoon:
151 153

@@ -156,16 +158,15 @@ Measured against four real coder sessions from the same afternoon:

156 158
| `17-08-32` | 4 | 5 | 4 | 9 | 8,418 B |
157 159
| `17-24-37` | 6 | 0 | 6 | 6 | 0 B |
158 160
159
For tool results the payload cap is not the binding constraint: the largest
160
observed was 8.4 KB, and the transcript already bounds a result to 4,000
161
characters before it reaches a model, so the same bound applied to an event
162
keeps every one of them comfortably inside 16 KB. A 30 KB shell output would not
163
fit, which is why the bound is applied rather than assumed. Reasoning is the
164
exception, and it has its own section below.
161
The payload ceiling turned out to be inherited rather than reasoned, and is
162
gone; see below. What remains true of the measurements is that tool results are
163
small — the largest observed was 8.4 KB — and that reasoning is not: a single
164
block reached 38,791 characters, which is what exposed the ceiling as the wrong
165
rule for this table.
165 166
166 167
**The listing cap was the binding constraint.** Turn-level persistence fits
167 168
inside fifty with room to spare; tool-level passes it on an ordinary working
168
session, and recording reasoning in parts pushes it further still. So `list_events/2` now takes an `:after` cursor and the route publishes
169
session. So `list_events/2` now takes an `:after` cursor and the route publishes
169 170
each event's id, because a history that cannot be read back is not persistence.
170 171
That is the one server change this section required, and it is done.
171 172

@@ -184,8 +185,8 @@ Recorded, in the order they happen:

184 185
  fact, and splitting them doubles the count against the cap for nothing.
185 186
- `turn.assistant` — the answer, with the turn's token usage and call count.
186 187
187
**Reasoning is recorded, and it is not optional.** An earlier draft of this
188
document left it out and called that a saving, on the grounds that it is not
188
**Reasoning is recorded whole, and it is not optional.** An earlier draft of
189
this document left it out and called that a saving, on the grounds that it is not
189 190
sent back to a model. That was wrong twice over. It is sent back now — a model
190 191
that cannot see how it reached its last answer reasons its way there again — and
191 192
it is the largest single part of what a session produces: 150,322 characters

@@ -199,20 +200,47 @@ A delta is how a reply arrived rather than what it is, and a transcript that

199 200
stores the arrival cannot be read back as the thing. Notices never reached a
200 201
model. That is the whole of what is dropped.
201 202
202
### Reasoning does not fit in one event
203
204
The payload cap is 16,384 bytes and the largest single reasoning block measured
205
is **38,791 characters**. So reasoning is the one thing here that a single event
206
cannot hold, and the cap is not the wrong size — a bounded event is the point of
207
an append-only evidence table.
208
209
It is stored in ordered parts: `turn.reasoning` events carrying `part`, `of`,
210
and a slice bounded well inside the cap, reassembled in order on read. The
211
alternative is raising the constraint, which trades a bound that holds for every
212
event against one case, and the case is the one that will keep growing.
213
214
This also means the event count per turn is no longer fixed, which the cursor
215
already handles.
203
### The payload ceiling was inherited, and is gone
204
205
An earlier draft of this section proposed splitting reasoning across events,
206
because the largest single block measured is 38,791 characters against a 16,384
207
byte payload ceiling. That was fitting the wrong constraint, and the reasoning
208
given for it — that a bounded event is the point of an append-only evidence
209
table — does not survive reading where the bound came from.
210
211
The same `octet_length(payload::text) <= 16384` appears on `voice_events`,
212
`scv_run_events`, program receipts, and `ComputerActivity`. `thread_events`
213
copied it: the schema said so outright. Where that bound is actually justified,
214
it is justified by those tables carrying **no content** —
215
`2026-08-21-sarah-computers-and-scv-architecture-audit.md` records that an SCV
216
event payload is reduced to a fixed key allowlist and that "no file paths, tool
217
arguments, tool output, or report prose reach an event row", and that
218
`ComputerActivity` events are "a projection and never the authority".
219
220
`thread_events` is the authority, and the bar set above is a full ATIF
221
trajectory. A ceiling designed to keep content out of a projection is the wrong
222
rule for the table that holds the content. It is dropped
223
(`20260824184030_relax_thread_event_payload_ceiling`), the floor stays at "is a
224
JSON object", and `INVARIANTS.md` moves with it.
225
226
### Storing and sending are different questions
227
228
Removing the ceiling does not mean a client sends everything to a model on every
229
round. Those are separate decisions and the confusion between them is what
230
produced the chunking proposal:
231
232
- **The record holds what happened.** Every turn, all of the reasoning, every
233
  tool call with its whole result. Unbounded, because a truncated record cannot
234
  reproduce the session and a session that cannot be reproduced is not evidence
235
  of anything.
236
- **The wire is bounded by the client.** The model transcript already bounds a
237
  tool result to 4,000 characters, and that bound belongs there — it is a
238
  context-budget decision made against a model's window, not a property of what
239
  happened.
240
241
A client that needs to page a large payload over the wire can do that when it
242
needs to. It is a transport concern, and solving it in the store cost every
243
future reader a reassembly step for nothing.
216 244
217 245
### Resume
218 246
lib/openagents/threads/event.ex modified +11 -4

@@ -3,9 +3,16 @@ defmodule OpenAgents.Threads.Event do

3 3
  One bounded, append-only entry in a thread's transcript
4 4
  (`openagents.thread.event.v1`).
5 5
6
  The shape follows `OpenAgents.SCV.ExecutionEvent`: the schema string is
7
  pinned by the database, the payload is capped at 16 KB, and there is no
8
  `updated_at`, so a recorded event is evidence rather than state.
6
  The schema string is pinned by the database and there is no `updated_at`, so a
7
  recorded event is evidence rather than state.
8
9
  Unlike `OpenAgents.SCV.ExecutionEvent`, whose payloads are a deliberately
10
  minimal projection of work that lives elsewhere, this table is the work: a
11
  thread's transcript has to reproduce the session as a full ATIF trajectory.
12
  So the payload carries what happened rather than a summary of it, and the only
13
  floor is that it is a JSON object — an event whose type is the whole of it
14
  satisfies that. What a client sends to a model is bounded by the client; what
15
  the record holds is what happened.
9 16
  """
10 17
11 18
  use Ecto.Schema

@@ -39,6 +46,6 @@ defmodule OpenAgents.Threads.Event do

39 46
    |> validate_inclusion(:schema, [@schema_version])
40 47
    |> foreign_key_constraint(:thread_id)
41 48
    |> check_constraint(:schema, name: :thread_events_schema_check)
42
    |> check_constraint(:payload, name: :thread_events_payload_bound_check)
49
    |> check_constraint(:payload, name: :thread_events_payload_present_check)
43 50
  end
44 51
end
mix.exs modified +4 -12

@@ -152,18 +152,10 @@ defmodule OpenAgents.MixProject do

152 152
        "esbuild openagents --minify",
153 153
        "phx.digest"
154 154
      ],
155
      precommit: [
156
        "cmd sh ops/dev/install-push-guard.sh --ensure",
157
        "hex.audit",
158
        "deps.audit",
159
        "compile --warnings-as-errors",
160
        "deps.unlock --check-unused",
161
        "format",
162
        "cmd ops/ci/reference-check.sh",
163
        "cmd elixir ops/ci/docs-check.exs",
164
        "assets.test",
165
        "test --warnings-as-errors"
166
      ]
155
      # Scaled to what changed: a prose-only change runs the checks that read
156
      # prose, and everything else runs the whole gate. The rules live in the
157
      # script because an alias cannot branch. See `ops/dev/precommit.sh`.
158
      precommit: ["cmd sh ops/dev/precommit.sh"]
167 159
    ]
168 160
  end
169 161
end
ops/dev/precommit.sh added +81

@@ -0,0 +1,81 @@

1
#!/usr/bin/env bash
2
#
3
# The precommit gate, scaled to what changed.
4
#
5
# The full gate compiles, audits dependencies, builds assets, and runs the whole
6
# suite — some four thousand tests and several minutes. That is the right price
7
# for a change to the application and the wrong one for a change to a document,
8
# and paying it for prose is how a gate stops being run.
9
#
10
# So a change that touches only prose runs only the checks that read prose. The
11
# rule is deliberately narrow: every changed path must be Markdown, and no
12
# Markdown that any code or test names by path counts, because several documents
13
# in this repository are read by the suite and changing one of those is a change
14
# to the suite's input. Anything else, including anything unreadable or
15
# ambiguous, takes the full gate.
16
set -euo pipefail
17
18
root=$(git rev-parse --show-toplevel)
19
cd "$root"
20
21
full() {
22
  mix hex.audit
23
  mix deps.audit
24
  mix compile --warnings-as-errors
25
  mix deps.unlock --check-unused
26
  mix format
27
  mix cmd ops/ci/reference-check.sh
28
  mix cmd elixir ops/ci/docs-check.exs
29
  mix assets.test
30
  mix test --warnings-as-errors
31
}
32
33
prose() {
34
  mix format
35
  mix cmd ops/ci/reference-check.sh
36
  mix cmd elixir ops/ci/docs-check.exs
37
}
38
39
sh ops/dev/install-push-guard.sh --ensure
40
41
# Everything this change touches: the working tree against HEAD, plus anything
42
# already committed that main has not seen. A commit made before running this
43
# is still part of what is about to be pushed.
44
changed=$(
45
  {
46
    git diff --name-only HEAD 2>/dev/null || true
47
    git ls-files --others --exclude-standard 2>/dev/null || true
48
    if git rev-parse --verify --quiet openagents/main >/dev/null 2>&1; then
49
      git diff --name-only "$(git merge-base HEAD openagents/main)"...HEAD 2>/dev/null || true
50
    fi
51
  } | sort -u | sed '/^$/d'
52
)
53
54
# Nothing to classify is not a reason to skip: a gate that finds no evidence
55
# runs the whole thing.
56
if [ -z "$changed" ]; then
57
  full
58
  exit 0
59
fi
60
61
while IFS= read -r path; do
62
  case "$path" in
63
    *.md) ;;
64
    *)
65
      full
66
      exit 0
67
      ;;
68
  esac
69
70
  # A document the suite reads is the suite's input, whatever its extension.
71
  if grep -rqlF "$path" lib test ops --include="*.ex" --include="*.exs" --include="*.sh" \
72
    2>/dev/null; then
73
    full
74
    exit 0
75
  fi
76
done <<EOF_PATHS
77
$changed
78
EOF_PATHS
79
80
echo "precommit: prose only (${changed//$'\n'/, }) — running the checks that read prose."
81
prose
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -289,7 +289,8 @@

289 289
    20260824035934,
290 290
    20260824040140,
291 291
    20260824042729,
292
    20260824043735
292
    20260824043735,
293
    20260824184030
293 294
  ],
294 295
  "required_tables": [
295 296
    "users",
priv/repo/migrations/20260824184030_relax_thread_event_payload_ceiling.exs added +40

@@ -0,0 +1,40 @@

1
defmodule OpenAgents.Repo.Migrations.RelaxThreadEventPayloadCeiling do
2
  @moduledoc """
3
  A thread's transcript holds the work, so it cannot be capped like a projection.
4
5
  The 16 KB ceiling came from `scv_run_events`, whose payloads are deliberately
6
  minimal: that table reduces a payload to a fixed key allowlist and lets no
7
  file path, tool argument, tool output, or report prose reach a row, because
8
  the work itself lives elsewhere. `ComputerActivity` states the same premise
9
  outright — its events are a projection and never the authority.
10
11
  `thread_events` is the authority. It has to reproduce a session as a full
12
  ATIF trajectory, and a single reasoning block observed in a live session is
13
  38,791 characters. Under the inherited ceiling the only way to record that was
14
  to split it across events and reassemble it on read, which is a wire concern
15
  solved in the store, and a reader of the transcript paying for it forever.
16
17
  So the upper bound goes. The floor stays at "is a JSON object", which an event
18
  carrying nothing but its type satisfies, and the schema pin and the
19
  append-only shape are untouched.
20
  Boundedness moves to where it belongs — what a client sends to a model is
21
  bounded by the client, and what the record holds is what happened.
22
  """
23
  use Ecto.Migration
24
25
  def up do
26
    drop constraint(:thread_events, :thread_events_payload_bound_check)
27
28
    create constraint(:thread_events, :thread_events_payload_present_check,
29
             check: "octet_length(payload::text) >= 2"
30
           )
31
  end
32
33
  def down do
34
    drop constraint(:thread_events, :thread_events_payload_present_check)
35
36
    create constraint(:thread_events, :thread_events_payload_bound_check,
37
             check: "octet_length(payload::text) BETWEEN 2 AND 16384"
38
           )
39
  end
40
end
test/openagents/threads_test.exs modified +16 -6

@@ -87,16 +87,26 @@ defmodule OpenAgents.ThreadsTest do

87 87
               thread |> Threads.list_events() |> Enum.map(& &1.event_type)
88 88
    end
89 89
90
    test "a payload past the ceiling is refused by the database" do
90
    test "a payload larger than a projection would allow is recorded whole" do
91 91
      user = owner("event-bound")
92 92
      {:ok, thread} = Threads.open(user, "Record something large")
93 93
94
      assert {:error, changeset} =
95
               Threads.record_event(thread, "thread.turn.started", %{
96
                 "blob" => String.duplicate("a", 16_400)
97
               })
94
      # Past the 16 KB ceiling this table inherited from `scv_run_events`, whose
95
      # payloads are a minimal projection of work stored elsewhere. This table
96
      # is the work: a single reasoning block observed in a live session is
97
      # 38,791 characters, and a transcript that cannot hold it cannot
98
      # reproduce the session.
99
      blob = String.duplicate("a", 40_000)
100
101
      assert {:ok, _updated} =
102
               Threads.record_event(thread, "thread.turn.started", %{"blob" => blob})
103
104
      stored =
105
        thread
106
        |> Threads.list_events()
107
        |> Enum.find(&(&1.payload["blob"] != nil))
98 108
99
      assert %{payload: _} = errors_on(changeset)
109
      assert stored.payload["blob"] == blob
100 110
    end
101 111
102 112
    test "a terminal thread accepts no further transcript" do
test/openagents_web/controllers/thread_controller_test.exs modified +38

@@ -571,6 +571,44 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

571 571
      assert Enum.map(rest["events"], & &1["payload"]["text"]) |> List.last() == "three"
572 572
    end
573 573
574
    test "records a payload far larger than the old ceiling", %{authenticated: conn, id: id} do
575
      # A single reasoning block observed in a live session is 38,791
576
      # characters. Under the inherited 16 KB ceiling the only way to record one
577
      # was to split it and reassemble it on every read.
578
      reasoning = String.duplicate("thinking about the problem. ", 2_000)
579
      assert byte_size(reasoning) > 16_384
580
581
      conn
582
      |> post(~p"/api/v3/threads/#{id}/events", %{
583
        "event_type" => "turn.reasoning",
584
        "payload" => %{"text" => reasoning}
585
      })
586
      |> json_response(201)
587
588
      body = conn |> get(~p"/api/v3/threads/#{id}/events") |> json_response(200)
589
590
      stored =
591
        body["events"]
592
        |> Enum.find(&(&1["event_type"] == "turn.reasoning"))
593
        |> get_in(["payload", "text"])
594
595
      # Stored whole, so the transcript reproduces the session rather than a
596
      # summary of it.
597
      assert stored == reasoning
598
    end
599
600
    test "accepts an event whose type is the whole of it", %{authenticated: conn, id: id} do
601
      # Some events carry nothing but their type, and the route defaults an
602
      # absent payload to an empty object. The remaining floor is that the
603
      # column holds valid JSON, not that the JSON is interesting.
604
      body =
605
        conn
606
        |> post(~p"/api/v3/threads/#{id}/events", %{"event_type" => "turn.started"})
607
        |> json_response(201)
608
609
      assert body["thread"]["event_count"] > 0
610
    end
611
574 612
    test "refuses an event with no type", %{authenticated: conn, id: id} do
575 613
      body =
576 614
        conn

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