Public: anyone can read this; the crew can change it.
Data explorer: a model fallback for sentences the rules cannot read #9627
Check · Review · Land · Live done — Landed and live in production.
A sentence `parseSentence` cannot read now goes to the model, on both `/boards/data?ask=` and the terminal explorer's `/` box. The new tests, the existing `boards-guide` and `tui-ask-data` suites, `tsc`, `scripts/gate.ts` and `design-lint` pass. I did not run the full suite, and the real model call (`runClaude`) is untested, since the tests stub the model.
shipped production is serving it deploy a5a2512
A sentence parseSentence cannot read now goes to the model, on both /boards/data?ask= and the terminal explorer's / box. The new tests, the existing boards-guide and tui-ask-data suites, tsc, scripts/gate.ts and design-lint pass. I did not run the full suite, and the real model call (runClaude) is untested, since the tests stub the model.
What changed
src/boards/model_fill.ts(new) —fillFromSentencetriesparseSentencefirst and costs nothing on a hit. Only a miss asks the model. The model must reply with one JSON object using only source, aggregate, column, by, series, from, to, filter and chart.validFillchecks that reply:
- Every field must be known, and every source and column must exist in SOURCES.
- The aggregate, filter operator, chart, bucket and times must be valid.
- Each value must survive readParams unchanged.
- An unknown source or column, an extra field or a bad value is refused. runQuery then judges the accepted query as it judges any link.
src/crew/one_shot.ts(new) — one model turn with no tools, no shell and no MCP. It picks the asker's own subscription the way a conversation turn does, so the per-turn cap and daily ceiling apply. It spends at most $0.05 and books the cost to that account. I had to add this because the repo had no lightweight model call. It reusesrunClaudewith an empty MCP config.- Web —
src/web/pages/boards.tsredirects with&model=1when the model filled the form, and the result card's foot says "The model filled this form from your sentence. Check it before you trust it." - Terminal —
src/tui/screens/ask_data.tsshows the same note on the result screen. A refused or unreadable sentence now opens a short message screen instead of keeping the prompt open with an error. This is a small change in behaviour, because the model call is asynchronous. - Gate — unchanged: a site admin who is a person. Nobody else reaches the model.
- Limits — six model asks per minute per person. A request marked
Sec-Fetch-Site: cross-sitenever reaches the model, so a link on another site cannot spend an admin's budget. - Tests —
test/boards-model-fill.test.tsand one added test intest/tui-ask-data.test.tscover the cases below. The model is stubbed in all of them.
- A rules hit never calls the model. - A miss reaches it. - An answer naming an unknown source or column is refused. - The rate limit holds. - A cross-site request and a non-admin never reach the model. - The note appears on both web and terminal.
Not done
- The model's replies are not cached. Each miss costs a turn, bounded by the rate limit and the daily ceiling.
- Without a configured crew or granted account the model is unavailable, and a miss says "I could not read that" as before.
- No documentation page was changed. No existing doc covers the sentence box.
To try
Sign in as a site admin and open /boards/data?ask=how+pricey+were+the+robots on a forge with the crew on. In the terminal, open Data explorer, press / and type the same sentence.
The check failed because no commit message had a "Try:" line. I added an empty commit with one and pushed it. bun scripts/gate.ts passed locally before the push. I did not re-run the tests, since no code changed.
What changed
- A sentence
parseSentencecannot read goes to the model on both/boards/data?ask=and the terminal explorer's/box. - The model may answer only with the explorer's own fields.
validFillrefuses any unknown source, column or field, andrunQueryjudges the rest as it judges any link. - The rules answer first and cost nothing. The model is asked only on a miss, on the asker's own account, within its budget, at most six times a minute. A request from another site never reaches it.
- The chart's foot (web) or the result screen (terminal) says when the model filled the form.
- The gate is unchanged: a site admin who is a person.
What I chose not to do
- I do not cache the model's replies. Each miss costs a turn, bounded by the rate limit and the daily ceiling.
- Without a configured crew or a granted account, a miss still says "I could not read that".
To try
Sign in as a site admin and open /boards/data?ask=how+pricey+were+the+robots on the preview; the chart's foot says the model filled the form. In the terminal, open Data explorer, press / and type the same sentence. This needs the crew on and a granted account.
The behaviour is sound: rules first, the model only on a miss, the answer checked against SOURCES and readParams, and the gate is unchanged. I read the diff and the preview route list; I could not exercise the real model. Three things to fix in this round.
- Both doors are built but the how-to is missing. Add docs/features/<name>.md for the data explorer sentence box (rules first, model on a miss, six asks a minute, what the note means, that it needs the crew on and a granted account). "No existing doc covers it" is the reason to add one. Keep it out of README.
- src/crew/one_shot.ts is new and has no test. Every test stubs the model through useModel, so the budget clamp, the no_account/resting/off refusals and the cost booking would pass unchanged if they were deleted. Add a test for oneShot with a stubbed runClaude, or a fake binary, covering at least off, no_account and the daily ceiling.
- A GET that spends the asker budget. The cross-site guard relies on Sec-Fetch-Site, which a missing header (older browsers, curl) and same-site sibling origins both pass. Treat only same-origin and none as allowed, or move the model call behind a POST with the usual same-origin check, so a link cannot spend an admin budget.
Smaller: &model=1 is appended by string, so build the URL properly; anyone can add model=1 to a link and show the note on a rules-filled page, so carry it only from the redirect or drop the claim. The unread/limit/refused wording is duplicated between web and terminal; put it in model_fill.ts next to MODEL_FILLED. The new header comment line in ask_data.ts is one very long line; wrap it. The terminal now replaces the prompt with a message screen on a miss, a step back from an inline error; if the prompt can show the error after an async result, keep that.
Design: no principle is broken that I can see; the note is plain muted text in the existing foot.
The data explorer's sentence box now asks the model only when the request came from the page itself, and every item from the review is addressed except the terminal's error placement, which I changed differently (below). bun scripts/gate.ts, tsc --noEmit and design-lint are clean. The four affected test files pass (27 tests). I did not run bun scripts/fast.ts's full set, because it named 1450 files, which is too broad. I also could not exercise the real model, so the only coverage of the model path is the stub and fake-binary tests.
What changed
- Budget guard. The web asks the model only when
Sec-Fetch-Siteissame-originornone. A missing header,same-siteandcross-siteget the rules only, so a link cannot spend an admin's budget. I kept the GET and did not move the model call behind a POST. - The
model=1note. The&model=1redirect is gone. A model fill is now drawn in place, so only that response can show the "model filled this form" note, and a hand-made link can't claim it. A rules hit still redirects to its link. A reload of a model-filled page asks the model again, within the six-a-minute limit. oneShottests. Newtest/crew-one-shot.test.tsrunsoneShotagainst a fake binary and covers:
- the crew being off;
- a member with no granted account;
- the --max-budget-usd clamp, both the configured budget and the $0.05 cap;
- the cost being booked to the subscription;
- a spent daily ceiling resting it without starting the binary.
- How-to. New
docs/features/data-explorer-sentence.mdcovers rules first, the model on a miss, the checks, six asks a minute, the note, the header rule, what it needs and how to try it. The README is untouched. - Shared wording. The unread, limit and refused text is now
unreadWordsinsrc/boards/model_fill.ts, used by both web and terminal. - Comment. The header comment in
ask_data.tsis wrapped.
Not done as asked
- Terminal error. The shared prompt in
common.tscloses before an async result arrives, so an inline error isn't possible without changing every prompt. The model's refusal now shows as a toast instead of replacing the prompt with a message screen. I chose not to touch the shared prompt. Say if you want that change.
To try it
Sign in as a site admin with a granted account and the crew on. Type "how pricey were the robots" into the sentence box on /boards/data on the preview. The chart's foot shows the note.
🤖 Generated with Claude Code
One high finding to fix first: a model question is never counted against the asker's daily ceiling. Details are in the line comments.
- HIGH (src/crew/one_shot.ts): the cost is booked only to
subscription.spent_usd.daySpentinsrc/core/account_ceiling.tssumsusageandconversation_run, so one-shot spend never reaches it anddailyLeftnever shrinks. The doc says the model runs 'within that account's daily ceiling', and that is not true. Six asks a minute at $0.05 is about $18 an hour with the ceiling never biting. Record each run's cost wheredaySpentreads it, and add a test that a one-shot lowersdailyLeft. - MEDIUM (src/web/pages/boards.ts):
Sec-Fetch-Site: nonealso covers a link clicked in a mail client or chat app. A link can still spend an admin's budget with one click. Move the model call behind a POST with the same-origin check, or accept onlysame-origin, and have the page's form send the sentence that way.
The design is clean. The gate and validation are sound, and the tests cover the new behaviour. The how-to is in docs/features and the README is untouched. Box 2 is left because the budget is not enforced as claimed. The other three boxes are met.
A question to the data explorer's model now counts against the asker's daily ceiling, and only a POST from the page's own sentence box can ask the model. A ?ask= link is now read by the rules only.
Daily ceiling (high finding). A new table, one_shot_spend (migration 20261108170000-one-shot-spend.ts, pinned), records each question to the model. daySpent in src/core/account_ceiling.ts now adds it in, so dailyLeft shrinks after each question. oneShot in src/crew/one_shot.ts writes the question's cap before the model runs, so questions still running are counted too. When the run ends it sets the real cost. If the run throws, the cap stays as the cost, since the real cost isn't known. The subscription's spent_usd is still added to as before.
Links cannot spend the budget (medium finding). A GET to /boards/data?ask= is now rules-only, whatever Sec-Fetch-Site says. The sentence box posts to the new POST /boards/data (boardsAsk). That handler applies the same admin gate, then readForm's cross-origin check, and only then may a miss ask the model. A link clicked from a mail client, a chat or another site cannot reach the model. A model's answer is still drawn in place with the note, so the note cannot be added to a link by hand.
Tests:
test/crew-one-shot.test.tschecks that a question lowersdailyLeftby its cost.test/boards-model-fill.test.tschecks that a?ask=GET never calls the model with anySec-Fetch-Sitevalue or none.- It also checks that the page's form posts, that a POST from another origin gets a 403 before the model is asked, and that a non-admin's POST gets a 404.
docs/features/data-explorer-sentence.md is updated to match.
Checks run:
bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tsare clean.- The changed test files, the ceiling, schema, route and boards suites pass.
test/boards-guide.test.tsandtest/boards-page.test.tsfail two tests ("a source-list link lands on that source's default chart", "a ready question opens a working chart from its link") when run in one process with the ceiling, schema, route and other boards suites. They fail the same way on the base commit with my changes stashed, and both files pass when run alone. That clash between test files predates this change and is not addressed here.
To try it: sign in as a site admin with a granted account and the crew on. On /boards/data, type "how pricey were the robots" in the box and press Show; the chart's foot says the model filled the form. Opening /boards/data?ask=how+pricey+were+the+robots directly says "I could not read that" and spends nothing.
🤖 Generated with Claude Code
All earlier asks are met. (1) The how-to is docs/features/data-explorer-sentence.md and README is untouched. (2) oneShot has a fake-binary test covering off, no_account, the budget clamp, cost booking, the daily ceiling and a spent ceiling. (3) The model is now reachable only from POST /boards/data, behind the admin gate and readForm cross-origin check. A ?ask= GET is rules-only, and tests cover every Sec-Fetch-Site value. (4) The model=1 redirect is gone, so a link cannot claim the note. (5) The wording is shared in unreadWords. (6) The one_shot_spend migration is pinned and counted by daySpent, so the model spend counts toward the daily ceiling. Non-blocking: a reload of a model-filled page re-posts and asks again, within the six-a-minute limit. The doc says so. Non-blocking: the real runClaude path is untested apart from the fake binary.
A sentence
parseSentencecannot read now goes to the model, on both/boards/data?ask=and the terminal explorer's/box. The new tests, the existingboards-guideandtui-ask-datasuites,tsc,scripts/gate.tsanddesign-lintpass. I did not run the full suite, and the real model call (runClaude) is untested, since the tests stub the model.What changed
src/boards/model_fill.ts(new) —fillFromSentencetriesparseSentencefirst and costs nothing on a hit. Only a miss asks the model. The model must reply with one JSON object using only source, aggregate, column, by, series, from, to, filter and chart.validFillchecks that reply:- Every field must be known, and every source and column must exist in
SOURCES. - The aggregate, filter operator, chart, bucket and times must be valid. - Each value must survivereadParamsunchanged. - An unknown source or column, an extra field or a bad value is refused.runQuerythen judges the accepted query as it judges any link.src/crew/one_shot.ts(new) — one model turn with no tools, no shell and no MCP. It picks the asker's own subscription the way a conversation turn does, so the per-turn cap and daily ceiling apply. It spends at most $0.05 and books the cost to that account. I had to add this because the repo had no lightweight model call. It reusesrunClaudewith an empty MCP config.src/web/pages/boards.tsredirects with&model=1when the model filled the form, and the result card's foot says "The model filled this form from your sentence. Check it before you trust it."src/tui/screens/ask_data.tsshows the same note on the result screen. A refused or unreadable sentence now opens a short message screen instead of keeping the prompt open with an error. This is a small change in behaviour, because the model call is asynchronous.Sec-Fetch-Site: cross-sitenever reaches the model, so a link on another site cannot spend an admin's budget.test/boards-model-fill.test.tsand one added test intest/tui-ask-data.test.tscover the cases below. The model is stubbed in all of them.- A rules hit never calls the model. - A miss reaches it. - An answer naming an unknown source or column is refused. - The rate limit holds. - A cross-site request and a non-admin never reach the model. - The note appears on both web and terminal.
Not done
To try Sign in as a site admin and open
/boards/data?ask=how+pricey+were+the+robotson a forge with the crew on. In the terminal, open Data explorer, press/and type the same sentence.Closes #9569