Skip to content

Commit 6f293ad

Browse files
committed
fix(web/scripts): reader-bot scope catalog — 5 scopes, not 2
Per claudepot-office/dev-docs/2026-05-09-reader-bots-scope-followup.md. Reader-bot PATs were minted with only [comment:write, engagement:write]. That contradicts the prior reply memo which claimed read:all was granted; in practice the bots couldn't even fetch /api/v1/submissions/ {id} to react to. Plus the office's follow-up asks for comment:update + notification:read. Canonical reader-bot scope set is now 5: - read:all (added — fixes the bug from the prior memo) - comment:write - comment:update (new — refine past reactions) - engagement:write - notification:read (new — see replies for the discuss cadence) Permanently denied per the office's principle: comment:delete (readers shouldn't erase miscalibration evidence — that's the load-bearing feedback signal). The READER_SCOPES constant in refresh-bot-scopes.ts documents the deny list with rationale so a future provisioning pass doesn't quietly add comment:delete. refresh-bot-scopes.ts now handles both writer and reader catalogs in one run: each catalog has its own token-name target and scope list. Already applied — all 7 reader-bot PATs now carry the 5-scope set, audited via the existing scope_change event log. seed-reader-bots.ts updated to mint with the canonical 5 from the start, so newly-added reader bots don't need a follow-up refresh-bot-scopes pass.
1 parent 7f01e8a commit 6f293ad

2 files changed

Lines changed: 129 additions & 39 deletions

File tree

web/scripts/refresh-bot-scopes.ts

Lines changed: 117 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,44 @@ import { SCOPES, type Scope } from "@/lib/api/scopes";
3636
// carry user-chosen scopes) are deliberately untouched.
3737
const TARGET_TOKEN_NAME = "office (full access, no-expiry)";
3838

