Count thread grant spend on the leaderboard

4a578fce1235 · AtlantisPleb · · parent d365b4cf54ad

Count thread grant spend on the leaderboard

A coder session runs on a thread, and its spend lands on the grant's
forward-only usage map — which the board never read, so a signed-in
account could spend all day through the proxy and appear nowhere
(issue #204, docs/2026-08-24-coder-account-integration-audit.md).

Leaderboard.account_totals/0 gains a third union arm over
inference_grants.usage, joined to the account through the thread's
owner visitor. Only thread-fenced grants count: THREAD-001 gives a
grant exactly one fence, and a conversation-fenced grant backs the
chat lane whose merged total the turn-receipt arm already claims, so
the inner join on threads is what keeps one account from being
credited twice. Inference.record_usage/2 now invalidates the board
when a thread grant's usage moves, matching the typed and voice
planes, and LEADERBOARD-001 names the third plane.

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

Deploy story

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

pushed
by user · WAL seq 297 · 2026-08-24T19:41:50.357447Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/inference.ex
  • modified lib/openagents/leaderboard.ex
  • modified test/openagents/leaderboard_test.exs

Diff

4 files changed, +156 -7

INVARIANTS.md modified +8 -5

@@ -2812,11 +2812,14 @@ model identifier, no message, transcript, memory, or recall content, no

2812 2812
activity timestamp, no typed/spoken split, and no priced cost. The struct is
2813 2813
the contract: a field added there is published to the internet.
2814 2814
2815
Totals are derived only from the two planes that already hold a merged total,
2816
`turn_receipts.usage` and `voice_sessions.usage`. Provider steps, voice
2817
response receipts, tool-step invocation counts, and off-path shadow-program
2818
runs are excluded, so no account is credited twice and none is credited for
2819
work it did not drive. Accounts appear only while active, not withheld by
2815
Totals are derived only from the three planes that already hold a merged
2816
total: `turn_receipts.usage`, `voice_sessions.usage`, and — for thread-fenced
2817
grants only — `inference_grants.usage`, joined to the account through the
2818
thread's owner visitor. Provider steps, voice response receipts, tool-step
2819
invocation counts, off-path shadow-program runs, and conversation-fenced
2820
grants (whose chat-lane spend the turn-receipt plane already claims) are
2821
excluded, so no account is credited twice and none is credited for work it did
2822
not drive. Accounts appear only while active, not withheld by
2820 2823
`users.public_leaderboard_opted_out`, and above zero tokens; banned accounts
2821 2824
and legacy browser-only visitors never appear. Deleting product data under
2822 2825
DATA-004 removes an account from the board by cascade, without a separate
lib/openagents/inference.ex modified +11

@@ -221,6 +221,17 @@ defmodule OpenAgents.Inference do

221 221
          Repo.rollback(:grant_not_active)
222 222
      end
223 223
    end)
224
    |> case do
225
      {:ok, %Grant{thread_id: thread_id} = updated} ->
226
        # A thread grant's usage is a leaderboard plane (LEADERBOARD-001), so
227
        # this is the one place a thread's token total can change. Conversation
228
        # grants are not counted there and stay silent.
229
        if is_binary(thread_id), do: :ok = OpenAgents.Leaderboard.invalidate()
230
        {:ok, updated}
231
232
      {:error, _reason} = error ->
233
        error
234
    end
224 235
  end
225 236
226 237
  @doc "Revoke a grant. Idempotent for already-terminal grants."
lib/openagents/leaderboard.ex modified +50 -2

@@ -3,7 +3,7 @@ defmodule OpenAgents.Leaderboard do

3 3
  The public token leaderboard.
4 4
5 5
  PostgreSQL is authoritative (`INVARIANTS.md` DATA-001). This module reads the
6
  two tables that hold per-account token truth and publishes the bounded
6
  three tables that hold per-account token truth and publishes the bounded
7 7
  projection described by LEADERBOARD-001 — nothing else crosses the account
8 8
  boundary.
9 9

@@ -17,6 +17,13 @@ defmodule OpenAgents.Leaderboard do

