Skip to content

feat(task-board): let report-generated tasks be deleted, and make it stick - #5720

Merged
pedrofrxncx merged 3 commits into
mainfrom
brussels
Aug 5, 2026
Merged

feat(task-board): let report-generated tasks be deleted, and make it stick#5720
pedrofrxncx merged 3 commits into
mainfrom
brussels

Conversation

@vibe-dex

@vibe-dex vibe-dex commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What is this contribution about?

TASK_BOARD_ITEM_DELETE refused any task with createdBy === "system", so report-generated findings (e.g. 36 Commerce Discovery cards on the deco-studio board) could never be cleared — while the card detail rendered a delete button anyway and swallowed the rejection, because the mutation had no onError and the dialog closed before the result arrived. This drops the guard (the edit guard in update.ts stays — the import legitimately owns title/description/priority) and makes the delete stick.

A reports card is dismissed, not dropped: one nullable column, task_board_items.dismissed_at. The row stays, leaves the board, and the import skips its external_key — which it has to, because the import dedups by key against open items only, so a deleted row matches nothing and the next scan re-creates the card.

Keeping the row is what makes the rest fall out for free:

  • No refund-by-delete hole to patch. task_quota_claims cascades from task_board_items, so hard-deleting a charged card would refund its period slot — delegate, delete, repeat for unlimited subsidized runs. A dismissed card never fires the cascade, so no FK drop and no change to billing-ledger semantics.
  • Restore brings back the same card, with its comments, activity, linked threads, PRs and quota claim — not a fresh one on the next import.
  • The import needs one query, not two: it already selects by external_key.

Dismissal keys on isReportsTask (created_by = "system"), not on "has an external_key" — the quota gate charges that same class of task and an imported item may omit its key, so keying on the key would leave keyless system cards hard-deletable and therefore refundable.

Also adds removeMany so a partly-failed bulk delete reports one summary instead of one toast per card, and corrects the Task Manager agent prompt, which called deletion merely irreversible without mentioning it now dismisses the finding.

How did you verify your code works?

410 tests pass, 0 fail against real Postgres 16 (bun test over apps/api/src/{tools/task-board,billing,storage} and task-board-import.integration.test.ts). bun run check and bun run lint (0 errors) are clean.

I inverted the test that encoded the old behavior — reports-task-guards.integration.test.ts asserted delete throws for a reports task — and added coverage for dismiss-on-delete, import skip + dismissed: N, restore-refreshes-the-same-card, org-scoping of dismissals, and empty-array-restores-nothing.

The quota invariant test (quota-refund.integration.test.ts) earns its keep: it caught an earlier version of this change that keyed dismissal on external_key, which left keyless system cards hard-deletable and the refund hole open. It charges a created_by = "system" card with no key, deletes it, and asserts the claim count is unchanged and the claim still held.

Migration 163 was confirmed to roll back and re-apply cleanly via a scripted migrateDown/migrateToLatest round-trip, checking the column's absence and presence on each side.

Screenshots/Demonstration

None — the UI change is behavioral (an error toast that previously never appeared). Reproduce per "How to Test" below.

How to Test

  1. Run migrations: bun run --cwd=apps/api migrate (adds task_board_items.dismissed_at).
  2. Seed a report-style card — POST /api/:org/internal/task-board/import with VAULT_SERVICE_TOKEN bearer, body {"items":[{"title":"finding","externalKey":"diag:example.com:lcp"}],"source":{"url":"example.com","run_id":"r1"}}.
  3. Open /$org/board, open the card, click the trash icon — expected: the card leaves the board (before this PR it silently stayed).
  4. Re-send the same import with a new run_idexpected: {"created":0,"updated":0,"delegated":0,"dismissed":1} and no new card.
  5. Call TASK_BOARD_DISMISSED_RESTORE with {"externalKeys":["diag:example.com:lcp"]}expected: the same card is back on the board immediately, comments and history intact; the next import reports updated: 1, not created: 1.
  6. Multi-select several cards and use "Delete selected" — expected: all removed, and on partial failure a single summary toast rather than one per card.

Migration Notes

One Kysely migration, reversible (bun run --cwd=apps/api migrate):

  • 163 adds the nullable task_board_items.dismissed_at. down() drops it, which un-dismisses every card back onto the board — the recoverable direction (a finding reappears; nothing is lost).

list() filters dismissed_at is null; getById deliberately does not, so a run reaction or a restore can still reach the row by id.

