Skip to content

feat(board): decision cards — a bot asks a multiple-choice question on the board, the owner answers with one tap, and the answer becomes a gated card - #535

Open
kh0pper wants to merge 19 commits into
mainfrom
feat/board-decision-cards
Open

kh0pper wants to merge 19 commits into
mainfrom
feat/board-decision-cards

Conversation

@kh0pper

@kh0pper kh0pper commented Oct 21, 2026

Copy link
Copy Markdown
Owner

What this adds

A decision card is an ordinary Bot Board card that carries a question and 2–4 options. A bot asks it over the board MCP server. The owner answers it in the card drawer with one tap, or with "Other…" and a short note. The answer is recorded, and the option can create a follow-up card. That card is always gated, and goes to the bot named on the option or, if that bot isn't enabled, to the Unassigned lane with an owner label.

  • board_ask_decision (MCP): validates the question and options.
    • 2–4 options; exactly one recommended, and it must be first; every option needs a consequence.
    • Caps: question ≤300, label ≤120, consequence ≤240, fingerprint ≤200, batch ≤100. snooze_days is an integer 0–90. expires is an ISO date, default today+7. At most 5 open cards per batch.
    • Unknown option keys are rejected.
    • De-dupe, per asker: no new card while the same caller has an open card with the same fingerprint, or while that caller's snoozed answer to it is still in force.
    • It never pushes.
  • board_notify_decisions (MCP): one grouped push for the caller's open decision cards in a batch ("N decisions waiting"). A batch is pushed once while its notified cards are still open, and each caller gets at most 3 pushes per UTC day. Overdue cards are expired first. If the operator has turned that notification type off, nothing is claimed and no daily slot is used.
  • POST /dashboard/bot-board-api/card/:id/decision {index, other_text?}: the only way to answer. It records the dashboard (human) actor. Answering a second time returns 409 already_decided and never creates a second follow-up. Expired, closed, archived and locked cards return 409 before anything is written.
  • Follow-up cards inherit the decision card's tags unchanged (the asker cannot set them), so a consumer that scopes by tag can recognise them.
  • Card drawer UI: shows the question, the options in order with the recommended one badged, a consequence line under each, "Other…", and a read-only view once decided or expired. Bot text is inserted as text only. Strings are in en and es.
  • board_create_item can now assign a new card to an enabled bot (assigned_bot, cards only). Unknown, disabled and voice bots are refused with 400 bad_bot. A tracker item is refused with 400 bad_field. Assigning starts nothing, and the card is gated by default. Follow-up cards from a decision are always gated.
  • servers/gateway/board/bot-lookup.js: one shared "enabled, non-voice bot" check, now also used by POST /card/:id and POST /card/:id/execute.
  • Docs: a "Decision cards" section in the Bot Builder guide (en + es).