17 17
  shadow-program runs are off-path under PROGRAM-002 and earn no credit. See
18 18
  `docs/LEADERBOARD.md`.
19 19
20
  `inference_grants.usage` counts only for thread-fenced grants. A grant names
21
  exactly one fence — a thread or a conversation, never both (THREAD-001) — and
22
  its usage map is the forward-only merge of every metered call it authorized,
23
  so a thread grant is the one place a coder session's spend lands. A
24
  conversation-fenced grant backs work inside the chat lane the turn-receipt
25
  arm already claims, so counting it here would credit the same account twice.
26
20 27
  ## Fan-out
21 28
22 29
  The board is public, so viewers are unbounded and anonymous. Reads never touch

@@ -31,9 +38,11 @@ defmodule OpenAgents.Leaderboard do

31 38
  alias OpenAgents.Conversations.Turn
32 39
  alias OpenAgents.Conversations.TurnReceipt
33 40
  alias OpenAgents.Conversations.Visitor
41
  alias OpenAgents.Inference.Grant
34 42
  alias OpenAgents.Leaderboard.Entry
35 43
  alias OpenAgents.Leaderboard.Server
36 44
  alias OpenAgents.Repo
45
  alias OpenAgents.Threads.Thread
37 46
  alias OpenAgents.Voice.Session
38 47
39 48
  @topic "leaderboard"

@@ -124,7 +133,12 @@ defmodule OpenAgents.Leaderboard do

124 133
  end
125 134
126 135
  defp account_totals do
127
    from(row in subquery(union_all(typed_turn_usage(), ^voice_session_usage())),
136
    usage_union =
137
      typed_turn_usage()
138
      |> union_all(^voice_session_usage())
139
      |> union_all(^thread_grant_usage())
140
141
    from(row in subquery(usage_union),
128 142
      group_by: row.user_id,
129 143
      select: %{user_id: row.user_id, tokens: fragment("SUM(?)::bigint", row.tokens)}
130 144
    )

@@ -191,4 +205,38 @@ defmodule OpenAgents.Leaderboard do

191 205
      }
192 206
    )
193 207
  end
208
209
  # Coder and thread spend. Only thread-fenced grants count: a grant's fence is
210
  # exactly one of thread or conversation (THREAD-001), and conversation-fenced
211
  # grants back the chat lane whose merged total the turn-receipt arm above
212
  # already claims. The inner join on the thread is what enforces the fence —
213
  # a grant with a NULL thread_id matches no thread row.
214
  defp thread_grant_usage do
215
    from(grant in Grant,
216
      join: thread in Thread,
217
      on: thread.id == grant.thread_id,
218
      join: visitor in Visitor,
219
      on: visitor.id == thread.owner_visitor_id,
220
      where: not is_nil(visitor.user_id),
221
      select: %{
222
        user_id: visitor.user_id,
223
        tokens:
224
          fragment(
225
            """
226
            GREATEST(
227
              CASE WHEN ? ->> 'total_tokens' ~ '^[0-9]+$' THEN (? ->> 'total_tokens')::bigint ELSE 0 END,
228
              CASE WHEN ? ->> 'input_tokens' ~ '^[0-9]+$' THEN (? ->> 'input_tokens')::bigint ELSE 0 END
229
                + CASE WHEN ? ->> 'output_tokens' ~ '^[0-9]+$' THEN (? ->> 'output_tokens')::bigint ELSE 0 END
230
            )
231
            """,
232
            grant.usage,
233
            grant.usage,
234
            grant.usage,
235
            grant.usage,
236
            grant.usage,
237
            grant.usage
238
          )
239
      }
240
    )
241
  end
194 242
end
test/openagents/leaderboard_test.exs modified +87

@@ -10,8 +10,10 @@ defmodule OpenAgents.LeaderboardTest do

10 10
  alias OpenAgents.Context.Composer
11 11
  alias OpenAgents.Conversations
12 12
  alias OpenAgents.DataRights
13
  alias OpenAgents.Inference