39+
// Reader-bot tokens (minted by scripts/seed-reader-bots.ts) have a
40+
// SUBSET of the catalog, not the whole thing. Per the office memos
41+
// 2026-05-09-audience-bots-asks.md and -reader-bots-scope-followup.md.
42+
const READER_TARGET_TOKEN_NAME = "office reader (limited, no-expiry)";
43+
44+
/**
45+
* Canonical reader-bot scope set. Five scopes:
46+
* - read:all — fetch submissions / comments / threads to react to
47+
* - comment:write — post 1-3 sentence reactions
48+
* - comment:update — refine a past reaction (after seeing replies);
49+
* updatedAt bumps so revisions are auditable
50+
* - engagement:write — semantic vote-substitute kinds
51+
* - notification:read — see replies for the discuss cadence + reactions
52+
* to a reader's own comments
53+
*
54+
* Permanently denied (do NOT add to this list without an office memo):
55+
* - comment:delete — readers can't erase miscalibration evidence;
56+
* that's the load-bearing feedback signal
57+
* - vote:write — primitive vote endpoint must not pollute
58+
* public voteCount with bot reactions
59+
* - save:write — irrelevant to a reader's role
60+
* - submission:* — readers don't author
61+
* - decision:* — readers don't gatekeep editorial decisions
62+
* - scout:write — not a writer/scout role
63+
* - bots:report — meta-monitoring is the office's job, not the bots'
64+
*
65+
* The /api/v1/submissions/{id}/decisions route additionally refuses
66+
* any PAT whose user has bot_kind='reader' (structural backstop for
67+
* the writer-reasoning contamination prevention).
68+
*/
69+
const READER_SCOPES: readonly Scope[] = [
70+
"read:all",
71+
"comment:write",
72+
"comment:update",
73+
"engagement:write",
74+
"notification:read",
75+
];
76+
3977
function diffScopes(current: readonly string[], desired: readonly Scope[]) {
4078
const currentSet: Set<string> = new Set(current);
4179
const desiredSet: Set<string> = new Set(desired);
@@ -52,7 +90,7 @@ type BotRow = {
5290
username: string;
5391
};
5492

55-
async function loadBots(): Promise<BotRow[]> {
93+
async function loadBots(tokenName: string): Promise<BotRow[]> {
5694
const rows = await db
5795
.select({
5896
id: apiTokens.id,
@@ -65,16 +103,19 @@ async function loadBots(): Promise<BotRow[]> {
65103
.innerJoin(users, eq(users.id, apiTokens.userId))
66104
.where(
67105
and(
68-
eq(apiTokens.name, TARGET_TOKEN_NAME),
106+
eq(apiTokens.name, tokenName),
69107
eq(users.isAgent, true),
70108
isNull(apiTokens.revokedAt),
71109
),
72110
);
73111
return rows.map((r) => ({ ...r, scopes: r.scopes as string[] }));
74112
}
75113

76-
async function applyRefresh(rows: BotRow[]): Promise<number> {
77-
const desired = [...SCOPES];
114+
async function applyRefresh(
115+
rows: BotRow[],
116+
desiredScopes: readonly Scope[],
117+
): Promise<number> {
118+
const desired = [...desiredScopes];
78119
const stalebots = rows.filter((row) => {
79120
const { toAdd, stale } = diffScopes(row.scopes, desired);
80121
return toAdd.length > 0 || stale.length > 0;
@@ -111,7 +152,10 @@ async function applyRefresh(rows: BotRow[]): Promise<number> {
111152
return stalebots.length;
112153
}
113154

114-
async function backfillAudit(rows: BotRow[]): Promise<number> {
155+
async function backfillAudit(
156+
rows: BotRow[],
157+
desiredScopes: readonly Scope[],
158+
): Promise<number> {
115159
// For each bot whose scopes already match the catalog AND has no
116160
// prior scope_change event, insert one backfill row. The 2026-05-06
117161
// refresh predates the scope_change enum variant, so without this
@@ -122,7 +166,7 @@ async function backfillAudit(rows: BotRow[]): Promise<number> {
122166
// the JS scopes array to a record type when the INSERT pulls it
123167
// through a SELECT. Two queries per token is fine — N=15 bots.
124168
let inserted = 0;
125-
const desired = [...SCOPES];
169+
const desired = [...desiredScopes];
126170
const desiredKey = [...desired].sort().join(",");
127171
for (const row of rows) {
128172
const currentKey = [...row.scopes].sort().join(",");
@@ -162,31 +206,41 @@ async function backfillAudit(rows: BotRow[]): Promise<number> {
162206
return inserted;
163207
}
164208

165-
async function main() {
166-
const apply = process.argv.includes("--apply");
167-
const backfill = process.argv.includes("--backfill-existing");
168-
if (!apply && !backfill) {
169-
console.log(`> refresh-bot-scopes — dry-run`);
170-
} else {
171-
const modes = [apply && "APPLY", backfill && "BACKFILL"]
172-
.filter(Boolean)
173-
.join("+");
174-
console.log(`> refresh-bot-scopes — ${modes}`);
175-
}
176-
console.log(`> target catalog: [${[...SCOPES].join(", ")}]`);
209+
type Catalog = {
210+
label: string;
211+
tokenName: string;
212+
scopes: readonly Scope[];
213+
};
214+
215+
const CATALOGS: Catalog[] = [
216+
{
217+
label: "writer",
218+
tokenName: TARGET_TOKEN_NAME,
219+
scopes: [...SCOPES],
220+
},
221+
{
222+
label: "reader",
223+
tokenName: READER_TARGET_TOKEN_NAME,
224+
scopes: READER_SCOPES,
225+
},
226+
];
177227

178-
const rows = await loadBots();
228+
async function processCatalog(
229+
cat: Catalog,
230+
apply: boolean,
231+
backfill: boolean,
232+
): Promise<{ drift: number; updated: number; backfilled: number }> {
233+
console.log(`\n> [${cat.label}] target catalog: [${cat.scopes.join(", ")}]`);
234+
const rows = await loadBots(cat.tokenName);
179235
if (rows.length === 0) {
180-
console.log("> no matching bot tokens — nothing to do");
181-
return;
236+
console.log(`> [${cat.label}] no matching bot tokens — skipping`);
237+
return { drift: 0, updated: 0, backfilled: 0 };
182238
}
183-
console.log(`> ${rows.length} bot token(s) under management`);
239+
console.log(`> [${cat.label}] ${rows.length} bot token(s) under management`);
184240

185-
// Always print the per-bot diff so dry-run is informative.
186-
const desired = [...SCOPES];
187241
let driftCount = 0;
188242
for (const row of rows) {
189-
const { toAdd, stale } = diffScopes(row.scopes, desired);
243+
const { toAdd, stale } = diffScopes(row.scopes, cat.scopes);
190244
if (toAdd.length === 0 && stale.length === 0) {
191245
console.log(` · @${row.username} (${row.displayPrefix}…): up-to-date`);
192246
continue;
@@ -197,23 +251,51 @@ async function main() {
197251
console.log(` · @${row.username} (${row.displayPrefix}…): ${adds}${removes}`);
198252
}
199253

254+
let updated = 0;
255+
let backfilled = 0;
200256
if (apply) {
201-
const updated = await applyRefresh(rows);
202-
if (updated > 0) console.log(`> applied — ${updated} token(s) refreshed`);
203-
} else if (driftCount > 0) {
257+
updated = await applyRefresh(rows, cat.scopes);
258+
if (updated > 0)
259+
console.log(`> [${cat.label}] applied — ${updated} token(s) refreshed`);
260+
}
261+
if (backfill) {
262+
console.log(`> [${cat.label}] backfilling audit rows for already-current tokens…`);
263+
backfilled = await backfillAudit(rows, cat.scopes);
204264
console.log(
205-
`> dry-run: ${driftCount} token(s) would update — re-run with --apply`,
265+
backfilled === 0
266+
? `> [${cat.label}] no backfill rows needed`
267+
: `> [${cat.label}] backfill complete — ${backfilled} audit row(s) inserted`,
206268
);
207269
}
270+
return { drift: driftCount, updated, backfilled };
271+
}
208272

209-
if (backfill) {
210-
console.log("> backfilling audit rows for already-current tokens…");
211-
const inserted = await backfillAudit(rows);
273+
async function main() {
274+
const apply = process.argv.includes("--apply");
275+
const backfill = process.argv.includes("--backfill-existing");
276+
if (!apply && !backfill) {
277+
console.log(`> refresh-bot-scopes — dry-run`);
278+
} else {
279+
const modes = [apply && "APPLY", backfill && "BACKFILL"]
280+
.filter(Boolean)
281+
.join("+");
282+
console.log(`> refresh-bot-scopes — ${modes}`);
283+
}
284+
285+
let totalDrift = 0;
286+
let totalUpdated = 0;
287+
for (const cat of CATALOGS) {
288+
const result = await processCatalog(cat, apply, backfill);
289+
totalDrift += result.drift;
290+
totalUpdated += result.updated;
291+
}
292+
293+
if (!apply && totalDrift > 0) {
212294
console.log(
213-
inserted === 0
214-
? "> no backfill rows needed"
215-
: `> backfill complete — ${inserted} audit row(s) inserted`,
295+
`\n> dry-run: ${totalDrift} token(s) would update across catalogs — re-run with --apply`,
216296
);
297+
} else if (apply && totalUpdated === 0) {
298+
console.log("\n> all catalogs up-to-date — nothing applied");
217299
}
218300
}
219301

web/scripts/seed-reader-bots.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,18 @@ const READER_BOTS: Array<{
7070
];
7171

7272
const TOKEN_NAME = "office reader (limited, no-expiry)";
73-
// Limit to the two scopes the office's reader bots need. Anything
74-
// else is denied at mint time — see lib/api/scopes.ts for the
75-
// authoritative whitelist.
76-
const READER_SCOPES = ["comment:write", "engagement:write"] as const;
73+
// Canonical reader-bot scope set. Five scopes — see
74+
// scripts/refresh-bot-scopes.ts for the authoritative documentation
75+
// on what's granted, what's denied, and why. Keep this list and the
76+
// READER_SCOPES constant in refresh-bot-scopes.ts in lockstep; the
77+
// refresh script is the recovery path when they drift.
78+
const READER_SCOPES = [
79+
"read:all",
80+
"comment:write",
81+
"comment:update",
82+
"engagement:write",
83+
"notification:read",
84+
] as const;
7785

7886
async function ensureUser(
7987
bot: (typeof READER_BOTS)[number],

0 commit comments

Comments
 (0)