From 3b1f1a63c46bd355cca00ec6e2018886ca2d89bd Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Thu, 1 Oct 2026 17:06:39 +0700 Subject: [PATCH 01/11] feat(queen): record what accepted .t27 work has earned, append-only The first half of spec authors mining TRI: nothing can be minted from a number nobody wrote down. Every Queen round now records each accepted turn whose boundary names a .t27 file as one earning per (repository, issue, judged commit), with work_id = sha256('t27-accept:v1|repo|issue|commit') so anyone can recompute it from public data. Why a table, when the leaderboard derives its score on read: a CI take-back edits queen_dispatch in place, so an acceptance derived on read would vanish instead of showing as taken back. Rows here are inserted and revoked, never deleted. A later sendBack/escalate of the same commit revokes an earning, and the revocation is final. GET /queen/public-earnings serves the record (public-read, no titles, no worker text, no notes) and says in its own body that nothing is withdrawable: no token is deployed, TRI per spec is undecided, and an accept does not yet require a merge. Tests: unit (grouping, query parameters, 503 without a database) and a live PostgreSQL test in tests/pglive covering idempotency, the work_id hash, the non-.t27 exclusion, take-back revocation and a new commit after a send-back. Route-guard census re-measured: 46 mounts, 9 public-read. Co-Authored-By: Claude Opus 5.5 --- .../src/api/routes/queen-public-earnings.ts | 42 +++ .../apps/server/src/api/server.ts | 5 + .../server/src/api/services/queen-tick.ts | 17 ++ .../src/api/services/queen-tri-earnings.ts | 273 ++++++++++++++++++ .../apps/server/src/lib/db/pg-migrate.ts | 24 ++ .../tests/api/queen-tri-earnings.test.ts | 144 +++++++++ .../tests/api/routes/route-guard.test.ts | 20 +- .../pglive/queen-tri-earnings-live.test.ts | 269 +++++++++++++++++ 8 files changed, 789 insertions(+), 5 deletions(-) create mode 100644 trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts create mode 100644 trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts create mode 100644 trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts create mode 100644 trios/agent-server/apps/server/tests/pglive/queen-tri-earnings-live.test.ts diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts new file mode 100644 index 0000000000..48a8880d46 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * WHAT ACCEPTED SPEC WORK HAS EARNED, AND WHAT WAS TAKEN BACK. + * + * Public on the same terms as the leaderboard, plus what an earning needs to + * be checkable: the repository, issue number, judged commit and declared + * `.t27` paths - all of them already public on GitHub - and the work id, which + * anyone can recompute from those. It carries no issue title, no worker text, + * no review note and no credential: a revocation says only which verdict + * revoked it. + * + * Nothing here is withdrawable and the answer says so in its own body, since a + * number on a page reads as money (queen-tri-earnings.ts). + */ +import { Hono } from 'hono' +import { createQueenPool } from '../../lib/db/queen-pool' +import { logger } from '../../lib/logger' +import { earningsLedger } from '../services/queen-tri-earnings' + +export function createQueenPublicEarningsRoute() { + return new Hono().get('/', async (c) => { + const url = process.env.DATABASE_URL + if (!url) return c.json({ error: 'No database configured' }, 503) + // One pool per request, closed when the answer is built, so a public + // route that anyone can call cannot accumulate connections. + const pool = createQueenPool(url, { max: 1 }) + try { + const ledger = await earningsLedger(pool) + return c.json(ledger, 200, { 'Cache-Control': 'public, max-age=60' }) + } catch (error) { + logger.warn('Queen earnings could not be read', { + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: 'The earnings ledger is unavailable' }, 503) + } finally { + await pool.end().catch(() => {}) + } + }) +} diff --git a/trios/agent-server/apps/server/src/api/server.ts b/trios/agent-server/apps/server/src/api/server.ts index 7959e7361f..f920fa3e29 100644 --- a/trios/agent-server/apps/server/src/api/server.ts +++ b/trios/agent-server/apps/server/src/api/server.ts @@ -56,6 +56,7 @@ import { createQueenLeaseRoute } from './routes/queen-lease' import { createQueenNeedsYouRoute } from './routes/queen-needs-you' import { createQueenPublicActivityRoute } from './routes/queen-public-activity' import { createQueenPublicAgentsRoute } from './routes/queen-public-agents' +import { createQueenPublicEarningsRoute } from './routes/queen-public-earnings' import { createQueenPublicHardwareRoute } from './routes/queen-public-hardware' import { createQueenPublicLeaderboardRoute } from './routes/queen-public-leaderboard' import { createQueenPublicResearchRoute } from './routes/queen-public-research' @@ -372,6 +373,7 @@ export async function createHttpServer(config: HttpServerConfig) { .use('/queen/public-research', publicReadCorsMiddleware()) .use('/queen/public-agents', publicReadCorsMiddleware()) .use('/queen/public-leaderboard', publicReadCorsMiddleware()) + .use('/queen/public-earnings', publicReadCorsMiddleware()) .use('/queen/scheduler', publicReadCorsMiddleware()) .use('/*', trustedCorsMiddleware()) // The Inngest server registers and invokes functions here; each request @@ -390,6 +392,9 @@ export async function createHttpServer(config: HttpServerConfig) { .route('/queen/public-board', createQueenPublicBoardRoute()) // Who lent a lane and what it did; no titles, no worker text, no key. .route('/queen/public-leaderboard', createQueenPublicLeaderboardRoute()) + // What accepted spec work earned; recorded, not withdrawable. Repository, + // issue, commit and declared .t27 paths only - no titles, notes or keys. + .route('/queen/public-earnings', createQueenPublicEarningsRoute()) .route('/queen/registry', queenRegistryRoutes) // The shell only. It holds no state and no token; every byte of data it // shows comes from /queen/lease, which stays guarded. See the route header diff --git a/trios/agent-server/apps/server/src/api/services/queen-tick.ts b/trios/agent-server/apps/server/src/api/services/queen-tick.ts index d543a4cceb..1c018b8d44 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tick.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tick.ts @@ -100,6 +100,7 @@ import { sameModelAs, visiblePatchPaths, } from './queen-reviewer' +import { recordEarnings } from './queen-tri-earnings' /** * The last non-secret allocator cursor already written durably. It survives a @@ -1574,6 +1575,22 @@ export async function runRound( return [] }) + // Write down what accepted spec work has earned, and revoke what a verdict + // just took back - after the review and the CI take-back, so this round's + // verdicts are what it records (queen-tri-earnings.ts). Housekeeping: a + // failure is logged and the round goes on; the next round records the rest. + await recordEarnings(pool, repo) + .then((done) => { + if (done.recorded > 0 || done.revoked > 0) { + logger.info('Queen recorded spec earnings', done) + } + }) + .catch((error) => { + logger.warn('Queen could not record spec earnings', { + error: error instanceof Error ? error.message : String(error), + }) + }) + const reaped = await reapStalledDispatches(pool) if (reaped.length > 0) { logger.info('Queen tick reaped stalled dispatches', { issues: reaped }) diff --git a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts new file mode 100644 index 0000000000..92ade451a2 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts @@ -0,0 +1,273 @@ +/** + * WHAT AN ACCEPTED SPEC HAS EARNED, WRITTEN DOWN ONCE. + * + * The owner, 2026-10-01: the people who write `.t27` specs should mine TRI for + * the ones the Queen accepts, and later move it to a wallet. Nothing can be + * minted from a number nobody wrote down, so this is the first half: an + * append-only record of every accepted spec turn, the commit it was judged on, + * and the lane that carried it. No token exists yet, so nothing here is + * withdrawable, and the public answer says so in words. + * + * WHY A TABLE, WHEN THE LEADERBOARD DERIVES ITS SCORE ON EVERY READ. + * The leaderboard counts turns; an earning is a claim somebody will later sign. + * `queen_dispatch` is keyed by issue and overwritten on redispatch, and the + * archive keeps a snapshot only when an attempt is overwritten - a CI take-back + * edits the row in place. Derived on read, an acceptance that was later taken + * back would simply stop existing, and the record would say it never happened. + * Here it stays, with `revoked_at` beside it. Rows are inserted and revoked, + * never deleted and never edited otherwise. + * + * ONE EARNING = ONE (repository, issue, judged commit). Its id is + * sha256('t27-accept:v1|||'), recomputable by anyone from + * public data, which is what `work_id` in the mint protocol asks for: a hash of + * the accepted work, not a bare counter (trinity-fpga, + * specs/trinet/mint_on_acceptance.t27). The same commit accepted twice is one + * earning; a new commit accepted after a send-back is a second one. + * + * WHAT COUNTS AS A SPEC, HONESTLY: an accepted turn whose declared boundary + * (`owned_paths`) names a `.t27` file - the same rule the leaderboard's `specs` + * uses. That is the claim, not the diff; the spec paths are stored so the + * claim can be checked against the commit. + * + * WHAT REVOKES ONE: a later verdict on the SAME commit that is a send-back or + * an escalation - which is what a CI take-back is (queen-ci-verdict.ts). A + * revocation is final for that earning: a fresh accept of the same commit does + * not resurrect it, because an earning that can flip back and forth is not one + * anybody can sign. + * + * WHAT IS NOT HERE: the amount. How much TRI one accepted spec mints is the + * owner's decision (trinity-fpga docs/docs/depin/decisions.md, O2), so the + * record counts earnings and says the amount is undecided rather than inventing + * one. Nor does an accept here mean the commit was merged: today an accept + * needs no merge (O4), and the answer says that too. + */ +import type { Pool } from 'pg' + +import { githubLoginOf, parseOwners } from './queen-leaderboard' + +/** The scheme a work id is hashed under; bumped if the inputs ever change. */ +export const EARNING_SCHEME = 't27-accept:v1' + +/** + * Record every accepted spec turn not yet recorded, then revoke the ones a + * later verdict on the same commit refused. Idempotent: a second run inserts + * and revokes nothing new. Returns how many of each this run did. + * + * `repo` is the repository the round supervises (TRIOS_GITHUB_REPO). An issue + * number means nothing without it, and the round already refuses to run when + * it is unset, so this never guesses one. + * + * (No backticks in the SQL below: it is a template literal.) + */ +export async function recordEarnings( + pool: Pool, + repo: string, +): Promise<{ recorded: number; revoked: number }> { + const inserted = await pool.query( + `WITH accepted AS ( + SELECT issue, judged_head, key_index, owned_paths, reviewed_at + FROM queen_dispatch + WHERE review_state = 'accept' + AND judged_head IS NOT NULL AND key_index IS NOT NULL + UNION ALL + SELECT issue, + snapshot->>'judged_head', + (snapshot->>'key_index')::integer, + coalesce(snapshot->'owned_paths', '[]'::jsonb), + (snapshot->>'reviewed_at')::timestamptz + FROM queen_dispatch_history + WHERE snapshot->>'review_state' = 'accept' + AND snapshot->>'judged_head' IS NOT NULL + AND snapshot->>'key_index' ~ '^[0-9]+$' + ), + specs AS ( + SELECT a.issue, a.judged_head, a.key_index, a.reviewed_at, + (SELECT coalesce(jsonb_agg(p.path ORDER BY p.path), '[]'::jsonb) + FROM jsonb_array_elements_text(a.owned_paths) AS p(path) + WHERE p.path LIKE '%.t27') AS spec_paths + FROM accepted a + ), + first_accept AS ( + -- One earning per commit: the earliest acceptance of it. + SELECT DISTINCT ON (issue, judged_head) + issue, judged_head, key_index, spec_paths, reviewed_at + FROM specs + WHERE jsonb_array_length(spec_paths) > 0 + ORDER BY issue, judged_head, reviewed_at ASC NULLS LAST + ) + INSERT INTO queen_tri_earnings + (work_id, repo, issue, judged_head, key_index, spec_paths, accepted_at) + SELECT encode(sha256(convert_to( + $2::text || '|' || $1::text || '|' || issue::text || '|' || judged_head, + 'UTF8')), 'hex'), + $1::text, issue, judged_head, key_index, spec_paths, + coalesce(reviewed_at, now()) + FROM first_accept + ON CONFLICT (work_id) DO NOTHING`, + [repo, EARNING_SCHEME], + ) + + // Only the verdict's STATE is kept as the reason. The note is worker text + // and CI log lines, and this table is read by a public route. + const revoked = await pool.query( + `UPDATE queen_tri_earnings e + SET revoked_at = now(), + revoked_reason = 'a later verdict on the same commit: ' || r.state + FROM ( + SELECT issue, judged_head, review_state AS state, reviewed_at + FROM queen_dispatch + WHERE review_state IN ('sendBack', 'escalate') + AND judged_head IS NOT NULL + UNION ALL + SELECT issue, + snapshot->>'judged_head', + snapshot->>'review_state', + (snapshot->>'reviewed_at')::timestamptz + FROM queen_dispatch_history + WHERE snapshot->>'review_state' IN ('sendBack', 'escalate') + AND snapshot->>'judged_head' IS NOT NULL + ) r + WHERE e.revoked_at IS NULL + AND e.repo = $1 + AND r.issue = e.issue + AND r.judged_head = e.judged_head + AND r.reviewed_at > e.accepted_at`, + [repo], + ) + + return { + recorded: inserted.rowCount ?? 0, + revoked: revoked.rowCount ?? 0, + } +} + +export interface Earning { + workId: string + repo: string + issue: number + /** The commit the acceptance was about. */ + commit: string + keyIndex: number + /** The `.t27` files the turn's declared boundary named. */ + specPaths: string[] + acceptedAt: string + revokedAt: string | null + revokedReason: string | null +} + +export interface Earner { + name: string + claimed: boolean + github?: string + keys: number[] + /** Earnings standing. */ + earned: number + /** Earnings a later verdict took back; shown, never hidden. */ + revoked: number +} + +/** + * Gather earnings by lender, the same way the leaderboard gathers lanes: by + * the operator's name for the lane (TRIOS_KEY_OWNERS), or `key #N` when nobody + * claimed it. Pure, so the suite drives it directly. + */ +export function earnersOf( + earnings: Earning[], + owners: Record, +): Earner[] { + const byName = new Map() + for (const earning of earnings) { + const claimed = Object.hasOwn(owners, earning.keyIndex) + const name = claimed ? owners[earning.keyIndex] : `key #${earning.keyIndex}` + const into: Earner = byName.get(name) ?? { + name, + claimed, + ...(claimed ? { github: githubLoginOf(name) } : {}), + keys: [], + earned: 0, + revoked: 0, + } + if (!into.keys.includes(earning.keyIndex)) { + into.keys.push(earning.keyIndex) + into.keys.sort((a, b) => a - b) + } + if (earning.revokedAt) into.revoked += 1 + else into.earned += 1 + byName.set(name, into) + } + return [...byName.values()].sort( + (a, b) => + b.earned - a.earned || + b.revoked - a.revoked || + a.name.localeCompare(b.name), + ) +} + +export async function readEarnings(pool: Pool): Promise { + const { rows } = await pool.query( + `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, + accepted_at, revoked_at, revoked_reason + FROM queen_tri_earnings + ORDER BY accepted_at DESC, work_id`, + ) + return rows.map((row) => ({ + workId: String(row.work_id), + repo: String(row.repo), + issue: Number(row.issue), + commit: String(row.judged_head), + keyIndex: Number(row.key_index), + specPaths: Array.isArray(row.spec_paths) ? row.spec_paths.map(String) : [], + acceptedAt: new Date(row.accepted_at).toISOString(), + revokedAt: row.revoked_at ? new Date(row.revoked_at).toISOString() : null, + revokedReason: row.revoked_reason ? String(row.revoked_reason) : null, + })) +} + +/** How many of the most recent earnings the public answer lists one by one. */ +export const RECENT_EARNINGS = 100 + +export interface EarningsLedger { + measuredAt: string + scheme: string + /** + * In words, because a number on a page reads as money: these are recorded, + * not minted, and no token exists to withdraw them into. + */ + status: 'recorded, not withdrawable: no token is deployed' + /** TRI per accepted spec. Null until the owner decides it. */ + triPerSpec: null + rules: { + counts: string + revokes: string + notYet: string[] + } + totals: { earned: number; revoked: number } + earners: Earner[] + recent: Earning[] +} + +export async function earningsLedger(pool: Pool): Promise { + const all = await readEarnings(pool) + const revoked = all.filter((e) => e.revokedAt).length + return { + measuredAt: new Date().toISOString(), + scheme: EARNING_SCHEME, + status: 'recorded, not withdrawable: no token is deployed', + triPerSpec: null, + rules: { + counts: + 'one earning per (repository, issue, judged commit) the Queen accepted, ' + + 'when the turn declared a .t27 file in its boundary', + revokes: + 'a later send-back or escalation of the same commit, such as a CI take-back', + notYet: [ + 'an accept does not require a merge yet, so an earning is not mintable on its own', + 'spec paths are what the turn declared, not yet checked against the diff', + 'TRI per accepted spec is undecided (owner decision)', + ], + }, + totals: { earned: all.length - revoked, revoked }, + earners: earnersOf(all, parseOwners(process.env.TRIOS_KEY_OWNERS)), + recent: all.slice(0, RECENT_EARNINGS), + } +} diff --git a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts index c74b3d7425..29ac242525 100644 --- a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts +++ b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts @@ -288,6 +288,30 @@ CREATE TABLE IF NOT EXISTS queen_report ( CREATE INDEX IF NOT EXISTS idx_queen_report_at ON queen_report (at DESC); +-- What an accepted spec has earned, written down once (queen-tri-earnings.ts). +-- +-- Append-only: a row is inserted, and later perhaps revoked, never deleted. +-- queen_dispatch is overwritten in place by a CI take-back, so an earning +-- derived from it on read would vanish instead of showing as taken back. +-- work_id is sha256 of the scheme, repository, issue and judged commit, so +-- anyone can recompute it from public data. No amount column: TRI per spec is +-- not decided, and a column would invite somebody to fill it. +CREATE TABLE IF NOT EXISTS queen_tri_earnings ( + work_id text PRIMARY KEY, + repo text NOT NULL, + issue int NOT NULL, + judged_head text NOT NULL, + key_index int NOT NULL, + spec_paths jsonb NOT NULL DEFAULT '[]'::jsonb, + accepted_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, + revoked_reason text +); + +CREATE INDEX IF NOT EXISTS idx_queen_tri_earnings_issue + ON queen_tri_earnings (repo, issue, judged_head); + CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation_order ON "conversationMessages" ("conversationId", "orderIndex"); ` diff --git a/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts new file mode 100644 index 0000000000..c72853246d --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { Pool } from 'pg' + +import { createQueenPublicEarningsRoute } from '../../src/api/routes/queen-public-earnings' +import { + EARNING_SCHEME, + type Earning, + earnersOf, + earningsLedger, + recordEarnings, +} from '../../src/api/services/queen-tri-earnings' + +/** + * The SQL itself is exercised against a real PostgreSQL in + * tests/pglive/queen-tri-earnings-live.test.ts. What is pinned here is what a + * reader can see without a database: the grouping, the parameters each query + * is given, and the words the public answer uses about money. + */ + +const earning = ( + keyIndex: number, + commit: string, + revoked = false, +): Earning => ({ + workId: `id-${keyIndex}-${commit}`, + repo: 'gHashTag/trios', + issue: 1, + commit, + keyIndex, + specPaths: ['specs/a.t27'], + acceptedAt: '2026-10-01T00:00:00.000Z', + revokedAt: revoked ? '2026-10-01T01:00:00.000Z' : null, + revokedReason: revoked + ? 'a later verdict on the same commit: sendBack' + : null, +}) + +describe('who earned', () => { + it('gathers lanes by their lender and counts a revoked earning apart', () => { + const rows = earnersOf( + [ + earning(0, 'a'), + earning(2, 'b'), + earning(0, 'c', true), + earning(5, 'd'), + ], + { 0: '@dmitrii', 2: '@dmitrii' }, + ) + expect(rows).toEqual([ + { + name: '@dmitrii', + claimed: true, + github: 'dmitrii', + keys: [0, 2], + earned: 2, + revoked: 1, + }, + { name: 'key #5', claimed: false, keys: [5], earned: 1, revoked: 0 }, + ]) + }) + + it('ranks by standing earnings, then by revoked, then by name', () => { + const rows = earnersOf( + [ + earning(1, 'a'), + earning(2, 'b'), + earning(2, 'c', true), + earning(3, 'd'), + ], + {}, + ) + expect(rows.map((r) => r.name)).toEqual(['key #2', 'key #1', 'key #3']) + }) + + it('shows a lane whose only earning was taken back, rather than hiding it', () => { + const [row] = earnersOf([earning(4, 'a', true)], {}) + expect(row).toMatchObject({ name: 'key #4', earned: 0, revoked: 1 }) + }) +}) + +describe('what the record asks the database', () => { + const spy = () => { + const seen: { text: string; params: unknown[] }[] = [] + const pool = { + query: (text: string, params: unknown[] = []) => { + seen.push({ text, params }) + return Promise.resolve({ rows: [], rowCount: seen.length }) + }, + } as unknown as Pool + return { pool, seen } + } + + it('inserts under the scheme and the repository, and never overwrites', async () => { + const { pool, seen } = spy() + const done = await recordEarnings(pool, 'gHashTag/trios') + expect(done).toEqual({ recorded: 1, revoked: 2 }) + + const [insert, revoke] = seen + expect(insert.params).toEqual(['gHashTag/trios', EARNING_SCHEME]) + expect(insert.text).toContain('ON CONFLICT (work_id) DO NOTHING') + expect(insert.text).toContain("LIKE '%.t27'") + // Both the live row and the archive, or an overwritten accept is lost. + expect(insert.text).toContain('FROM queen_dispatch\n') + expect(insert.text).toContain('FROM queen_dispatch_history') + + expect(revoke.params).toEqual(['gHashTag/trios']) + // Only a verdict AFTER the acceptance revokes it, and only once. + expect(revoke.text).toContain('r.reviewed_at > e.accepted_at') + expect(revoke.text).toContain('e.revoked_at IS NULL') + // The reason is the verdict's state, never its note. + expect(revoke.text).not.toContain('review_note') + }) + + it('says in words that nothing is withdrawable, and invents no amount', async () => { + const { pool } = spy() + const ledger = await earningsLedger(pool) + expect(ledger.status).toBe( + 'recorded, not withdrawable: no token is deployed', + ) + expect(ledger.triPerSpec).toBeNull() + expect(ledger.scheme).toBe(EARNING_SCHEME) + expect(ledger.totals).toEqual({ earned: 0, revoked: 0 }) + expect(ledger.rules.notYet.join(' ')).toContain('does not require a merge') + }) +}) + +describe('the public route', () => { + let saved: string | undefined + beforeEach(() => { + saved = process.env.DATABASE_URL + delete process.env.DATABASE_URL + }) + afterEach(() => { + if (saved === undefined) delete process.env.DATABASE_URL + else process.env.DATABASE_URL = saved + }) + + it('answers 503 rather than an empty ledger when there is no database', async () => { + const response = await createQueenPublicEarningsRoute().request('/') + expect(response.status).toBe(503) + // An empty list would read as "nobody has earned anything". + expect(await response.json()).toEqual({ error: 'No database configured' }) + }) +}) diff --git a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts index f5950ac673..99a3e47838 100644 --- a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts +++ b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts @@ -84,10 +84,14 @@ describe('route-guard audit over src/api/server.ts', () => { // where a route named `public` belongs. Every other number here is // unchanged, which is the part worth stating: no guarded route quietly lost // its guard to make room for it. - expect(report.totalMounts).toBe(45) + // RE-MEASURED 2026-10-01: 45 became 46, the same way. One mount added on + // purpose - `/queen/public-earnings`, an explicit + // `publicReadCorsMiddleware()` on the record of accepted spec work - and + // the audit puts it in `public-read`. Prefix and wrapper counts unchanged. + expect(report.totalMounts).toBe(46) expect(report.prefixGuardCount).toBe(18) expect(report.guardedSubAppCount).toBe(15) - expect(report.publicReadCount).toBe(8) + expect(report.publicReadCount).toBe(9) }) it('reports zero unguarded mounts once the reasoned allowlist is applied', () => { @@ -105,7 +109,7 @@ describe('route-guard audit over src/api/server.ts', () => { ) }) - it('splits the twenty-two /queen mounts into 8 public-read, 8 wrapper-guarded and 6 allowlisted shells', () => { + it('splits the twenty-three /queen mounts into 9 public-read, 8 wrapper-guarded and 6 allowlisted shells', () => { const queenMounts = classifyMounts(source).filter( (mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'), ) @@ -127,7 +131,13 @@ describe('route-guard audit over src/api/server.ts', () => { // issue title, no worker text and no credential: only a key's INDEX ever // reaches the database, so there is nothing here a stranger could read that // the board does not already show. - expect(queenMounts.length).toBe(22) + // RE-MEASURED 2026-10-01: twenty-two became twenty-three. The ninth + // public-read is /queen/public-earnings - which accepted spec commits were + // recorded as earnings and which a later verdict took back. Like the + // leaderboard it carries no issue title, no worker text, no review note and + // no credential; the repository, issue, commit and declared .t27 paths are + // already public on GitHub. + expect(queenMounts.length).toBe(23) const counts: Record = { 'public-read': 0, @@ -141,7 +151,7 @@ describe('route-guard audit over src/api/server.ts', () => { // The four buckets must account for every mount with the exact expected // split; anything unaccounted for breaks one of these numbers. expect(counts).toEqual({ - 'public-read': 8, + 'public-read': 9, 'prefix-guard': 0, wrapper: 8, unguarded: 6, diff --git a/trios/agent-server/apps/server/tests/pglive/queen-tri-earnings-live.test.ts b/trios/agent-server/apps/server/tests/pglive/queen-tri-earnings-live.test.ts new file mode 100644 index 0000000000..9b1782a5ec --- /dev/null +++ b/trios/agent-server/apps/server/tests/pglive/queen-tri-earnings-live.test.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * The earnings record, run against a real PostgreSQL. + * + * recordEarnings is two statements of SQL over jsonb snapshots, a DISTINCT ON + * and a sha256 - nothing a fake pool can say anything about. So it runs here, + * in the live group, next to the migration gate and for the same reason: other + * test files mock `pg` at module scope, and a group is its own bun process + * (pg-migrate-live.test.ts explains the measurement). + * + * Like that gate, this FAILS when no server is reachable, unless + * TRIOS_PG_MIGRATE_GATE=offline asks for a printed skip. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { createHash, randomBytes } from 'node:crypto' +import { userInfo } from 'node:os' +import { Pool } from 'pg' +import { + EARNING_SCHEME, + earningsLedger, + readEarnings, + recordEarnings, +} from '../../src/api/services/queen-tri-earnings' +import { runPgMigrations } from '../../src/lib/db/pg-migrate' +import { createQueenPool } from '../../src/lib/db/queen-pool' + +const REPO = 'gHashTag/trios' + +function adminUrl(): string { + return ( + process.env.TRIOS_PG_TEST_URL ?? + `postgres://${userInfo().username}@127.0.0.1:5432/postgres` + ) +} + +function isLocal(url: string): boolean { + try { + const host = new URL(url).hostname + return host === '127.0.0.1' || host === 'localhost' || host === '::1' + } catch { + return false + } +} + +const offline = + (process.env.TRIOS_PG_MIGRATE_GATE ?? '').toLowerCase() === 'offline' + +/** What anybody outside can compute from the repository, issue and commit. */ +const workIdOf = (issue: number, commit: string) => + createHash('sha256') + .update(`${EARNING_SCHEME}|${REPO}|${issue}|${commit}`, 'utf8') + .digest('hex') + +const at = (minutes: number) => + new Date(Date.UTC(2026, 9, 1, 12, minutes)).toISOString() + +let admin: Pool | undefined +let pool: Pool | undefined +let scratchName = '' +let skipped = false + +beforeAll(async () => { + const url = adminUrl() + if (!isLocal(url) && process.env.TRIOS_PG_TEST_ALLOW_REMOTE !== '1') { + throw new Error( + 'TRIOS_PG_TEST_URL is not local; this test creates and drops a database.', + ) + } + admin = new Pool({ + connectionString: url, + max: 1, + connectionTimeoutMillis: 4000, + }) + try { + await admin.query('SELECT 1') + } catch (error) { + await admin.end().catch(() => {}) + admin = undefined + if (offline) { + skipped = true + console.error( + '\n THE EARNINGS LIVE TEST COULD NOT REACH A POSTGRESQL; TRIOS_PG_MIGRATE_GATE=offline, so it is a SKIP.\n', + ) + return + } + throw error + } + + scratchName = `trios_earn_${process.pid}_${randomBytes(4).toString('hex')}` + await admin.query(`CREATE DATABASE ${scratchName}`) + const scratch = new URL(url) + scratch.pathname = `/${scratchName}` + + const saved = process.env.DATABASE_URL + const savedRailway = process.env.RAILWAY_SSOT_URL + process.env.DATABASE_URL = scratch.toString() + delete process.env.RAILWAY_SSOT_URL + try { + await runPgMigrations() + } finally { + if (saved === undefined) delete process.env.DATABASE_URL + else process.env.DATABASE_URL = saved + if (savedRailway !== undefined) process.env.RAILWAY_SSOT_URL = savedRailway + } + + pool = createQueenPool(scratch.toString(), { max: 1 }) + // judged_head is added by the round's own boot (queen-tick + // ensureQueenColumns), not by MIGRATION_SQL, so a scratch database built + // from the migration alone does not have it. + await pool.query( + 'ALTER TABLE queen_dispatch ADD COLUMN IF NOT EXISTS judged_head text', + ) + // The whole migration block runs here, which takes longer than bun's + // default five-second hook budget. +}, 60_000) + +afterAll(async () => { + await pool?.end().catch(() => {}) + if (admin && scratchName) { + await admin + .query(`DROP DATABASE IF EXISTS ${scratchName} WITH (FORCE)`) + .catch(() => {}) + } + await admin?.end().catch(() => {}) +}) + +async function dispatch( + issue: number, + state: string, + head: string, + key: number, + paths: string[], + reviewedAt: string, +) { + await pool!.query( + `INSERT INTO queen_dispatch + (issue, branch, started, detail, owned_paths, key_index, + review_state, reviewed_at, judged_head) + VALUES ($1, 'b', true, 'd', $2::jsonb, $3, $4, $5, $6) + ON CONFLICT (issue) DO UPDATE + SET owned_paths = EXCLUDED.owned_paths, key_index = EXCLUDED.key_index, + review_state = EXCLUDED.review_state, + reviewed_at = EXCLUDED.reviewed_at, + judged_head = EXCLUDED.judged_head`, + [issue, JSON.stringify(paths), key, state, reviewedAt, head], + ) +} + +async function archive( + issue: number, + state: string, + head: string, + key: number, + paths: string[], + reviewedAt: string, +) { + await pool!.query( + 'INSERT INTO queen_dispatch_history (issue, snapshot) VALUES ($1, $2::jsonb)', + [ + issue, + JSON.stringify({ + review_state: state, + judged_head: head, + key_index: key, + owned_paths: paths, + reviewed_at: reviewedAt, + }), + ], + ) +} + +describe('the earnings record, on a real PostgreSQL', () => { + it('records each accepted spec commit once, revokes on take-back, and never forgets', async () => { + if (skipped) return + + // Issue 10: accepted on h1 twice - once in an archived attempt, once live. + // One commit, one earning, dated by the FIRST acceptance. + await archive(10, 'accept', 'h1', 0, ['specs/a.t27'], at(1)) + await dispatch(10, 'accept', 'h1', 0, ['specs/a.t27', 'src/x.ts'], at(5)) + // Issue 11: accepted, but its boundary names no .t27 file. + await dispatch(11, 'accept', 'h2', 1, ['src/only.ts'], at(5)) + // Issue 12: accepted only in the archive. A send-back of the same commit + // that came BEFORE the acceptance does not revoke it. + await archive(12, 'sendBack', 'h3', 2, ['b.t27'], at(0)) + await archive(12, 'accept', 'h3', 2, ['b.t27'], at(2)) + // Issue 13: still waiting; nothing to record. + await dispatch(13, 'wait', 'h9', 3, ['c.t27'], at(5)) + + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 2, + revoked: 0, + }) + // Idempotent: the round calls this every tick. + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 0, + revoked: 0, + }) + + let rows = await readEarnings(pool!) + const byIssue = (issue: number) => rows.filter((r) => r.issue === issue) + expect(rows.map((r) => r.issue).sort()).toEqual([10, 12]) + const [ten] = byIssue(10) + expect(ten.workId).toBe(workIdOf(10, 'h1')) + expect(ten.commit).toBe('h1') + expect(ten.acceptedAt).toBe(at(1)) + expect(ten.revokedAt).toBeNull() + expect(byIssue(12)[0].specPaths).toEqual(['b.t27']) + + // A CI take-back edits the live row in place: same commit, now refused. + await dispatch(10, 'sendBack', 'h1', 0, ['specs/a.t27'], at(10)) + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 0, + revoked: 1, + }) + rows = await readEarnings(pool!) + expect(byIssue(10)[0].revokedReason).toBe( + 'a later verdict on the same commit: sendBack', + ) + // The earning is still there, beside its revocation. + expect(rows).toHaveLength(2) + + // Accepting the same commit again does not resurrect it. + await dispatch(10, 'accept', 'h1', 0, ['specs/a.t27'], at(15)) + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 0, + revoked: 0, + }) + expect( + (await readEarnings(pool!)).find((r) => r.issue === 10)?.revokedAt, + ).not.toBeNull() + + // A NEW commit accepted after the send-back is a new earning. + await archive(10, 'accept', 'h1', 0, ['specs/a.t27'], at(15)) + await dispatch(10, 'accept', 'h4', 0, ['specs/a.t27'], at(20)) + expect(await recordEarnings(pool!, REPO)).toEqual({ + recorded: 1, + revoked: 0, + }) + + const saved = process.env.TRIOS_KEY_OWNERS + process.env.TRIOS_KEY_OWNERS = '0=@dmitrii' + try { + const ledger = await earningsLedger(pool!) + expect(ledger.totals).toEqual({ earned: 2, revoked: 1 }) + expect(ledger.earners).toEqual([ + { + name: '@dmitrii', + claimed: true, + github: 'dmitrii', + keys: [0], + earned: 1, + revoked: 1, + }, + { name: 'key #2', claimed: false, keys: [2], earned: 1, revoked: 0 }, + ]) + // Newest first. + expect(ledger.recent[0].commit).toBe('h4') + } finally { + if (saved === undefined) delete process.env.TRIOS_KEY_OWNERS + else process.env.TRIOS_KEY_OWNERS = saved + } + }) +}) From b3f92ee8f8dc06cb0a28ca5070c3d95934f58677 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:52:29 +0000 Subject: [PATCH 02/11] fix(ci): pin Bun to the workspace version and untrack a local node_modules symlink Every `Tests / *` job on #517 and #518 was cancelled at the 20-minute budget while still inside `bun ci`, before any test ran. Two things combined: - trios/agent-server/apps/server/node_modules was committed as a symlink to a local macOS path. `.gitignore` said `node_modules/`, which only matches directories, so the symlink slipped through. - setup-bun ran without a version. The `packageManager: bun@1.3.6` pin lives in trios/agent-server/package.json, not at the repo root, so CI got the latest release (1.4.2), which hangs on that dangling symlink. 1.3.x installs past it. Untrack the symlink, make the ignore rule match files too, and read the Bun version from the workspace package.json in test.yml. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .github/workflows/test.yml | 6 ++++++ trios/agent-server/.gitignore | 4 +++- trios/agent-server/apps/server/node_modules | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) delete mode 120000 trios/agent-server/apps/server/node_modules diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da5e35e3f3..decf3c12d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -134,6 +134,12 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + # The pin (packageManager: bun@1.3.6) lives in the workspace, not at the + # repo root, so without this setup-bun installs the latest release. + # Bun 1.4.2 hung in `bun ci` for the whole 20-minute budget on every + # suite (#517, #518) before a single test ran. + bun-version-file: trios/agent-server/package.json - name: Install dependencies run: bun ci diff --git a/trios/agent-server/.gitignore b/trios/agent-server/.gitignore index 5b07fe5f44..fade51cb84 100644 --- a/trios/agent-server/.gitignore +++ b/trios/agent-server/.gitignore @@ -53,7 +53,9 @@ bower_components build/Release # Dependency directories -node_modules/ +# No trailing slash: a symlinked node_modules is a file, and `node_modules/` +# let one pointing at a local Mac path be committed and hang CI's install. +node_modules jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) diff --git a/trios/agent-server/apps/server/node_modules b/trios/agent-server/apps/server/node_modules deleted file mode 120000 index d9dd43591a..0000000000 --- a/trios/agent-server/apps/server/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/playom/queen-patches/work/browseros-deploy/trios/agent-server/apps/server/node_modules \ No newline at end of file From e4f379eae77e31d3fe3d7d0ff5c9b79f97d26528 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Fri, 2 Oct 2026 00:02:48 +0700 Subject: [PATCH 03/11] feat(queen): one earning by work id, and the epoch-1 amount (27 TRI) GET /queen/public-earnings/:workId returns one earning with its earner's GitHub login, the scheme and TRI_PER_SPEC = 27 (owner decision O2, 2026-10-01). This is what each TRI signer reads before it signs; the merge rule (O4) is checked by the signers on GitHub, not here. Status text says what is true: mintable on TON testnet only, V1, signer quorum, NOT trustless. Co-Authored-By: Claude Opus 5.5 --- .../src/api/routes/queen-public-earnings.ts | 68 ++++++++--- .../src/api/services/queen-tri-earnings.ts | 111 ++++++++++++++---- .../tests/api/queen-tri-earnings.test.ts | 82 ++++++++++++- 3 files changed, 211 insertions(+), 50 deletions(-) diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts index 48a8880d46..051f56a66f 100644 --- a/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts @@ -18,25 +18,57 @@ import { Hono } from 'hono' import { createQueenPool } from '../../lib/db/queen-pool' import { logger } from '../../lib/logger' -import { earningsLedger } from '../services/queen-tri-earnings' +import { parseOwners } from '../services/queen-leaderboard' +import { earningByWorkId, earningsLedger } from '../services/queen-tri-earnings' + +const WORK_ID = /^[0-9a-f]{64}$/ export function createQueenPublicEarningsRoute() { - return new Hono().get('/', async (c) => { - const url = process.env.DATABASE_URL - if (!url) return c.json({ error: 'No database configured' }, 503) - // One pool per request, closed when the answer is built, so a public - // route that anyone can call cannot accumulate connections. - const pool = createQueenPool(url, { max: 1 }) - try { - const ledger = await earningsLedger(pool) - return c.json(ledger, 200, { 'Cache-Control': 'public, max-age=60' }) - } catch (error) { - logger.warn('Queen earnings could not be read', { - error: error instanceof Error ? error.message : String(error), + return ( + new Hono() + .get('/', async (c) => { + const url = process.env.DATABASE_URL + if (!url) return c.json({ error: 'No database configured' }, 503) + // One pool per request, closed when the answer is built, so a public + // route that anyone can call cannot accumulate connections. + const pool = createQueenPool(url, { max: 1 }) + try { + const ledger = await earningsLedger(pool) + return c.json(ledger, 200, { 'Cache-Control': 'public, max-age=60' }) + } catch (error) { + logger.warn('Queen earnings could not be read', { + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: 'The earnings ledger is unavailable' }, 503) + } finally { + await pool.end().catch(() => {}) + } + }) + // One earning and who it is credited to: what a TRI signer reads before + // it signs for this work id. A malformed id is refused before any query. + .get('/:workId', async (c) => { + const workId = c.req.param('workId') + if (!WORK_ID.test(workId)) + return c.json({ error: 'work id must be 64 lowercase hex' }, 400) + const url = process.env.DATABASE_URL + if (!url) return c.json({ error: 'No database configured' }, 503) + const pool = createQueenPool(url, { max: 1 }) + try { + const found = await earningByWorkId( + pool, + workId, + parseOwners(process.env.TRIOS_KEY_OWNERS), + ) + if (!found) return c.json({ error: 'No such earning' }, 404) + return c.json(found, 200, { 'Cache-Control': 'public, max-age=60' }) + } catch (error) { + logger.warn('Queen earning could not be read', { + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: 'The earnings ledger is unavailable' }, 503) + } finally { + await pool.end().catch(() => {}) + } }) - return c.json({ error: 'The earnings ledger is unavailable' }, 503) - } finally { - await pool.end().catch(() => {}) - } - }) + ) } diff --git a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts index 92ade451a2..37341c948d 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts @@ -35,11 +35,15 @@ * not resurrect it, because an earning that can flip back and forth is not one * anybody can sign. * - * WHAT IS NOT HERE: the amount. How much TRI one accepted spec mints is the - * owner's decision (trinity-fpga docs/docs/depin/decisions.md, O2), so the - * record counts earnings and says the amount is undecided rather than inventing - * one. Nor does an accept here mean the commit was merged: today an accept - * needs no merge (O4), and the answer says that too. + * THE AMOUNT is the owner's decision O2 (trinity-fpga + * docs/docs/depin/decisions.md), taken 2026-10-01: 27 TRI per accepted spec in + * epoch 1. It is published here, beside the record, so a signer can refuse an + * attestation whose amount differs from the one anybody can read. + * + * WHAT IS NOT HERE: whether the commit was merged. Decision O4 says an earning + * mints only once its judged commit landed; that is checked on GitHub by each + * signer independently, not asserted by this record, so a Queen that lied about + * a merge would convince nobody. */ import type { Pool } from 'pg' @@ -48,6 +52,9 @@ import { githubLoginOf, parseOwners } from './queen-leaderboard' /** The scheme a work id is hashed under; bumped if the inputs ever change. */ export const EARNING_SCHEME = 't27-accept:v1' +/** O2, 2026-10-01: TRI one accepted spec earns in epoch 1. */ +export const TRI_PER_SPEC = 27 + /** * Record every accepted spec turn not yet recorded, then revoke the ones a * later verdict on the same commit refused. Idempotent: a second run inserts @@ -203,24 +210,30 @@ export function earnersOf( ) } -export async function readEarnings(pool: Pool): Promise { - const { rows } = await pool.query( - `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, - accepted_at, revoked_at, revoked_reason - FROM queen_tri_earnings - ORDER BY accepted_at DESC, work_id`, - ) - return rows.map((row) => ({ +function toEarning(row: Record): Earning { + return { workId: String(row.work_id), repo: String(row.repo), issue: Number(row.issue), commit: String(row.judged_head), keyIndex: Number(row.key_index), specPaths: Array.isArray(row.spec_paths) ? row.spec_paths.map(String) : [], - acceptedAt: new Date(row.accepted_at).toISOString(), - revokedAt: row.revoked_at ? new Date(row.revoked_at).toISOString() : null, + acceptedAt: new Date(row.accepted_at as string).toISOString(), + revokedAt: row.revoked_at + ? new Date(row.revoked_at as string).toISOString() + : null, revokedReason: row.revoked_reason ? String(row.revoked_reason) : null, - })) + } +} + +export async function readEarnings(pool: Pool): Promise { + const { rows } = await pool.query( + `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, + accepted_at, revoked_at, revoked_reason + FROM queen_tri_earnings + ORDER BY accepted_at DESC, work_id`, + ) + return rows.map(toEarning) } /** How many of the most recent earnings the public answer lists one by one. */ @@ -230,12 +243,12 @@ export interface EarningsLedger { measuredAt: string scheme: string /** - * In words, because a number on a page reads as money: these are recorded, - * not minted, and no token exists to withdraw them into. + * In words, because a number on a page reads as money: a mint exists only on + * TON testnet, behind a signer quorum, and is not trustless. */ - status: 'recorded, not withdrawable: no token is deployed' - /** TRI per accepted spec. Null until the owner decides it. */ - triPerSpec: null + status: typeof EARNINGS_STATUS + /** TRI per accepted spec (O2). */ + triPerSpec: typeof TRI_PER_SPEC rules: { counts: string revokes: string @@ -246,14 +259,17 @@ export interface EarningsLedger { recent: Earning[] } +export const EARNINGS_STATUS = + 'recorded; mintable on TON testnet only -- V1, signer quorum, NOT trustless' + export async function earningsLedger(pool: Pool): Promise { const all = await readEarnings(pool) const revoked = all.filter((e) => e.revokedAt).length return { measuredAt: new Date().toISOString(), scheme: EARNING_SCHEME, - status: 'recorded, not withdrawable: no token is deployed', - triPerSpec: null, + status: EARNINGS_STATUS, + triPerSpec: TRI_PER_SPEC, rules: { counts: 'one earning per (repository, issue, judged commit) the Queen accepted, ' + @@ -261,9 +277,11 @@ export async function earningsLedger(pool: Pool): Promise { revokes: 'a later send-back or escalation of the same commit, such as a CI take-back', notYet: [ - 'an accept does not require a merge yet, so an earning is not mintable on its own', - 'spec paths are what the turn declared, not yet checked against the diff', - 'TRI per accepted spec is undecided (owner decision)', + 'an earning mints only after its judged commit is part of a pull request merged ' + + 'into the default branch that changes a declared .t27 file; each signer checks ' + + 'that on GitHub, this record does not assert it', + 'spec paths are what the turn declared; the merge check above is what ties them to a diff', + 'no mainnet token exists', ], }, totals: { earned: all.length - revoked, revoked }, @@ -271,3 +289,44 @@ export async function earningsLedger(pool: Pool): Promise { recent: all.slice(0, RECENT_EARNINGS), } } + +/** + * One earning by its work id, and who it is credited to: what a signer reads + * before it signs an attestation for that work id. Null when no such earning + * was recorded. + */ +export interface EarningLookup { + scheme: string + status: typeof EARNINGS_STATUS + triPerSpec: typeof TRI_PER_SPEC + earning: Earning + earner: Pick +} + +export async function earningByWorkId( + pool: Pool, + workId: string, + owners: Record, +): Promise { + const { rows } = await pool.query( + `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, + accepted_at, revoked_at, revoked_reason + FROM queen_tri_earnings + WHERE work_id = $1`, + [workId], + ) + if (rows.length === 0) return null + const earning = toEarning(rows[0]) + const [who] = earnersOf([earning], owners) + return { + scheme: EARNING_SCHEME, + status: EARNINGS_STATUS, + triPerSpec: TRI_PER_SPEC, + earning, + earner: { + name: who.name, + claimed: who.claimed, + ...(who.github ? { github: who.github } : {}), + }, + } +} diff --git a/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts index c72853246d..537ce4a561 100644 --- a/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts @@ -4,10 +4,13 @@ import type { Pool } from 'pg' import { createQueenPublicEarningsRoute } from '../../src/api/routes/queen-public-earnings' import { EARNING_SCHEME, + EARNINGS_STATUS, type Earning, earnersOf, + earningByWorkId, earningsLedger, recordEarnings, + TRI_PER_SPEC, } from '../../src/api/services/queen-tri-earnings' /** @@ -111,16 +114,70 @@ describe('what the record asks the database', () => { expect(revoke.text).not.toContain('review_note') }) - it('says in words that nothing is withdrawable, and invents no amount', async () => { + it('says in words that a mint is testnet-only and not trustless, and publishes the amount', async () => { const { pool } = spy() const ledger = await earningsLedger(pool) - expect(ledger.status).toBe( - 'recorded, not withdrawable: no token is deployed', - ) - expect(ledger.triPerSpec).toBeNull() + expect(ledger.status).toBe(EARNINGS_STATUS) + expect(EARNINGS_STATUS).toContain('testnet only') + expect(EARNINGS_STATUS).toContain('NOT trustless') + // O2, 2026-10-01. A signer refuses any other amount. + expect(ledger.triPerSpec).toBe(27) + expect(TRI_PER_SPEC).toBe(27) expect(ledger.scheme).toBe(EARNING_SCHEME) expect(ledger.totals).toEqual({ earned: 0, revoked: 0 }) - expect(ledger.rules.notYet.join(' ')).toContain('does not require a merge') + // O4: the merge is checked by the signers on GitHub, not asserted here. + expect(ledger.rules.notYet.join(' ')).toContain('merged') + expect(ledger.rules.notYet.join(' ')).toContain('no mainnet token') + }) + + it('looks one earning up by its id and names who it is credited to', async () => { + const seen: unknown[][] = [] + const row = { + work_id: 'a'.repeat(64), + repo: 'gHashTag/t27', + issue: 5429, + judged_head: '7808383a3ca84c8a7ec813ae0869d8f3f7dc6309', + key_index: 6, + spec_paths: ['specs/x.t27'], + accepted_at: '2026-10-01T00:00:00.000Z', + revoked_at: null, + revoked_reason: null, + } + const pool = { + query: (_text: string, params: unknown[] = []) => { + seen.push(params) + return Promise.resolve({ rows: params[0] === row.work_id ? [row] : [] }) + }, + } as unknown as Pool + const found = await earningByWorkId(pool, row.work_id, { 6: '@gHashTag' }) + expect(found).toMatchObject({ + triPerSpec: 27, + earning: { issue: 5429, commit: row.judged_head, revokedAt: null }, + earner: { name: '@gHashTag', claimed: true, github: 'gHashTag' }, + }) + expect(await earningByWorkId(pool, 'b'.repeat(64), {})).toBeNull() + expect(seen).toEqual([[row.work_id], ['b'.repeat(64)]]) + }) + + it('credits an unclaimed lane to nobody on GitHub', async () => { + const pool = { + query: () => + Promise.resolve({ + rows: [ + { + work_id: 'c'.repeat(64), + repo: 'r', + issue: 1, + judged_head: 'h', + key_index: 21, + spec_paths: [], + accepted_at: '2026-10-01T00:00:00.000Z', + }, + ], + }), + } as unknown as Pool + const found = await earningByWorkId(pool, 'c'.repeat(64), {}) + expect(found?.earner).toEqual({ name: 'key #21', claimed: false }) }) }) @@ -141,4 +198,17 @@ describe('the public route', () => { // An empty list would read as "nobody has earned anything". expect(await response.json()).toEqual({ error: 'No database configured' }) }) + + it('refuses a malformed work id before touching any database', async () => { + const response = + await createQueenPublicEarningsRoute().request('/not-a-work-id') + expect(response.status).toBe(400) + }) + + it('answers 503 for a well-formed id when there is no database', async () => { + const response = await createQueenPublicEarningsRoute().request( + `/${'d'.repeat(64)}`, + ) + expect(response.status).toBe(503) + }) }) From 583d8ea76f65fcec2fe15052e55a5d907ba797a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:04:04 +0000 Subject: [PATCH 04/11] test(tools): get_page_content reads a constructed page, not the live example.com With installs no longer hanging, server-tools ran for the first time since 2026-09-23 and failed one test: get_page_content read https://example.com 57 ms after opening it and found no "Example Domain". The test is about extracting text, so it now writes that text into about:blank with evaluate_script, as get_page_links already does. Locally (BrowserOS AppImage, headless, --no-sandbox): the old test fails the same way; the new one passes 3/3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/tools/observation.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/tools/observation.test.ts b/trios/agent-server/apps/server/tests/tools/observation.test.ts index 982296c644..373f2e41fa 100644 --- a/trios/agent-server/apps/server/tests/tools/observation.test.ts +++ b/trios/agent-server/apps/server/tests/tools/observation.test.ts @@ -157,8 +157,17 @@ describe('observation tools', () => { it('get_page_content returns markdown text', async () => { await withBrowser(async ({ execute }) => { - const newResult = await execute(new_page, { url: 'https://example.com' }) + // Constructed in place rather than fetched: reading https://example.com + // raced its own load (CI read it 57 ms after opening, before any text + // arrived), and the test is about extracting text, not about the network. + const newResult = await execute(new_page, { url: 'about:blank' }) const pageId = pageIdOf(newResult) + await execute(evaluate_script, { + page: pageId, + expression: `document.body.innerHTML = ${JSON.stringify( + '

Example Domain

This domain is for use in documentation examples.

', + )}`, + }) const contentResult = await execute(get_page_content, { page: pageId }) assert.ok(!contentResult.isError, textOf(contentResult)) From 0800f957668e8fac00a50696d0c2d966888ba600 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Fri, 2 Oct 2026 00:08:50 +0700 Subject: [PATCH 05/11] feat(queen): every earning credited to one GitHub login GET /queen/public-earnings/by/:github lists the earnings of the keys lent under that login, newest first: what the TRI wallet shows its owner as claimable. The ledger's 'recent' is capped at 100 and cannot serve this. Co-Authored-By: Claude Opus 5.5 --- .../src/api/routes/queen-public-earnings.ts | 33 +++++++++++- .../src/api/services/queen-tri-earnings.ts | 39 ++++++++++++++ .../tests/api/queen-tri-earnings.test.ts | 54 +++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts index 051f56a66f..e73c5b8b83 100644 --- a/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-earnings.ts @@ -19,9 +19,15 @@ import { Hono } from 'hono' import { createQueenPool } from '../../lib/db/queen-pool' import { logger } from '../../lib/logger' import { parseOwners } from '../services/queen-leaderboard' -import { earningByWorkId, earningsLedger } from '../services/queen-tri-earnings' +import { + earningByWorkId, + earningsLedger, + earningsOfLogin, +} from '../services/queen-tri-earnings' const WORK_ID = /^[0-9a-f]{64}$/ +/** GitHub's own login rule: 1-39 alphanumerics or single hyphens. */ +const GITHUB_LOGIN_PARAM = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/ export function createQueenPublicEarningsRoute() { return ( @@ -44,6 +50,31 @@ export function createQueenPublicEarningsRoute() { await pool.end().catch(() => {}) } }) + // Every earning credited to one GitHub login: what a wallet lists as + // claimable. A login that is not a GitHub login is refused unqueried. + .get('/by/:github', async (c) => { + const github = c.req.param('github') + if (!GITHUB_LOGIN_PARAM.test(github)) + return c.json({ error: 'not a GitHub login' }, 400) + const url = process.env.DATABASE_URL + if (!url) return c.json({ error: 'No database configured' }, 503) + const pool = createQueenPool(url, { max: 1 }) + try { + const found = await earningsOfLogin( + pool, + github, + parseOwners(process.env.TRIOS_KEY_OWNERS), + ) + return c.json(found, 200, { 'Cache-Control': 'public, max-age=60' }) + } catch (error) { + logger.warn('Queen earnings of a login could not be read', { + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: 'The earnings ledger is unavailable' }, 503) + } finally { + await pool.end().catch(() => {}) + } + }) // One earning and who it is credited to: what a TRI signer reads before // it signs for this work id. A malformed id is refused before any query. .get('/:workId', async (c) => { diff --git a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts index 37341c948d..8f07f21a3c 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts @@ -330,3 +330,42 @@ export async function earningByWorkId( }, } } + +/** + * Every earning credited to one GitHub login, newest first: what a wallet + * shows its owner as claimable. A login no key is lent under has none. + */ +export interface EarningsOfLogin { + scheme: string + status: typeof EARNINGS_STATUS + triPerSpec: typeof TRI_PER_SPEC + github: string + earnings: Earning[] +} + +export async function earningsOfLogin( + pool: Pool, + github: string, + owners: Record, +): Promise { + const want = github.toLowerCase() + const keys = Object.entries(owners) + .filter(([, name]) => githubLoginOf(name)?.toLowerCase() === want) + .map(([index]) => Number(index)) + const base = { + scheme: EARNING_SCHEME, + status: EARNINGS_STATUS, + triPerSpec: TRI_PER_SPEC, + github, + } + if (keys.length === 0) return { ...base, earnings: [] } + const { rows } = await pool.query( + `SELECT work_id, repo, issue, judged_head, key_index, spec_paths, + accepted_at, revoked_at, revoked_reason + FROM queen_tri_earnings + WHERE key_index = ANY($1::int[]) + ORDER BY accepted_at DESC, work_id`, + [keys], + ) + return { ...base, earnings: rows.map(toEarning) } +} diff --git a/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts index 537ce4a561..5860d71671 100644 --- a/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-tri-earnings.test.ts @@ -9,6 +9,7 @@ import { earnersOf, earningByWorkId, earningsLedger, + earningsOfLogin, recordEarnings, TRI_PER_SPEC, } from '../../src/api/services/queen-tri-earnings' @@ -181,6 +182,47 @@ describe('what the record asks the database', () => { }) }) +describe('the earnings of one GitHub login', () => { + const owners = { 0: '@gHashTag', 1: '@dmitrii-f-t27', 6: '@gHashTag' } + + it('asks only for the keys lent under that login, case-blind', async () => { + const seen: unknown[][] = [] + const pool = { + query: (_text: string, params: unknown[] = []) => { + seen.push(params) + return Promise.resolve({ + rows: [ + { + work_id: 'a'.repeat(64), + repo: 'gHashTag/t27', + issue: 5429, + judged_head: 'h', + key_index: 6, + spec_paths: ['specs/x.t27'], + accepted_at: '2026-10-01T00:00:00.000Z', + }, + ], + }) + }, + } as unknown as Pool + const found = await earningsOfLogin(pool, 'ghashtag', owners) + expect(seen).toEqual([[[0, 6]]]) + expect(found).toMatchObject({ triPerSpec: 27, github: 'ghashtag' }) + expect(found.earnings.map((e) => e.issue)).toEqual([5429]) + }) + + it('answers none, without a query, for a login no key is lent under', async () => { + const pool = { + query: () => { + throw new Error('should not query') + }, + } as unknown as Pool + expect((await earningsOfLogin(pool, 'stranger', owners)).earnings).toEqual( + [], + ) + }) +}) + describe('the public route', () => { let saved: string | undefined beforeEach(() => { @@ -211,4 +253,16 @@ describe('the public route', () => { ) expect(response.status).toBe(503) }) + + it('refuses something that is not a GitHub login before any database', async () => { + const response = + await createQueenPublicEarningsRoute().request('/by/-not-a-login-') + expect(response.status).toBe(400) + }) + + it('answers 503 for a real login when there is no database', async () => { + const response = + await createQueenPublicEarningsRoute().request('/by/gHashTag') + expect(response.status).toBe(503) + }) }) From 26938a669ce23d9f2de05ca1fb4dc23d9951b1a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:13:48 +0000 Subject: [PATCH 06/11] test(helpers): killProcessOnPort kills listeners only, never the test process server-tools still exited 1 after every test in observation.test.ts passed: before navigation-newtab-guard.test.ts the helper ran `lsof -ti :`, which also lists clients still connected to the port. One of them was the bun test process itself (its CDP socket to the previous file's browser), so the SIGTERM ended the whole run and no junit report was written ("workflow > server-tools setup"). Use `lsof -ti tcp: -sTCP:LISTEN` and drop process.pid. Locally, input.test.ts + navigation-newtab-guard.test.ts in one process: before, exit 143 right after "Terminating process(es) , ..."; after, 18 pass / 0 fail. The whole test:tools group now runs to the end (242 pass; the 2 local failures load https://example.com, which this sandbox's browser cannot reach and CI can). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/__helpers__/utils.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/trios/agent-server/apps/server/tests/__helpers__/utils.ts b/trios/agent-server/apps/server/tests/__helpers__/utils.ts index 7c684896b4..c808f6e245 100644 --- a/trios/agent-server/apps/server/tests/__helpers__/utils.ts +++ b/trios/agent-server/apps/server/tests/__helpers__/utils.ts @@ -14,22 +14,29 @@ export async function killProcessOnPort(port: number): Promise { try { console.log(`Finding process on port ${port}...`) - const pids = execSync(`lsof -ti :${port}`, { + // LISTEN only, and never this process. A bare `lsof -i :port` also lists + // every client still connected to the port - including this test process, + // whose CDP socket to the previous file's browser outlives it - and the + // SIGTERM that followed ended the whole server-tools run (exit 143). + const pids = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], - }).trim() + }) + .split('\n') + .map((pid) => pid.trim()) + .filter((pid) => pid !== '' && pid !== String(process.pid)) + .join(' ') if (pids) { - const pidList = pids.replace(/\n/g, ', ') - console.log(`Terminating process(es) ${pidList} on port ${port}...`) + console.log(`Terminating process(es) ${pids} on port ${port}...`) try { - execSync(`kill -15 ${pids.replace(/\n/g, ' ')}`, { + execSync(`kill -15 ${pids}`, { stdio: 'ignore', }) await new Promise((resolve) => setTimeout(resolve, 500)) } catch { - execSync(`kill -9 ${pids.replace(/\n/g, ' ')}`, { + execSync(`kill -9 ${pids}`, { stdio: 'ignore', }) } From 3d57649dada9a41b3f837fbd023ec5083224d9de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:38:27 +0000 Subject: [PATCH 07/11] test(tools): wait_for waits for text a data: page adds, not the live example.com With the run no longer killing itself, server-tools finished in CI with 243 pass / 1 fail: `wait_for finds text on page` waited its full 10 s for "Example Domain" on https://example.com and never saw it - the same page get_page_content could not read either. The page now adds that text itself 500 ms after load, so the test still proves wait_for waits, with nothing outside the runner involved. Locally: 2/2 wait_for tests pass on repeat; the whole test:tools group is 243 pass, the one local failure being take_screenshot (a 60 s hang in this sandbox only - it passes in CI). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/tools/navigation.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/tools/navigation.test.ts b/trios/agent-server/apps/server/tests/tools/navigation.test.ts index f78b9942bf..b69331d2ed 100644 --- a/trios/agent-server/apps/server/tests/tools/navigation.test.ts +++ b/trios/agent-server/apps/server/tests/tools/navigation.test.ts @@ -157,7 +157,15 @@ describe('navigation tools', () => { it('wait_for finds text on page', async () => { await withBrowser(async ({ execute }) => { - const newResult = await execute(new_page, { url: 'https://example.com' }) + // The text arrives half a second after load, from the page itself, so + // this still exercises the waiting - without depending on the live + // https://example.com, which CI's browser never showed it on. + const page = `` + const newResult = await execute(new_page, { + url: `data:text/html,${encodeURIComponent(page)}`, + }) const pageId = structuredOf<{ pageId: number }>(newResult).pageId const waitResult = await execute(wait_for, { From 83213496ae2a3784eee7a3721df72cc37040fc1a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:42:02 +0000 Subject: [PATCH 08/11] test(tools): the class-selector search_dom test retries the load race too server-tools on 3d57649 ran clean except one test that had passed on both earlier runs: `search_dom > finds multiple elements with CSS class selector` (123 ms, fewer than 3 matches). It searches once, straight after new_page - the race this file already names and fixes with searchUntil for two sibling tests. Use the same helper here. Locally: search_dom 13/13, three runs in a row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- trios/agent-server/apps/server/tests/tools/dom.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/trios/agent-server/apps/server/tests/tools/dom.test.ts b/trios/agent-server/apps/server/tests/tools/dom.test.ts index d487a8f2cb..53e9ccd686 100644 --- a/trios/agent-server/apps/server/tests/tools/dom.test.ts +++ b/trios/agent-server/apps/server/tests/tools/dom.test.ts @@ -400,10 +400,9 @@ describe('search_dom', () => { const newResult = await execute(new_page, { url: RICH_PAGE }) const pageId = pageIdOf(newResult) - const result = await execute(search_dom, { - page: pageId, - query: '.nav-link', - }) + // Same load race searchUntil exists for: CI found fewer than 3 once + // (2026-10-01) on a run where the identical query passed before. + const result = await searchUntil(execute, pageId, '.nav-link', 'Found 3') assert.ok(!result.isError, textOf(result)) const text = textOf(result) assert.ok(text.includes('Found 3'), 'Should find exactly 3 nav links') From 350d55969383eef7fbfacd16590f5987357d563c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:44:28 +0000 Subject: [PATCH 09/11] test(queen): give the 205-file salvage rename test an explicit 30 s budget `the salvage commit > never splits a rename across the path cap` runs real git over 205 files and salvageWorktree. It takes ~2 s for the whole file locally and passed on the two CI runs before, then hit bun's 5 s default once on a loaded runner (job 110500921083) with nothing in the change touching salvage. A git-heavy fixture test should not share the budget of a pure unit test. Locally: queen-salvage-guards.test.ts 13 pass / 0 fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/api/queen-salvage-guards.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts index a533efeecc..f80ba552ca 100644 --- a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts @@ -541,7 +541,9 @@ describe('the salvage commit', () => { expect(head).toContain('trios/docs/a/new.md') expect(head).not.toContain('trios/docs/z/old.md') rmSync(f.scratch, { recursive: true, force: true }) - }) + // Real git over 205 files: ~0.2 s here, but once over bun's 5 s default on + // a loaded CI runner (2026-10-01) while passing on the runs either side. + }, 30_000) }) // --------------------------------------------------------------------------- From aa74689dfc605ac3e02c2e7e719a787bc3cbfdd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:23:23 +0000 Subject: [PATCH 10/11] test(queen): the route-guard audit knows /queen/contributor-keys #522 mounted /queen/contributor-keys and left the route-guard audit unchanged, so feat/queen-supervisor fails four route-guard tests: 46 mounts against a pin of 45, 23 /queen mounts against 22, and an unguarded mount nobody allowlisted. The route is a server-to-server door for the app render proxy and has its own guard: a bearer equal to QUEEN_CONTRIBUTOR_PROXY_TOKEN (32+ bytes, timingSafeEqual) plus a verified contributor header, and it is off while that token is unset. The trusted-origin check would refuse its only caller, so it is allowlisted with that reason and the pins are re-measured. No other number moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../tests/api/routes/route-guard.test.ts | 20 +++++++++++++------ trios/tools/route-guard-audit.mjs | 5 +++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts index f5950ac673..cee95fcc39 100644 --- a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts +++ b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts @@ -25,7 +25,7 @@ import { const source = readServerSource() const report = auditServer(source, DEFAULT_ALLOWLIST) -// Regression pin for the --no-allowlist run: exactly these seven mounts carry +// Regression pin for the --no-allowlist run: exactly these nine mounts carry // no guard today, each for a reason the comments beside the mount give. // RE-MEASURED 2026-09-13: /api/inngest joined. It is not a shell - it is the // Queen's scheduler endpoint - and it is unguarded on purpose: Inngest signs @@ -39,6 +39,7 @@ const report = auditServer(source, DEFAULT_ALLOWLIST) const EXPECTED_UNGUARDED_WITHOUT_ALLOWLIST = [ '/api/inngest', '/health', + '/queen/contributor-keys', '/queen/dashboard', '/queen/feed', '/queen/hq', @@ -84,7 +85,10 @@ describe('route-guard audit over src/api/server.ts', () => { // where a route named `public` belongs. Every other number here is // unchanged, which is the part worth stating: no guarded route quietly lost // its guard to make room for it. - expect(report.totalMounts).toBe(45) + // RE-MEASURED 2026-10-02: 45 became 46 with /queen/contributor-keys + // (#522), a server-to-server route behind its own capability token. It is + // allowlisted with that reason; no other number moved. + expect(report.totalMounts).toBe(46) expect(report.prefixGuardCount).toBe(18) expect(report.guardedSubAppCount).toBe(15) expect(report.publicReadCount).toBe(8) @@ -97,7 +101,7 @@ describe('route-guard audit over src/api/server.ts', () => { expect(report.entriesMissingReason).toEqual([]) }) - it('reports exactly the seven reasoned exceptions when the allowlist is dropped', () => { + it('reports exactly the nine reasoned exceptions when the allowlist is dropped', () => { // The classifier reports mounts in file order; the assertion is on the // exact set, so both sides are sorted before comparing. expect([...unguardedMounts(source, [])].sort()).toEqual( @@ -105,7 +109,7 @@ describe('route-guard audit over src/api/server.ts', () => { ) }) - it('splits the twenty-two /queen mounts into 8 public-read, 8 wrapper-guarded and 6 allowlisted shells', () => { + it('splits the twenty-three /queen mounts into 8 public-read, 8 wrapper-guarded and 7 allowlisted', () => { const queenMounts = classifyMounts(source).filter( (mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'), ) @@ -127,7 +131,11 @@ describe('route-guard audit over src/api/server.ts', () => { // issue title, no worker text and no credential: only a key's INDEX ever // reaches the database, so there is nothing here a stranger could read that // the board does not already show. - expect(queenMounts.length).toBe(22) + // RE-MEASURED 2026-10-02: twenty-two became twenty-three. The seventh + // allowlisted mount is /queen/contributor-keys (#522), which serves data + // only to a caller holding QUEEN_CONTRIBUTOR_PROXY_TOKEN - its own guard, + // not the trusted-origin one, because its caller is a server. + expect(queenMounts.length).toBe(23) const counts: Record = { 'public-read': 0, @@ -144,7 +152,7 @@ describe('route-guard audit over src/api/server.ts', () => { 'public-read': 8, 'prefix-guard': 0, wrapper: 8, - unguarded: 6, + unguarded: 7, }) // Every unguarded /queen mount must be one of the allowlisted shells. diff --git a/trios/tools/route-guard-audit.mjs b/trios/tools/route-guard-audit.mjs index 0254f4feb8..5dda90e16d 100644 --- a/trios/tools/route-guard-audit.mjs +++ b/trios/tools/route-guard-audit.mjs @@ -105,6 +105,11 @@ export const DEFAULT_ALLOWLIST = [ reason: 'shell only — the operator page holds no state and no token; its numbers come from /queen/lease and its one action POSTs there with a bearer the reader supplies, so both stay guarded (comment at the mount)', }, + { + path: '/queen/contributor-keys', + reason: + 'own capability - a server-to-server route for the app render proxy, with no browser Origin; every request is refused unless its bearer equals QUEEN_CONTRIBUTOR_PROXY_TOKEN (at least 32 bytes, compared with timingSafeEqual) and it carries a verified x-queen-contributor-id, and the route is off while that token is unset (src/api/routes/queen-contributor-keys.ts, tests/api/queen-contributor-keys.test.ts)', + }, { path: '/api/inngest', reason: From 44b62f6f8deffed1f8af8f5c9faf48c5cdd224e5 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Fri, 2 Oct 2026 14:09:17 +0700 Subject: [PATCH 11/11] fix(queen): keep the earnings status literal in earningsOfLogin The base object literal widened EARNINGS_STATUS to string, so the function no longer matched EarningsOfLogin. Typing base as Omit keeps the literal. Co-Authored-By: Claude Opus 5.5 --- .../apps/server/src/api/services/queen-tri-earnings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts index 8f07f21a3c..4f822db055 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tri-earnings.ts @@ -352,7 +352,7 @@ export async function earningsOfLogin( const keys = Object.entries(owners) .filter(([, name]) => githubLoginOf(name)?.toLowerCase() === want) .map(([index]) => Number(index)) - const base = { + const base: Omit = { scheme: EARNING_SCHEME, status: EARNINGS_STATUS, triPerSpec: TRI_PER_SPEC,