shipped implementation; Contract Graph status partial (2026-07-15)

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 Task is 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_useful or resolved-elsewhere semantics) — dismissals are calibration data.
  • As the assignee, I want the task to look normal — it appears in /work/tasks like 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)

MethodPathMin roleNotes
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/managerSets 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.tsx item 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 brief namespace, en+vi together.

7. Test Plan

#CaseTypeWhere
1Assign creates linked task with provenance + inherited impactbackendbackend/tests/api/test_briefs.py::TestBriefItemActions
2Cross-tenant assignee rejected; viewer/member role rejectedbackendmandatory
3Dismiss records feedback; idempotency on double-tapbackendsame
4Assign flow from brief, task appears in /work/taskse2eextend 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

#ErrorUser messageHTTPLoggedNotes
ES0Invalid due date — assign payload contains a non-ISO date.due_date must be a valid ISO date.400NoParsed before Task.objects.create; no partial task is written.
ES1Cross-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)404NoThe query scopes to request.tenant via view queryset — missing items result in get_object_or_404.
ES2Viewer/member tries to assign — a member (not manager/owner/admin) calls the assign endpoint.briefs.error_not_permitted403NoGated by IsOrgManager permission class.
ES3Already assigned — duplicate assign call to same item (second CEO action or race).briefs.error_already_assigned with detail409NoRace-safe: related_task is set before the response; a concurrent second request sees the populated FK and returns 409.
ES4Invalid owner_id — cross-tenant UUID — owner_id UUID belongs to a member of a different org.briefs.error_invalid_owner400NoValidated by queryset check: OrganizationMember.objects.filter(id=owner_id, organization=request.tenant).exists().
ES5Brief-item write during read-only org — org is in read-only status and assign/dismiss are both write operations.subscription_read_only403NoBlockReadOnlyOrgMiddleware 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)

#ClaimStatusEvidence
1POST /api/briefs/items/<id>/assign/ creates Task with source_type="ai", provenance, business_impact inherited from source✅ matchbackend/apps/briefs/views.py::assign_brief_item; backend/tests/api/test_briefs.py::TestBriefItemActions
2Already-assigned → 409 with code="already_assigned"✅ matchbackend/tests/api/test_briefs.py::test_assign_already_assigned_conflict
3Cross-tenant item → 404; cross-tenant or invalid owner_id → 400✅ matchTestBriefItemActions cross-tenant tests
4POST /api/briefs/items/<id>/dismiss/ idempotent; creates Feedback row with target_type="brief_item"✅ matchbackend/tests/api/test_briefs.py::test_dismiss_idempotent, test_dismiss_sets_dismissed_and_feedback
5Dismiss does not cancel underlying DecisionRequest✅ matchtest_dismiss_does_not_cancel_decision
6Manager-only gate for both assign and dismiss (IsOrgManager)✅ matchTestBriefItemActions role tests
7Frontend inline “Giao việc”/“Bỏ qua” per card; i18n en+vi (16 keys); E2E targetspartialfrontend/src/pages/intel/BriefPageComponents.tsx, frontend/src/api/intel.ts, frontend/tests/e2e/lumi.part01.spec.ts; browser execution remains open
8Assign to deactivated member — only active same-org members are valid assignees✅ match2026-07-01 test_assign_inactive_owner_rejected; assign_brief_item looks up OrganizationMember(..., status="active")
9Assign where source FK deleted (SET_NULL) — fallback to business_impact="none"✅ match2026-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
10Read-only org blocks both assign and dismiss with subscription_read_only✅ match2026-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 decision item also cancel the underlying DecisionRequest? Resolved: no — the item hides, the request stays open until decided/cancelled explicitly (covered by test_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 TestBriefItemActions count 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) to test_assign_already_assigned_conflict (test_api.py:1803).
  • §11 row 4: corrected test name from test_dismiss_records_feedback (does not exist) to test_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, inherited business_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 assign and dismiss are blocked with subscription_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/ and POST /api/briefs/items/<id>/dismiss/.
  • Added BriefItem.dismissed_at and Task.evidence_sources (M2M).

Tests:

  • TestBriefItemActions (9 tests) in backend/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_detail types; 16 i18n keys (en+vi); 2 E2E tests in describe("CEO Brief").