# stapel-tasks 0.4.0

Generic tasks and kanban boards: Board/Column/Task/ChecklistItem/TaskComment, a REST surface, a full outbox event surface, and custom fields via stapel-attributes. Usable standalone (a team runs a board by hand) or as the substrate an external orchestrator projects onto through opaque origin_* handles, a MOVE_POLICY authorization seam, and comm Functions (tasks.get/list_board/create/move/comment).

Contract: axes 3 · surface 28 · extension points 5 · operations 22 · error codes 57 · flows 3.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- MOVE_POLICY [enum, default "stapel_tasks.policy.AllowAllMovePolicy"] — Who/what may move a card between columns
  Decides whether a card may move from one column to another (drag-and-drop / tasks.move): allow / deny(reason_key) / defer (accepted but applied later by an external orchestrator). Default allows any move but honours a per-board transitions whitelist (conf.py, MODULE.md Extension point 2).
- SCOPE_PROVIDER [enum, default "stapel_tasks.scope.DefaultScopeProvider"] — Multi-tenant scoping and permissions
  Resolves the opaque workspace_id from a request, filters querysets by it, and answers viewer/member/admin permission checks. Default is a single global scope that allows everything; a stapel-workspaces-aware host swaps in a real provider — this module never imports stapel-workspaces (conf.py, MODULE.md Extension point 1).
- STORE_UNKNOWN_FEATURES [bool, default true] — Keep unknown custom fields when typed validation is off
  Controls what happens to custom card fields when stapel-attributes isn't installed: keep the raw submitted data, or discard it.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- add_checklist_item — stapel_tasks.services.add_checklist_item
  instead of: ChecklistItem.objects.create()
  Append a checklist step to a card, defaulting order to the current item count. Use this instead of ChecklistItem.objects.create() by hand to keep ordering contiguous.
- add_column — stapel_tasks.services.add_column
  Append (or insert at a given order) a single column on an existing board, defaulting order to the current count. Use this instead of Column.objects.create() by hand so the ordering stays contiguous with what reorder_columns produces.
- add_comment — stapel_tasks.services.add_comment
  instead of: TaskComment.objects.create()
  Add a comment and emit task.comment_added — the human-to-orchestrator reply channel for a managed card. Call this rather than TaskComment.objects.create() directly whenever anything (a pipeline orchestrator) subscribes to the comment event.
- archive_task — stapel_tasks.services.archive_task
  instead of: Task.delete()
  Soft-delete a card (is_archived + archived_at) and emit task.archived, idempotently. Use this instead of task.delete() — every positioning helper (create_task/move_task) already filters is_archived=False, so a hard-deleted-outside-this-call card breaks referential integrity for comments/checklist items that still point at it, where an archived one does not.
- board_cards — stapel_tasks.services.board_cards
  Read a whole board in one query: columns in display order plus every non-archived card grouped by column key and sorted by position, capped and flagged when the cap cut the answer short. Reach for this instead of paginating boards/{id}/tasks and re-sorting client-side — that listing is a -created_at feed, and this is the order a card actually sits in.
- create_board — stapel_tasks.services.create_board
  Create a board with its starting columns (an explicit list or a named preset) and a validated custom-field schema in one call. Reach for this instead of creating a Board row and its Columns by hand — feature_defs is validated through the attributes seam before anything is written, and an unknown preset raises KeyError up front rather than leaving a columnless board.
- create_task — stapel_tasks.services.create_task
  instead of: Task.objects.create()
  Create a card: validates/normalizes custom-field values, appends it to the end of its column with a fresh fractional position, and emits task.created (plus task.completed if it lands straight in a DONE column) in one mutate_and_emit() unit. This is the creation path — Task.objects.create() skips validation, positioning and the event entirely.
- delete_comment — stapel_tasks.services.delete_comment
  Soft-delete a comment (blanks its body, no event) — deletion is intentionally not a domain fact anyone subscribes to. Use this instead of comment.delete() so readers see is_deleted rather than the row simply vanishing out from under a still-referencing thread.
- get_board_presets — stapel_tasks.presets.get_board_presets
  The effective preset map after merging built-ins, the STAPEL_TASKS['BOARD_PRESETS'] setting and runtime registrations. Call this instead of reading BUILTIN_PRESETS or the setting separately when rendering a 'choose a board preset' UI, so the list shown matches exactly what create_board(preset=...) will accept.
- get_preset_columns — stapel_tasks.presets.get_preset_columns
  Resolve one preset's column list without creating a board (raises KeyError for an unknown key). Use this to preview a preset's columns instead of calling create_board and discarding the result just to inspect its shape.
- move_task — stapel_tasks.services.move_task
  The one function that runs MOVE_POLICY, computes the target fractional position (rebalancing the column if precision is exhausted) and emits task.moved (+ task.completed/uncompletes on DONE-column transitions) as one commit. This is the only sanctioned way to change a card's column or position — assigning task.column directly skips the move-policy check (deny/defer) entirely.
