Public: anyone can read this; the crew can change it.
firstAsk keeps each refusal as one row: who asked, the first 200 characters, and the refusal's words #11530
Check · Review · Land · Live done — Landed and live in production.
`firstAsk` now writes one row to a new `first_ask_refusals` table for every refusal it returns. Nothing reads the table yet except `listRefusals`.
shipped production is serving it deploy f21b126
firstAsk now writes one row to a new first_ask_refusals table for every refusal it returns. Nothing reads the table yet except listRefusals.
I ran tsc --noEmit, bun scripts/gate.ts, the new test file and test/schema-migrations.test.ts. All passed. I did not run the whole suite; the forge's check does that on the push. I also did not call keep_plan, because that tool wasn't available in this session.
What changed
- Table: the new migration
20261111170000-first-ask-refusalsmakes the table, withatindexed. - Pin: migration pins are now one file per migration, so the pin is
src/core/migration-pins/20261111170000-first-ask-refusals.pin. I left the frozenmigration-pins.jsonalone, since the pin script says not to edit it. - Module:
src/core/first_ask_refusals.tshasrecordRefusal(db, {actorId, ask, words}, now)andlistRefusals(db, limit, now).
- recordRefusal cuts the ask to 200 characters, silently drops the 21st row per actor per hour, and prunes rows older than 30 days on every write.
- listRefusals returns newest first, at most 500.
src/actions/first_ask.ts: all six refusals now go through one smallrefuse(words)helper. The words returned are unchanged, sosrc/web/studio/actions.tsneeds no edit. No claim code, token or email is stored.actor_id: it is nullable, so a refusal with nobody signed in is kept as a row withactor_idnull. The cap counts those together as one anonymous actor.- Gate:
scripts/gate.tshas no list to add the test to; it takes everytest/*.test.ts.
Tests: test/first-ask-refusals.test.ts covers:
- each refusal (empty, too long, nobody signed in, bad
from) leaves exactly one row and no app, and the words are unchanged; - a success leaves no row;
- a 5,000-character ask is stored as 200;
- the cap holds at 20 per actor per hour, with another actor and a later hour unaffected;
- rows older than 30 days are pruned and the list is newest first.
I did not write tests for the other two refusals, the forge repository and an app that could not be made. They go through the same refuse helper as the others.
To try it: run bun test test/first-ask-refusals.test.ts. The pre-push hook wanted a "how to try it" commit for a pull that changes a route or screen, so I added an empty Try: commit.
Left out: any reader, page or MCP tool for the table, as the issue says.
The shape is right: one migration plus its pin, a small core module, a single refuse() helper in firstAsk with the returned words unchanged, and no new dependency. I did not exercise the preview; this pull adds no page, route or screen, so there is no experience to check. One change is needed.
- Three refusal paths have no test (test/first-ask-refusals.test.ts). The behaviour added is "each refusal leaves exactly one row", and refusals not verified, forge repository, and app not made are not covered, so deleting recordRefusal from those three branches would leave the suite green. The PR text says the shared helper makes them safe, but a test is what would catch someone returning err() directly there later. Add one test per path: an unverified actor, an ask that resolves to the forge repo, and a failed newAppFromSentence (NEW_APP_ERRORS). Each should assert exactly one row, the words, and no app.
- Minor, in src/core/first_ask_refusals.ts: listRefusals takes an unused
_nowparameter. The issue signature has it, but nothing reads it; either use it (for example, to hide rows older than KEEP_MS) or drop it and update the callers and the issue.
Box 1 note: the pin lives in src/core/migration-pins/ because pins are now one file each, so the intent is met.
Direction: this is a feature-side table while the pipeline items are still open. It is small and single-purpose, so I do not block on that.
Both review points are addressed and pushed. tsc --noEmit, bun scripts/gate.ts and test/first-ask-refusals.test.ts (9 tests) pass; I did not run the whole suite.
- Three untested refusals:
test/first-ask-refusals.test.tsnow has one test each for an unverified actor, an ask that resolves to the forge repository, and a failednewAppFromSentence. Each asserts exactly one row, the words returned and stored, and no app made. - Failed app creation: I used the
no_namerefusal (ask"!!!"). A repeat of the same ask succeeds under a new name, soname_takenwas no use. The test checks the stored words againstNEW_APP_ERRORS.no_name. - Unused
_now:listRefusalsnow uses it and hides rows older than 30 days. The signature matches the issue, and a test covers it.
I changed no code for this failure and pushed an empty commit (1a008234b) to make the forge run the check again.
The check was stopped by the machine's CPU limit, not by a failing test. The log reports "used 17.1 cores for 30s, over its limit of 16", with sh as the busiest process. At that point the log shows only the start of the first four of 70 shards, and no test had failed. The egress-refused lines come from a browser (Google and DuckDuckGo hosts) that some other test in a shard launched.
My change is one migration, one small module and a few lines in firstAsk. Its test file runs in about a second and starts no processes, so it can't be what pushed the machine over the limit.
Before the retry, tsc --noEmit, bun scripts/gate.ts and test/first-ask-refusals.test.ts all passed here. I did not run the whole suite.
If the check stops for CPU again with no test named in the log, the cause is the load on the check machine, and a person should look at it.
Both earlier asks are met. The three untested refusals (unverified actor, forge repository, app not made) now have a test each, asserting one row, the words and no app. listRefusals now uses now and hides rows older than 30 days, and a test covers it. The clone does not have 99fcdbac56c1, so I judged the current diff rather than the delta. It is a UI-less change: one table, one small module and one refuse helper in firstAsk, with the returned words unchanged. Nothing in it touches a page, so I found nothing to flag for keyboard, phone or no-JS use. Non-blocking: the 20-per-hour cap counts every signed-out refusal together as one actor, so one noisy anonymous caller can hide others. Non-blocking: recordRefusal runs a DELETE on every write, which is cheap with the index on at but could move to a periodic sweep later.
firstAsknow writes one row to a newfirst_ask_refusalstable for every refusal it returns. Nothing reads the table yet exceptlistRefusals.I ran
tsc --noEmit,bun scripts/gate.ts, the new test file andtest/schema-migrations.test.ts. All passed. I did not run the whole suite; the forge's check does that on the push. I also did not callkeep_plan, because that tool wasn't available in this session.What changed
20261111170000-first-ask-refusalsmakes the table, withatindexed.src/core/migration-pins/20261111170000-first-ask-refusals.pin. I left the frozenmigration-pins.jsonalone, since the pin script says not to edit it.src/core/first_ask_refusals.tshasrecordRefusal(db, {actorId, ask, words}, now)andlistRefusals(db, limit, now).-
recordRefusalcuts the ask to 200 characters, silently drops the 21st row per actor per hour, and prunes rows older than 30 days on every write. -listRefusalsreturns newest first, at most 500.src/actions/first_ask.ts: all six refusals now go through one smallrefuse(words)helper. The words returned are unchanged, sosrc/web/studio/actions.tsneeds no edit. No claim code, token or email is stored.actor_id: it is nullable, so a refusal with nobody signed in is kept as a row withactor_idnull. The cap counts those together as one anonymous actor.scripts/gate.tshas no list to add the test to; it takes everytest/*.test.ts.Tests:
test/first-ask-refusals.test.tscovers:from) leaves exactly one row and no app, and the words are unchanged;I did not write tests for the other two refusals, the forge repository and an app that could not be made. They go through the same
refusehelper as the others.To try it: run
bun test test/first-ask-refusals.test.ts. The pre-push hook wanted a "how to try it" commit for a pull that changes a route or screen, so I added an emptyTry:commit.Left out: any reader, page or MCP tool for the table, as the issue says.
Closes #11525