13 14
  alias OpenAgents.Leaderboard
14 15
  alias OpenAgents.Providers.Request
16
  alias OpenAgents.Threads
15 17
  alias OpenAgents.Voice
16 18
  alias OpenAgents.Voice.Config
17 19
  alias OpenAgents.Voice.ProviderEvent

@@ -89,6 +91,84 @@ defmodule OpenAgents.LeaderboardTest do

89 91
    assert entry.total_tokens == 12
90 92
  end
91 93
94
  test "counts a thread grant's spend for its owning account" do
95
    # A coder session runs on a thread, and its spend lands on the grant's
96
    # forward-only usage map rather than on any turn receipt. The board sums it
97
    # alongside the chat planes.
98
    mixed = account("thread-mixed")
99
    coder = account("thread-only")
100
101
    complete_typed_turn(mixed, "Typed alongside the coder.", %{
102
      "input_tokens" => 100,
103
      "output_tokens" => 40,
104
      "total_tokens" => 140
105
    })
106
107
    record_thread_grant_usage(mixed, %{
108
      "input_tokens" => 45,
109
      "output_tokens" => 15,
110
      "total_tokens" => 60
111
    })
112
113
    record_thread_grant_usage(coder, %{
114
      "input_tokens" => 3,
115
      "output_tokens" => 2,
116
      "total_tokens" => 5
117
    })
118
119
    assert [first, second] = Leaderboard.compute_entries()
120
121
    assert first.rank == 1
122
    assert first.github_login == mixed.github_login
123
    assert first.total_tokens == 200
124
125
    assert second.rank == 2
126
    assert second.github_login == coder.github_login
127
    assert second.total_tokens == 5
128
  end
129
130
  test "does not count a conversation-fenced grant's usage" do
131
    # A conversation grant backs the chat lane the turn-receipt arm already
132
    # claims (THREAD-001 gives a grant exactly one fence). Counting the grant
133
    # too would credit the same account twice.
134
    user = account("conversation-grant")
135
136
    complete_typed_turn(user, "The receipt claims this lane.", %{
137
      "input_tokens" => 7,
138
      "output_tokens" => 5,
139
      "total_tokens" => 12
140
    })
141
142
    conversation = conversation_for(user)
143
144
    {:ok, grant, _token} =
145
      Inference.mint(%{
146
        owner_visitor_id: conversation.visitor_id,
147
        conversation_id: conversation.id,
148
        machine_id: nil
149
      })
150
151
    {:ok, _grant} =
152
      Inference.record_usage(grant, %{
153
        "input_tokens" => 900,
154
        "output_tokens" => 99,
155
        "total_tokens" => 999
156
      })
157
158
    assert [entry] = Leaderboard.compute_entries()
159
    assert entry.total_tokens == 12
160
  end
161
162
  test "omits an account whose only thread grant has spent nothing" do
163
    # A freshly minted grant carries an empty usage map. Authority is not
164
    # activity, so the mint alone publishes nothing.
165
    user = account("thread-idle")
166
    {:ok, thread} = Threads.open(user, "Minted and never spent")
167
    {:ok, _thread, _grant, _token} = Threads.mint_grant(thread)
168
169
    assert Leaderboard.compute_entries() == []
170
  end
171
92 172
  test "omits accounts with no tokens" do
93 173
    silent = account("silent")
94 174
    {:ok, _conversation} = Conversations.ensure_conversation(silent)

@@ -279,6 +359,13 @@ defmodule OpenAgents.LeaderboardTest do

279 359
    inference
280 360
  end
281 361
362
  defp record_thread_grant_usage(user, usage) do
363
    {:ok, thread} = Threads.open(user, "Coder session")
364
    {:ok, _thread, grant, _token} = Threads.mint_grant(thread)
365
    {:ok, _grant} = Inference.record_usage(grant, usage)
366
    :ok
367
  end
368
282 369
  defp record_voice_usage(user, rtc_id, usage) do
283 370
    conversation = conversation_for(user)
284 371
    {:ok, session} = Voice.admit_session(conversation, voice_config())

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