- normalize_features — stapel_tasks.features.normalize_features
  instead of: stapel_attributes.normalize_to_dao
  Turn a submitted custom-field DTO into the DAO stored on Task.features (display metadata injected), falling back to a raw pass-through or drop when attributes is absent per STORE_UNKNOWN_FEATURES. Call this rather than stapel-attributes' normalizer directly so a card's stored shape matches what the built-in views/services produce even on a host without the attributes engine installed.
- position_between — stapel_tasks.positioning.position_between
  Compute a fractional index that sorts strictly between two neighbouring positions (either may be None at a column edge). Reach for this instead of designing your own fractional/lexicographic ordering scheme for any other drag-and-drop-orderable list in your product — it is the exact midpoint algorithm stapel-tasks itself uses for card positions, edge cases included.
- reorder_columns — stapel_tasks.services.reorder_columns
  Reorder a board's columns from a list of keys in one bulk_update, any column not listed keeping trailing order. Call this instead of writing per-column .save(update_fields=['order']) loops for a drag-and-drop column-reorder UI.
- reset_presets — stapel_tasks.presets.reset_presets
  Drop every runtime-registered preset. Call this in test teardown/fixtures so register_board_preset calls from one test never leak into the next — it does not touch the STAPEL_TASKS['BOARD_PRESETS'] setting, only the runtime layer.
- set_assignees — stapel_tasks.services.set_assignees
  instead of: Task.assignees.set()
  Replace a card's assignee set, emitting one task.assigned event per user added/removed rather than a single batch change. Call this instead of task.assignees.set(...) directly whenever anything downstream (notifications, an orchestrator) needs per-user assignment events.
- set_board_feature_defs — stapel_tasks.services.set_board_feature_defs
  Replace a board's custom-field schema, validated via the attributes seam before the write reaches Board.feature_defs. Use this instead of assigning the field directly and saving — a structurally broken schema must never reach existing cards unvalidated.
- set_checklist_item_state — stapel_tasks.services.set_checklist_item_state
  Set a checklist step's state and emit task.checklist_item_changed — the QA channel a projector watches for a FAILED step (a real state, not a missing DONE). Reach for this instead of item.save() directly whenever anything downstream needs to react to a state change; it also rejects any value outside pending/done/failed.
- update_task — stapel_tasks.services.update_task
  Patch a card's scalar fields and/or custom-field values, emitting task.updated with the precise changed-field list, and touching only fields that actually differ. Use this instead of task.save(update_fields=...) by hand so the write and the event commit as one unit and changed_fields is accurate rather than guessed.
- upsert_task_by_origin — stapel_tasks.services.upsert_task_by_origin
  instead of: Task.objects.get_or_create()
  Idempotent create-or-update by (board, origin_type, origin_ref) — the single entry point an external orchestrator should call to project a pipeline state into a card, race-safe under concurrent projections (the create-race loser falls back to an update instead of raising IntegrityError). Reach for this instead of a manual get_or_create: Django's own get_or_create does not know the uniqueness constraint is conditional (origin_ref must be non-empty) nor run create_task's validation/positioning on the create branch.
- validate_feature_defs — stapel_tasks.features.validate_feature_defs
  instead of: stapel_attributes.validate_configs_structured
  Validate a board's custom-field *schema itself* (not a value) before it is saved to Board.feature_defs — call this on any path that edits the schema outside set_board_feature_defs, so a structurally broken schema never reaches cards.
- validate_features — stapel_tasks.features.validate_features
  instead of: stapel_attributes.validate_dto
  Validate a submitted custom-field DTO against a board's feature_defs schema (a no-op when the board declares none or attributes is absent), raising FeatureValidationError with one message per bad field. Call this before persisting card custom-field values from any write path other than the built-in serializers/services — calling stapel-attributes directly skips the 'no schema declared' / 'attributes not installed' no-op guard this seam exists for.
### predicate
- attributes_available — stapel_tasks.features.attributes_available
  Whether stapel-attributes is importable right now — the one place that decides whether the custom-field seam runs real validation or falls back to the STORE_UNKNOWN_FEATURES pass-through/drop. Check this before assuming validate_features/normalize_features will actually enforce a schema in this environment.
- needs_rebalance — stapel_tasks.positioning.needs_rebalance
  True when no representable position exists between two interior neighbours at the field's 20-decimal-place precision — the repeated-midpoint exhaustion case. Call this before trusting a plain position_between() result on an interior insert; skipping it is how positions silently collapse onto a neighbour after enough drags into the same gap.
### factory
- get_move_policy — stapel_tasks.policy.get_move_policy
  Resolve the configured MovePolicy instance. Call this instead of importing a specific policy class directly whenever custom code needs to run the same allow/deny/defer check the built-in tasks.move Function and TaskMoveView use before applying a move.
- get_scope_provider — stapel_tasks.scope.get_scope_provider
  Resolve the configured ScopeProvider instance. Call this instead of importing a specific provider class directly whenever custom code (a management command, a non-DRF view) needs to resolve/filter by workspace_id or run the same permission check the built-in views use.
- rebalanced_positions — stapel_tasks.positioning.rebalanced_positions
  Evenly spaced integer positions for a full-column renumber — the rare O(n) fallback once needs_rebalance signals precision exhaustion. Use this (paired with needs_rebalance) instead of inventing your own renumbering step for any other fractional-position list you build on the same scheme.
- register_board_preset — stapel_tasks.presets.register_board_preset
  Register or override a board-shape preset at runtime (equivalent to putting the same key in STAPEL_TASKS['BOARD_PRESETS']) — the fork-free way to add or replace a preset a host's boards can be created with. Pass factory=None to remove a built-in.

## Extension points — what a product replaces, fork-free
- BOARD_PRESETS [merge_registry]
  Open registry (STAPEL_TASKS['BOARD_PRESETS'] + register_board_preset(key, factory)) merged over the built-in 'simple' preset; None removes a built-in. Guarded by E005/E006 (MODULE.md Extension point 3).
- MOVE_POLICY [dotted_path]
  REPLACE seam: a MovePolicy subclass returning allow()/deny(reason_key)/defer() (policy.py). Guarded by E003/E004 (MODULE.md Extension point 2).
- SCOPE_PROVIDER [dotted_path]
  REPLACE seam: a ScopeProvider subclass with resolve()/filter()/can() (scope.py). Guarded by system checks E001/E002 (MODULE.md Extension point 1).
- custom_field_seam [attribute_bridge]
  Board.feature_defs is a stapel-attributes FeatureDef list; Task.features holds the normalized DAO via services.create_task/update_task -> features.validate_features/normalize_features. Add vertical field types with attributes' own register_feature_type (MODULE.md Extension point 4).
- serializer_seams [class_override]
  Every APIView mixes in SerializerSeamMixin (request_serializer_class/response_serializer_class + get_* methods); the DTOs in dto.py are the API models, never ORM instances (views.py:60-68, MODULE.md Extension point 5).

## Fits with — fleet dependencies
- stapel-attributes (optional) — soft integration for typed custom-field validation on cards; module runs without it (feature seam degrades to a pass-through governed by STORE_UNKNOWN_FEATURES) (pyproject.toml optional-dependencies 'attributes' extra)
- stapel-core (required) — comm bus (task.* emits, tasks.* Functions, user.deleted consume for GDPR anonymization) (pyproject.toml dependency)

## HTTP operations (22) — call by operationId, never by a typed path
Paths are relative to `/tasks/api/v1/`.
### Tasks
- GET /boards/{board_id}/cards — tasks_api_v1_boards_cards_retrieve
- POST /boards/{board_id}/columns — tasks_api_v1_boards_columns_create
- GET /boards/{board_id}/columns — tasks_api_v1_boards_columns_list
- POST /boards/{board_id}/columns/reorder — tasks_api_v1_boards_columns_reorder_create
- POST /boards — tasks_api_v1_boards_create
- DELETE /boards/{board_id} — tasks_api_v1_boards_destroy
- GET /boards — tasks_api_v1_boards_list
- PATCH /boards/{board_id} — tasks_api_v1_boards_partial_update
- GET /boards/presets — tasks_api_v1_boards_presets_retrieve
- GET /boards/{board_id} — tasks_api_v1_boards_retrieve
- POST /boards/{board_id}/tasks — tasks_api_v1_boards_tasks_create
- GET /boards/{board_id}/tasks — tasks_api_v1_boards_tasks_retrieve
- POST /tasks/{task_id}/assign — tasks_api_v1_tasks_assign_create
- POST /tasks/{task_id}/checklist — tasks_api_v1_tasks_checklist_create
- GET /tasks/{task_id}/checklist — tasks_api_v1_tasks_checklist_list
- POST /tasks/{task_id}/checklist/{item_id}/state — tasks_api_v1_tasks_checklist_state_create
- POST /tasks/{task_id}/comments — tasks_api_v1_tasks_comments_create
- GET /tasks/{task_id}/comments — tasks_api_v1_tasks_comments_list
- DELETE /tasks/{task_id} — tasks_api_v1_tasks_destroy
- POST /tasks/{task_id}/move — tasks_api_v1_tasks_move_create
- PATCH /tasks/{task_id} — tasks_api_v1_tasks_partial_update
- GET /tasks/{task_id} — tasks_api_v1_tasks_retrieve

## Error codes (57) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.tasks_invalid_checklist_state [400] fix_input
- error.400.tasks_invalid_column [400] fix_input
- error.400.tasks_invalid_feature_defs [400] fix_input
- error.400.tasks_invalid_features [400] fix_input
- error.400.tasks_invalid_move [400] fix_input
- error.400.tasks_unknown_preset [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.tasks_forbidden [403] retry
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.tasks_board_not_found [404] retry
- error.404.tasks_checklist_item_not_found [404] retry
- error.404.tasks_column_not_found [404] retry
- error.404.tasks_comment_not_found [404] retry
- error.404.tasks_task_not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.409.tasks_column_exists [409] fix_input
- error.409.tasks_transition_not_allowed [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
- error.503.tasks_scope_unresolved [503] retry

## Documented flows (3) — full steps in docs/flows.json
- tasks.board_setup — Set up a board
- tasks.card_lifecycle — Work a card from creation to archive
- tasks.card_move — Move a card across the board
