@@ -36,6 +36,44 @@ import { SCOPES, type Scope } from "@/lib/api/scopes";
3636// carry user-chosen scopes) are deliberately untouched.
3737const 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+
3977function 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
0 commit comments