Behavioral note for the reports engine: the import response gains an optional dismissed field, and previously-imported findings whose cards get dismissed will stop being re-pushed. Dismissal is currently permanent — a genuinely regressing finding won't re-surface until restored. Making it expire is now a dismissed_at age comparison in the import filter, no schema change needed; picking the window (30d? 90d?) is a product call, so it's deliberately not in this PR.

Review Checklist

  • PR title is clear and descriptive
  • Changes are tested and working
  • Documentation is updated (if needed)
  • No breaking changes — the import's dismissed field is additive, and the quota ledger's semantics are unchanged (a dismissed card never triggers the claim cascade).

…stick

TASK_BOARD_ITEM_DELETE refused any task with createdBy === "system", so the
36 Commerce Discovery findings on a board could never be cleared. The card
detail rendered a delete button anyway and swallowed the rejection: the
mutation had no onError, the board imports no toast, and the dialog closed
before the result arrived — a refused delete read as a successful one.

Drop the isReportsTask guard from delete. The edit guard in update.ts stays:
the import legitimately owns title/description/priority.

Lifting the guard alone would be a half-fix. The import dedups findings by
external_key against OPEN items only, so a deleted row matches nothing and
the next scan re-creates the card. Deleting all 36 and re-running a scan
would bring all 36 back. Migration 163 adds task_board_dismissed_findings;
delete tombstones the finding's key inside the existing transaction (outside
it, a tombstone could survive a rollback and suppress a finding whose card
still exists). The import skips those keys and reports `dismissed: N`, since
a silent skip reads as "imported everything" when it didn't.
TASK_BOARD_DISMISSED_LIST / _RESTORE undo it.

Migration 164 closes a billing bypass the guard removal opens.
task_quota_claims cascaded from task_board_items — unreachable while reports
tasks (the only ones the quota charges) couldn't be deleted. Once they can
be: delegate to the Super Agent to charge a period slot, delete the card,
slot refunded, repeat for unlimited subsidized runs. Dropping the FK makes
claims an append-only ledger; a refund already has one writer and one
meaning (state = 'released').

Web: add onError to the delete mutation, and removeMany so a partly-failed
bulk delete reports one summary instead of one toast per card.

Also correct the Task Manager agent prompt, which called deletion merely
irreversible without mentioning it now dismisses the finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the claude PR authored by a coding agent label Aug 5, 2026
@vibe-dex
vibe-dex requested a review from pedrofrxncx August 5, 2026 01:59
…tombstone table

Replaces the task_board_dismissed_findings table and the task_quota_claims FK
drop with one nullable column, task_board_items.dismissed_at.

The tombstone's whole payload — (org, external_key, who, when) — already lived
on the row it shadowed. Keeping the row instead of deleting it means:

- migration 164 is unnecessary. The refund-by-delete hole only existed because
  task_quota_claims cascades from a hard-deleted card; a dismissed card never
  fires the cascade, so the charge survives without changing billing-ledger
  semantics or shipping a down() that deletes ledger rows.
- restoring brings back the SAME card, with its comments, activity, linked
  threads, PRs and quota claim. Before, a restore re-created a fresh card on the
  next import and lost all of it.
- the import needs one query, not two: it already selects by external_key.

Dismissal keys on isReportsTask (created_by = 'system'), not on external_key —
the quota gate charges that same class, and an imported item may omit its key,
so keying on the key would leave keyless system cards hard-deletable and
therefore refundable. The quota invariant test covers exactly that case.

Also: getUserId(ctx) ?? "system" instead of a non-null assertion, and the two
new i18n keys follow the neighboring taskBoard.taskBoard.* prefix.
Comment on lines +346 to +348
// Deleting a reports card dismisses its finding. Without this the delete
// would silently undo itself: a deleted row matches no open item, so the
// next run would create the card again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +382 to +383
// The card is dismissed, not dropped — its comments, activity and quota
// claim stay with it, so restoring brings back the same card.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +43 to +48
* A key the org has DISMISSED (deleted its card — the row stays with
* `dismissed_at` set, migration 163) is skipped entirely and counted in the
* response's `dismissed`. That's what makes deleting a reports-pushed card
* stick: without it, the next run re-creates the card, since a card that's gone
* matches no open item.
*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +165 to +169
// Existing cards for the batch's external keys. An OPEN one gets
// refreshed instead of duplicated; done ones don't match (a regression is
// a new card); a DISMISSED one suppresses the finding entirely — that's
// what makes deleting a reports card stick, since a delete the next scan
// undoes isn't a delete.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +188 to +189
// Dismissal wins over an open card with the same key: a finding can
// be closed, regress into a fresh card, then be dismissed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +288 to +289
// Suppression must be visible: a silent skip reads as "imported
// everything" when it didn't.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment thread apps/api/src/storage/task-board.ts Outdated
Comment on lines +9 to +10
// Pure predicate — the single source of truth for "this card is a report's
// finding", shared with the quota gate that charges the same class of task.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment thread apps/api/src/storage/task-board.ts Outdated
Comment on lines +158 to +159
// Dismissed findings are off the board — see `delete`. `getById` still
// resolves them, so a run reaction or a restore can reach the row.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment thread apps/api/src/tools/task-board/delete.ts Outdated
Comment on lines +31 to +33
// Reports-pushed tasks are deletable like any other — storage dismisses
// theirs instead of dropping the row, so the next import skips the finding
// rather than re-creating the card. TASK_BOARD_DISMISSED_RESTORE undoes it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +224 to +228
// Deleting a card must not be a backdoor refund. `task_quota_claims` cascades
// from `task_board_items`, so a hard delete would turn "delete the card" into
// a refund: delegate, delete, and the period slot frees up — repeat for
// unlimited subsidized runs. A reports card is dismissed, not dropped, so the
// cascade never fires and the charge outlives the card.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +231 to +232
// The period the charge landed in, read rather than guessed — it depends on
// the org's billing status.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +3 to +6
* content immutability (UPDATE), the delete-and-dismiss path (DELETE writes a
* dismissal tombstone so the finding doesn't return on the next import), and
* the pre-write paywall on the delegation flip — everything that fires BEFORE
* any thread/dispatch machinery, so the ctx stub stays small.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +161 to +164
// Deleting a reports task used to be refused outright. It's allowed now —
// the board's delete button offered it and silently failed. What replaces the
// refusal is the dismissal tombstone: the finding stays off the board instead
// of the card coming back on the next import.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +130 to +132
// A rejected delete used to land nowhere: the dialog had already closed, so
// a refused deletion read as a successful one. Surface it and refetch, so
// the card the server kept comes back into the list.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +143 to +145
// Bulk delete from the selection bar. Calls the tool directly rather than
// fanning out through `remove` so a partly-failed batch reports one summary
// instead of one toast per card.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +43 to +44
* A DISMISSED key (`dismissed_at` set) is skipped and counted in `dismissed`.
*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +161 to +162
// Open cards get refreshed, done ones don't match, dismissed ones
// suppress the finding.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

Comment on lines +224 to +225
// `task_quota_claims` cascades from `task_board_items`, so a hard delete
// would be a backdoor refund: delegate, delete, repeat.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment — fix the code, don't explain it away (CLAUDE.md).

@pedrofrxncx
pedrofrxncx enabled auto-merge (squash) August 5, 2026 02:22
@pedrofrxncx
pedrofrxncx merged commit cdfae48 into main Aug 5, 2026
27 of 29 checks passed
@pedrofrxncx
pedrofrxncx deleted the brussels branch August 5, 2026 02:33
decocms Bot pushed a commit that referenced this pull request Aug 5, 2026
PR: #5720 feat(task-board): let report-generated tasks be deleted, and make it stick
Bump type: minor

- decocms (apps/api/package.json): 4.178.0 -> 4.179.0
- @decocms/native (apps/native/package.json): 4.178.0 -> 4.179.0
- @decocms/shared (packages/shared/package.json): 0.23.0 -> 0.24.0

Deploy-Scope: both
pedrofrxncx added a commit that referenced this pull request Aug 5, 2026
TaskBoardStorage.delete() returned void unconditionally, so a
nonexistent or cross-org id fell through as a silent no-op — the
tool still returned {success: true} and broadcast a removal event
for a card that was never touched. This is the exact bug class
#5720 just fixed for the missing-guard case (delete swallowed a
rejection and read as success), reintroduced for the not-found case.

delete() now returns whether the id existed in this org; the tool
throws "Task board item not found" and skips the broadcast when
it didn't.

Regression test: reports-task-guards.integration.test.ts asserts
TASK_BOARD_ITEM_DELETE rejects an unknown id instead of resolving.

Verified: bun run fmt, apps/api tsc --noEmit, oxlint on the 3
touched files. The integration test needs real Postgres (unavailable
here) — CI runs it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude PR authored by a coding agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants