Skip to content

fix(tools): refuse checklist item ids that aren't on the task - #263

Open
aa-blinov wants to merge 1 commit into
bitrix24:mainfrom
aa-blinov:fix/checklist-item-membership
Open

aa-blinov wants to merge 1 commit into
bitrix24:mainfrom
aa-blinov:fix/checklist-item-membership

Conversation

@aa-blinov

Copy link
Copy Markdown

Summary

task.checklistitem.{complete,renew,delete} resolve the item by id alone — the taskId they take positionally is decorative. Passing task A with an item id from task B returns success and acts on task B. All three verbs now pre-flight the task's checklist and refuse ids that aren't on it (ITEM_NOT_ON_TASK).

Type of change

  • feat
  • fix
  • docs
  • chore
  • test
  • refactor
  • ci

Linked issue

Checklist

  • PR title follows Conventional Commits
  • pnpm lint passes
  • pnpm typecheck passes
  • pnpm test passes
  • New or changed code has tests
  • Public-facing changes reflected in docs/ and skills/ (adding-tools.md gains a "membership pre-flight" note next to the delete registry — it's a pattern worth copying wherever a REST method takes a parent id it doesn't enforce)
  • No unrelated changes
  • No secrets in code, tests, or CI logs

Screenshots / logs

Live portal, before. Item 1341 belongs to task 4191; the call names task 4193:

b24_task_checklist_item_add { taskId: 4191, title: "пункт чужой задачи" }
→ {"added":true,"taskId":4191,"itemId":1347}

b24_task_checklist_item_complete { taskId: 4193, itemId: 1347 }
→ {"completed":true,"taskId":4193,"itemId":1347}      ← success, wrong task

b24_task_checklist_item_list { taskId: 4191 }
→ items[0] = { id: 1347, isComplete: true, toggledBy: 9, ... }   ← it really happened

A nonexistent id behaves the same way: complete { taskId: 4193, itemId: 999999 } → {"completed": true}.

After:

b24_task_checklist_item_complete { taskId: 4193, itemId: 1347 }
→ ERR  Checklist item 1347 is not on task 4193. Bitrix24 would answer this call
       with success anyway — and act on whichever task those ids really belong to
       — so `b24_task_checklist_item_complete` refuses it. List the task's
       checklist with `b24_task_checklist_item_list` and use ids from that response.

b24_task_checklist_item_list { taskId: 4191 }
→ items[0] = { id: 1347, isComplete: false, toggledBy: null }     ← untouched

The normal path is unchanged — completing, renewing and deleting real items on their own task all still work, verified on the same portal.

Notes for reviewers

The trade-off is one extra getlist per complete / renew call. I think it's clearly worth it — the alternative is a tool that writes to the wrong task and reports success — but it is a real cost and you may want it configurable, so here's the reasoning to push back on:

  • The delete path already made this read for heading detection, so it pays nothing new. I refactored so the heading check and the membership check share one read rather than two.
  • Batch mode makes exactly one pre-flight for the whole batch, matching the pattern the skill doc already prescribes for cascade checks.
  • The check stands down when the pre-flight returns a non-list, so a Bitrix24 response-shape drift degrades to today's behaviour instead of blocking every call. Same fail-soft convention as assertNotHeading.
  • confirmDeleteHeading: true waives the cascade check only. I deliberately did not let it waive membership: "I agree to wipe this checklist" is not "I agree to wipe a checklist on some other task".

If you'd rather keep complete / renew at zero extra round-trips, the alternative I considered was validating only when the id looks suspicious — but there is no such signal in the input, so it would amount to not validating.

New error code ITEM_NOT_ON_TASK is registered in Bitrix24ErrorCode with the registry-completeness test updated, per the code-registry procedure in the skill.

`task.checklistitem.{complete,renew,delete}` take positional
`[taskId, itemId]` but resolve the item by id ALONE — the task id on the
wire is decorative. Verified against a live portal (2026-09-03):

  b24_task_checklist_item_complete { taskId: 4193, itemId: 1341 }

where item 1341 belongs to task 4191, answered
`{"completed": true, "taskId": 4193}` — and a listing of task 4191 then
showed that item ticked with `toggledBy` set. A nonexistent id is
acknowledged just as cheerfully: `{"completed": true, "itemId": 999999}`.

So an agent that mixes up two ids silently edits a different task's
checklist and is told it succeeded. That is the worst failure shape we
can ship: a write to the wrong place, reported as a write to the right
one.

All three verbs, single and batch, now read the task's checklist once per
call and refuse ids that aren't on it with the new `ITEM_NOT_ON_TASK`
code, naming the strays and pointing at `b24_task_checklist_item_list`.
The delete path already made that read for heading detection, so the
cost there is unchanged; complete / renew pay one extra round-trip, which
is the price of `completed: true` meaning what it says.

Fail-soft convention preserved: if the pre-flight returns something that
isn't a list, the check stands down and the real call's own error is the
signal. `confirmDeleteHeading: true` still waives the cascade check — it
does not waive membership.

The heading pre-flight and the membership check now share a single
`getlist`, so an unconfirmed heading delete still makes exactly one
read. Tests: cross-task refusal (single + batch), nonexistent id,
stand-down when the pre-flight shape drifts, and the existing heading
gate kept green.
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