Security review findings and how they are answered

  1. Missing authorization on the push trigger (board_notify_decisions let any board-MCP caller push any batch).
    • Each decision card records who asked it (decision.asked_by, from the resolved, signature-verified board actor).
    • Batches are namespaced by asker: batch_key = <actor key>|<batch>. A caller can only ever find and notify its own cards. Unsigned callers share the key bot:unattributed.
    • A batch is pushed at most once (already_notified).
    • Each caller gets at most 3 decision pushes per UTC day (429 push_limit).
  2. Rate-limit race (the count and the insert were separate steps).
    • The cap check and the claim are now one SQLite statement: UPDATE … WHERE … AND (SELECT COUNT(DISTINCT notify_stamp) … today, this caller) < 3 RETURNING id.
    • Pushes are counted from the cards' own stamps, so no schema change was needed.
    • If the push fails, the claim and its stamp are released, which gives the capacity back.
    • Test: 6 parallel notifies by one caller across 6 batches give exactly 3 pushes.
  3. Batch hijack / denial (one bot could add a card to another bot's batch and make its notify fail).
    • Closed by the per-asker namespace in (1). Two callers using the same batch name get separate batches, caps and pushes.
    • The whole-branch review found the same problem one level down: fingerprint de-dupe was global, so one caller could pre-empt another's question. Fingerprints are now per asker too (fingerprint_key).
    • Tests: two bots both use batch "x"; two bots both use the same fingerprint.

Known limits (deliberate, noted for follow-up)

  • Unsigned bots, all session callers and all human callers each share one namespace and one daily cap.
  • A lock can still be taken in the moment between the lock check and the final move to done, because the lock lives in another database. The decision is still recorded, and the call reports locked.
  • Concurrent askDecision calls can both get past the fingerprint de-dupe (check-then-create).
  • The follow-up link reloads the board onto the new card but does not open its drawer.
  • The drawer block is covered by DOM-stub tests only. Before merge: one manual run in a real browser (open a decision card, answer it, answer again from a second tab to see the 409, try Other…, follow the link).

Tests

  • New: tests/board-decision-service.test.js, tests/board-bot-lookup.test.js, tests/board-decision-ui.test.js.
  • Extended: tests/board-mcp.test.js, tests/board-card-api.test.js.
  • Full suite: 10492 tests, 10457 pass / 0 fail / 35 skipped on the final head (cb52ce1); scratch gateway start-up check clean.
  • check-public-hygiene: OK. check-port-allocation: OK.

https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

…ouped push per batch

A decision card offers 2-4 options (one recommended, first, each with a
consequence). Answering records the choice with a conditional write, so a
double tap makes one decision and at most one gated follow-up card.
Re-asks dedupe by fingerprint and honour snooze_days; a batch holds at most
5 open decisions; notifyBatch claims each card before pushing. Adds
bot-lookup.js isEnabledBot (enabled pi_bot_defs row, not the voice bot).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
… a batch push in one statement

Deciding a card a bot is working now returns 409 locked before chosen, the
follow-up card or the mutation are written. Expiry only tolerates a lock
error from the move. notifyBatch claims the whole set with one UPDATE ...
RETURNING and releases the claims if the push throws. Optional strings are
measured after trimming.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…tion keys, and a roundtrip test pins what survives into the stored card

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ions, answers it with one click or Other…, and turns read-only once decided or expired

The block (decision-client.js, spliced into the drawer like drawer.js) renders
from GET /card/:id's decision key: question, context as plain text, options in
order with the recommended one badged, a consequence under each, and Other…
with a textarea. A choice posts to /card/:id/decision and then shows the chosen
answer and a link to the follow-up card; a 409 re-reads the card and shows the
server's state. History labels decision_ask, decision and decision_expire.
en+es strings, and a Decision cards section in the Bot Builder guide.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ecision queries tolerate malformed rows; notify tool description states the push rules

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…led notifications and expiry; follow-up failures are still audited; decision errors are translated; docs state the per-caller rules

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
@kh0pper
kh0pper force-pushed the feat/board-decision-cards branch from b69b481 to 593e9ba Compare October 21, 2026 14:30
The card edit route is now compare-and-set on status: the drawer sends the
status it loaded as expected_status, and a card that moved since (a decision
answer closed it, another tab or a bot moved it) is refused 409 stale with
nothing written and no move recorded. A status change without
expected_status is refused the same way, so an old page cannot move a card
blind; edits that leave status alone need no precondition.

The drawer re-reads the card after a decision answer (and after a refused
one), keeping typed text, so the form shows the closed status. Save waits
for the card GET, and a 409 stale reloads the drawer with a translated
message.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
When an answer creates a follow-up card, the live watcher reloads the board
about 2.5 s later, which closed the drawer in every open tab. The board is
server-rendered, and patching columns, lane tabs, the Unassigned lane, list
view and filters in place would duplicate the renderer, so the drawer is
kept across the reload instead: the live reload remembers the open card and
the drawer's scroll in sessionStorage, and the next load of the same board
reopens it (one-shot, within 30 s).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
It used to reload the page onto a #card= hash, which only scrolled to and
highlighted the card. It now opens that card's drawer, as clicking the card
would; a card that is not on this board yet (it was created a moment ago)
is loaded by id. The href stays for opening in a new tab.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
The router mounts at the app root, before the dashboard router's
csrfMiddleware, so its POST routes had no CSRF check. It now runs
csrfMiddleware on its own prefix after dashboardAuth. Every in-repo caller
(the board panel, the Bot Builder activity and advanced tabs) is a
dashboard page whose layout fetch wrapper adds X-Crow-Csrf; requests
without a session cookie and peer-signed calls pass, as on every other
dashboard route.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant