Repository navigation
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
Conversation
…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
…over the decision-card service Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ashboard human; card GET exposes the decision 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
…wn batch, one push per batch, three per caller per day Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…t (cards only; unknown, disabled and voice bots are refused) Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ent, so parallel notifies cannot exceed it 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
…quat on or push another caller's batch 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
…locally on this commit, 10457/0) Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
kh0pper
force-pushed
the
feat/board-decision-cards
branch
from
October 21, 2026 14:30
b69b481 to
593e9ba
Compare
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
…ailed card GET drops the pending drawer scroll Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.recommended, and it must be first; every option needs aconsequence.snooze_daysis an integer 0–90.expiresis an ISO date, default today+7. At most 5 open cards per batch.fingerprint, or while that caller's snoozed answer to it is still in force.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 returns409 already_decidedand never creates a second follow-up. Expired, closed, archived and locked cards return 409 before anything is written.board_create_itemcan now assign a new card to an enabled bot (assigned_bot, cards only). Unknown, disabled and voice bots are refused with400 bad_bot. A tracker item is refused with400 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 byPOST /card/:idandPOST /card/:id/execute.Security review findings and how they are answered
board_notify_decisionslet any board-MCP caller push any batch).decision.asked_by, from the resolved, signature-verified board actor).batch_key = <actor key>|<batch>. A caller can only ever find and notify its own cards. Unsigned callers share the keybot:unattributed.already_notified).429 push_limit).UPDATE … WHERE … AND (SELECT COUNT(DISTINCT notify_stamp) … today, this caller) < 3 RETURNING id.fingerprint_key).Known limits (deliberate, noted for follow-up)
locked.askDecisioncalls can both get past the fingerprint de-dupe (check-then-create).Tests
tests/board-decision-service.test.js,tests/board-bot-lookup.test.js,tests/board-decision-ui.test.js.tests/board-mcp.test.js,tests/board-card-api.test.js.check-public-hygiene: OK.check-port-allocation: OK.https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp