Spec: F25 — Act on Brief Items (edit / assign)
Status: shipped implementation; Contract Graph status partial (2026-07-15) Contract: F25 CUE contract
Implementation audit: F25 contract audit
Test plan: F25 test plan
Contract Graph boundary
F25 is the canonical Contract Graph owner of the two Brief action endpoints and the related web
and shared-platform action surfaces. Local backend and unit evidence is collected, but collection
does not promote browser or native runtime claims. The contract retains explicit gaps for browser
execution and named Android/iOS execution; malformed due_date handling is covered by local
backend proof.
1. Problem & CEO Job
Decide fast — closing the loop. The Brief is read-only: Lumi identifies an overdue commitment, then the CEO must leave it to find and chat the delegate. That insight→action gap leaks value. F25 lets the CEO dispatch any item as a real task or dismiss it in place, turning the Brief from a report into a command surface.
2. User Stories & Acceptance Criteria
- As a CEO, I want to assign a brief item to someone with a due date in two taps, so that acting costs less than worrying.
- Given any actionable BriefItem, then “Giao việc” opens an inline assign control (member picker + optional due date + prefilled title from the item) — no navigation, no modal maze.
- Given assignment, then a real
Taskis created:source_type="ai", owner set, linked back to the BriefItem (related_task), business_impact inherited from the source object where known, evidence carried over. - Given the source object (commitment/risk/decision) exists, the new task references it — assignment must not orphan provenance.
- As a CEO, I want to edit the suggestion before dispatching, so that the instruction is mine, in my words.
- Given assign, the title/description are editable inline pre-create; the original AI text is preserved on the BriefItem (audit trail of what Lumi said vs what the CEO sent).
- As a CEO, I want to dismiss an item (“đã xử lý” / “bỏ qua”), so that the brief reflects reality.
- Dismissal records Feedback (F15,
not_usefulor resolved-elsewhere semantics) — dismissals are calibration data.
- Dismissal records Feedback (F15,
- As the assignee, I want the task to look normal — it appears in
/work/taskslike any task; (Telegram notify is a separate decision, F21 §10 restraint rule applies).
3. Scope Boundaries
- In: assign-to-task from BriefItem, inline edit before dispatch, dismiss with feedback capture, provenance links.
- Out: assigning directly from risk/commitment list pages (later, same pattern), bulk assign, auto-assignment by AI (proposes ≠ disposes — a human always dispatches), editing the Brief’s ranking.
4. Data Model
BriefItem.dismissed_at (nullable DateTimeField, added — see migration 0003_briefitem_dismissed_at) and Task.evidence_sources (M2M to EvidenceSource, added — see migration 0004_task_evidence_sources) were added to support this feature. ER diagram + GLOSSARY updated same PR. BriefItem.related_task (existing), Task.source_type="ai" (existing), Feedback (existing) used as designed.
related_task now has one deliberate product meaning across Brief surfaces: it points to the live task the CEO should act through, whether that task already existed (for task-backed Brief items such as overdue work or field-exception blockers) or was created by the assign action in this feature. The UI must therefore hide “Giao việc” whenever related_task is already present.
5. API Contract (as built)
| Method | Path | Min role | Notes |
|---|---|---|---|
| POST | /api/briefs/items/<id>/assign/ | manager | {owner_id, title?, description?, due_date?} → creates Task (source_type="ai", business_impact inherited from the source Commitment/DecisionRequest when valid, evidence copied), sets BriefItem.related_task, returns the Task. Already-assigned → 409 {"detail": ..., "code": "already_assigned"}. Invalid/cross-tenant owner_id → 400. |
| POST | /api/briefs/items/<id>/dismiss/ | manager | Sets BriefItem.dismissed_at (idempotent — second call is a no-op) and creates one Feedback row (target_type="brief_item", feedback_type from payload, default not_useful). Does not cancel any underlying Commitment/Risk/DecisionRequest. Returns the updated BriefItem. Blocked with 403 {"detail": "subscription_read_only"} when org billing state is read-only. |
Both endpoints: cross-tenant item id (organization_id mismatch) → 404. Assignee must be an active OrganizationMember of the same org — cross-tenant or invalid owner_id → 400.
6. UI / UX
- Lives entirely inside
BriefPage.tsxitem cards: two quiet actions per item (“Giao việc”, “Bỏ qua”). The reading experience stays primary — actions must not turn the brief into a form. - Assigned state visible on the item (”→ Đã giao cho Tuấn, hạn 14/6”) so re-reading the brief shows what’s already handled.
- States: assigning, failure (preserve the editable draft + actionable localized feedback), already-assigned conflict (close the stale editor, refetch server truth, show the assigned state, and don’t re-offer).
- The task title and due-date controls retain explicit label associations for keyboard and assistive-technology users.
- i18n: extend
briefnamespace, en+vi together.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Assign creates linked task with provenance + inherited impact | backend | backend/tests/api/test_briefs.py::TestBriefItemActions |
| 2 | Cross-tenant assignee rejected; viewer/member role rejected | backend | mandatory |
| 3 | Dismiss records feedback; idempotency on double-tap | backend | same |
| 4 | Assign flow from brief, task appears in /work/tasks | e2e | extend describe("CEO Brief") |
8. Current Behavior
The implementation is shipped and the local backend/unit surfaces match. Assignment parses an optional ISO due date before task creation and returns 400 for malformed input. The Contract Graph remains partial until browser execution and named native runtime evidence close.
9. Error States
| # | Error | User message | HTTP | Logged | Notes |
|---|---|---|---|---|---|
| ES0 | Invalid due date — assign payload contains a non-ISO date. | due_date must be a valid ISO date. | 400 | No | Parsed before Task.objects.create; no partial task is written. |
| ES1 | Cross-tenant item id — brief_item_id belongs to a different org than the requester’s X-Organization-ID header. | Standard 404 (don’t reveal existence) | 404 | No | The query scopes to request.tenant via view queryset — missing items result in get_object_or_404. |
| ES2 | Viewer/member tries to assign — a member (not manager/owner/admin) calls the assign endpoint. | briefs.error_not_permitted | 403 | No | Gated by IsOrgManager permission class. |
| ES3 | Already assigned — duplicate assign call to same item (second CEO action or race). | briefs.error_already_assigned with detail | 409 | No | Race-safe: related_task is set before the response; a concurrent second request sees the populated FK and returns 409. |
| ES4 | Invalid owner_id — cross-tenant UUID — owner_id UUID belongs to a member of a different org. | briefs.error_invalid_owner | 400 | No | Validated by queryset check: OrganizationMember.objects.filter(id=owner_id, organization=request.tenant).exists(). |
| ES5 | Brief-item write during read-only org — org is in read-only status and assign/dismiss are both write operations. | subscription_read_only | 403 | No | BlockReadOnlyOrgMiddleware runs before the view; assign and dismiss are both blocked uniformly outside exempt prefixes. |
The web client maps these structured statuses to localized, actionable messages. It must not flatten invalid owner, invalid due date, authorization, or conflict responses into a generic failure. A read-only response remains owned by the shared subscription interceptor so the Brief does not stack a second contradictory toast.
10. Diagrams & Docs Touched
backend/DIAGRAMS.md (BriefItem interactions) · frontend/DIAGRAMS.md if routes change (shouldn’t) · registry F25 row.
11. Implementation Audit (2026-06-10)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | POST /api/briefs/items/<id>/assign/ creates Task with source_type="ai", provenance, business_impact inherited from source | ✅ match | backend/apps/briefs/views.py::assign_brief_item; backend/tests/api/test_briefs.py::TestBriefItemActions |
| 2 | Already-assigned → 409 with code="already_assigned" | ✅ match | backend/tests/api/test_briefs.py::test_assign_already_assigned_conflict |
| 3 | Cross-tenant item → 404; cross-tenant or invalid owner_id → 400 | ✅ match | TestBriefItemActions cross-tenant tests |
| 4 | POST /api/briefs/items/<id>/dismiss/ idempotent; creates Feedback row with target_type="brief_item" | ✅ match | backend/tests/api/test_briefs.py::test_dismiss_idempotent, test_dismiss_sets_dismissed_and_feedback |
| 5 | Dismiss does not cancel underlying DecisionRequest | ✅ match | test_dismiss_does_not_cancel_decision |
| 6 | Manager-only gate for both assign and dismiss (IsOrgManager) | ✅ match | TestBriefItemActions role tests |
| 7 | Frontend inline “Giao việc”/“Bỏ qua” per card; i18n en+vi (16 keys); E2E targets | partial | frontend/src/pages/intel/BriefPageComponents.tsx, frontend/src/api/intel.ts, frontend/tests/e2e/lumi.part01.spec.ts; browser execution remains open |
| 8 | Assign to deactivated member — only active same-org members are valid assignees | ✅ match | 2026-07-01 test_assign_inactive_owner_rejected; assign_brief_item looks up OrganizationMember(..., status="active") |
| 9 | Assign where source FK deleted (SET_NULL) — fallback to business_impact="none" | ✅ match | 2026-07-01 test_assign_deleted_source_falls_back_to_none_business_impact; source lookup falls through to default "none" when related_decision_request/related_commitment is null |
| 10 | Read-only org blocks both assign and dismiss with subscription_read_only | ✅ match | 2026-07-01 test_read_only_org_blocks_assign_and_dismiss; backend/core/middleware.py::BlockReadOnlyOrgMiddleware blocks both endpoints before view logic |
Summary: 11/11 match. The due-date boundary was closed on 2026-07-16; the remaining browser and native runtime evidence stays explicit in the Contract Graph.
Edge Cases Catalog (formerly §12, 2026-06-12; E1–E6): Key items integrated above. Remaining: E2 assign-after-dismiss or dismiss-then-assign — both can coexist (related_task and dismissed_at not mutually exclusive); test_dismiss_idempotent covers double-dismiss but not mixed ordering. E4 missing owner_id — 400 (required field); TestBriefItemActions covers invalid/cross-tenant but not missing explicitly. E5 dismissed-with-completed-source — dismiss only affects BriefItem visibility; next Brief generation skips resolved sources normally. In read-only orgs, both assign and dismiss are uniformly blocked at middleware before either mixed-order path can run. Full catalog in git history.
12. Open Questions
- Does dismissing a
decisionitem also cancel the underlying DecisionRequest? Resolved: no — the item hides, the request stays open until decided/cancelled explicitly (covered bytest_dismiss_does_not_cancel_decision).
13. Bug / Change Log
2026-07-15 - Contract Graph ownership and evidence boundary
Type: Contract and documentation truth fix
Summary:
- Moved canonical ownership of the assign and dismiss endpoint contracts from F12 to F25 without changing endpoint behavior.
- Registered backend, frontend unit, browser-target, and native-source proof IDs in
contracts/features/F25.cue. - Corrected the E2E path and Radix combobox selector to match the shipped frontend source.
- Retained explicit gaps for due-date validation, browser execution, and named native execution.
2026-07-02 — Fix stale test names and test count in §11
Type: Documentation truth fix
Summary:
- §11 row 1: updated
TestBriefItemActionscount from 9 to 15 (the class grew as rows 8-10 were added in the 2026-07-01 session, plus earlier additions). - §11 row 2: corrected test name from
test_assign_re_assign_returns_409(does not exist) totest_assign_already_assigned_conflict(test_api.py:1803). - §11 row 4: corrected test name from
test_dismiss_records_feedback(does not exist) totest_dismiss_sets_dismissed_and_feedback(test_api.py:1900).
Tests: No code changes — existing tests pass; only the spec’s references to them were wrong.
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /tasks -> /work/tasks (app-wide IA restructure; API paths unchanged).
2026-07-02 — Correct spec drift: check 5 shipped ACs confirmed by §11
Type: Documentation Change
Summary:
- §2 ACs brought in line with §11 audit (10/10 ✅). Checked 5 previously unchecked ACs:
- “Giao việc” inline assign control (§11 row 7)
- Task created with
source_type="ai", provenance, inheritedbusiness_impact(§11 row 1) - Source object reference preserved — no orphaned provenance (§11 row 1)
- Title/description editable inline; original AI text preserved on BriefItem (§11 row 7)
- Dismissal records Feedback row (§11 row 4)
- No code changes; documentation only.
2026-07-01 — Close F25 proof debt and correct read-only behavior
Type: Backend test coverage + documentation truth fix
Summary:
- Added backend proof that suspended members cannot be selected as assignees and that brief-item assignment falls back to
business_impact="none"when the source FK has already been nulled. - Corrected the read-only-org contract: both
assignanddismissare blocked withsubscription_read_only; the previously documented asymmetry did not exist in the shipped middleware.
Tests:
cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_api.py -k "assign_inactive_owner_rejected or assign_deleted_source_falls_back_to_none_business_impact or read_only_org_blocks_assign_and_dismiss or assign_creates_linked_task or dismiss_idempotent" -x --tb=short
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8; §8 Diagrams → §10; §9 Implementation Audit → §11 (10-row table); §10 Open Questions → §12; §11 Bug Log + §14 Bug Log (duplicate) merged into §13; §12 Edge Cases absorbed into §11 note; §13 Error States → §9.
- §11 audit: 10 rows, 7 ✅, 3 gaps (deactivated member, deleted-source fallback, read-only asymmetry).
2026-06-12 — Added Edge Cases + Error States sections
Type: Documentation Change
Summary:
- Added §12 Edge Cases (E1–E6): deactivated member, assign-after-dismiss, deleted source, missing owner_id, dismissed-with-completed-source, reassign rejection.
- Added §13 Error States (ES1–ES5): cross-tenant 404, role block, already-assigned 409, invalid owner_id, read-only asymmetry.
2026-06-10 — Assign + dismiss endpoints (P02 PR-1)
Type: Feature
Summary:
- Added
POST /api/briefs/items/<id>/assign/andPOST /api/briefs/items/<id>/dismiss/. - Added
BriefItem.dismissed_atandTask.evidence_sources(M2M).
Tests:
TestBriefItemActions(9 tests) inbackend/tests/test_api.py, 100% coverage maintained.
Notes:
- Frontend (PR-2) shipped same session: per-card “Giao việc”/“Bỏ qua” in
BriefPage.tsx;briefsApi.assignItem/dismissItem;BriefItem.dismissed_at/related_task_detailtypes; 16 i18n keys (en+vi); 2 E2E tests indescribe("CEO Brief").