Spec: F04 — Projects
Status: shipped foundation · agreed ownership/membership expansion · Audited: 2026-07-30
1. Problem & CEO Job
See clearly. Projects are containers that give tasks business context (“the Vincom fit-out”, “Q3 hiring push”) so the CEO and the chat agent can answer “tóm tắt project X”. Lumi is not a PM tool — projects exist to group signal, not to plan work. No Gantt, no sprints, no burndown, ever (docs/AGENTS.md → What Not To Build).
2. User Stories & Acceptance Criteria
- As a manager, I want to create a project with just a name, so that grouping work costs nothing.
- Given only a name, when I create, then the project exists (
active, org visibility) — every other field optional.
- Given only a name, when I create, then the project exists (
- As a CEO, I want project status at a glance, so that “what’s alive right now” needs zero clicks.
- Given the projects list, then status (active/paused/completed/archived) and owner are visible per row without opening anything.
- As a team lead, I want team-visible projects, so that sensitive work (M&A, salaries) stays scoped.
- Given
visibility=team, when a member outside the team lists projects, then it is absent (404 on direct fetch, not 403). - Given
visibility=private, then only explicit project members see it; a non-member receives404, never a disclosure-bearing403.
- Given
- As a project creator, I want to become its owner automatically, so that accountability
is explicit without another form field.
- Given I create a project, when the server returns
201, thenowneris my active organization membership, I appear once inmembers, and one private default channel exists.
- Given I create a project, when the server returns
- As a project owner, I want to manage the roster and transfer ownership, so that
access and accountability follow the real team.
- Given an active same-organization member, when I add them, then they become a project member and join every linked project channel in the same transaction.
- Given a non-owner member, when I confirm removal, then project and linked-channel access are revoked together while message history remains.
- Given I try to remove the current owner, then Lumi requires ownership transfer first and changes nothing.
- Given I transfer ownership to an active same-organization member, then the new owner is added to the project if needed and the previous owner remains a member. Foreign, inactive, or nonexistent targets return 404.
- Given the project owner is unavailable, when an organization owner explicitly confirms an override transfer, then the same locked transfer service runs, the override consequence and scope are audited, and no admin/manager/member role gets that break-glass authority.
- As a project member, I want its conversations beside the project, so that I do not
hunt through a global channel directory.
- Given I open a project on mobile, then I see owner, members, and linked channels and can open any channel in one tap.
- Given the project owner creates another project channel, then all current active project members join and future roster or organization-member status changes remain synchronized.
3. Scope Boundaries
- In: CRUD, creator-owned accountability, project membership, atomic ownership transfer, one required linked channel, additional linked channels, inherited channel membership, status lifecycle, team assignment, start/due dates, and visibility (organization/team/private).
- Out: milestones, dependencies, budgets, file attachments, timeline rendering, custom project roles, channel-specific members/roles, and cross-project channels.
4. Data Model
work.Project—status: active → paused ⇄ active → completed | archived.visibility: organization/team/private (legacypublicremoved — see §13).owner→OrganizationMember,team→Team. The database remains nullable for legacy/deleted-member compatibility, but every new project sets owner torequest.membership; ordinary create/update requests cannot select an owner.work.ProjectMember—project+OrganizationMember, unique per pair. It has no role column: accountability lives inProject.owner; membership only grants participation.- F29
Channel.projectlinks zero or more private channels to one project withon_delete=CASCADE. The protected first linked channel hasis_parent_default=Truewithout overloading F29’s organization-wideis_default;ChannelMembermaterializes activeProjectMemberrows, including while the channel is archived. One project may have at most 50 linked channels.
5. API Contract
| Method | Path | Min role | Notes |
|---|---|---|---|
| GET list/retrieve | /api/projects/ | viewer | Visibility-scoped reads |
| POST | /api/projects/ | member | Creator becomes owner/member; supplied owner is rejected |
| PATCH | /api/projects/<id>/ | non-viewer project owner | Owner-only field/status update |
| DELETE | /api/projects/<id>/ | non-viewer project owner | Owner-only irreversible delete; linked-history consequence must be confirmed |
| GET/POST | /api/projects/<id>/members/ | visible member / project owner for POST | List or add an active same-org member |
| DELETE | /api/projects/<id>/members/<id>/ | project owner | Remove a non-owner member after confirmation |
| POST | /api/projects/<id>/transfer-owner/ | non-viewer project owner | {owner: active non-viewer organization_member_id}; atomic and non-optimistic |
| GET/POST | /api/projects/<id>/channels/ | project member / project owner for POST | List or create inherited private channels |
Every tenant-owned query explicitly uses request.organization_id; visibility filtering is
enforced server-side. can_manage_members, can_transfer_owner, can_create_channel,
can_update, and can_delete are true only when the requester is not a viewer and
request.membership.id == project.owner_id. A viewer cannot receive project ownership; if legacy
data names a viewer as owner, that viewer remains read-only and the organization owner must use
the recovery transfer. Organization admin/manager/member labels do not widen private-project
governance. An organization owner receives only
can_override_transfer_owner; using it requires confirm_owner_override=true, names the
consequence, and writes an override audit before the normal locked transfer. A same-tenant user who
can read an organization/team-visible project but lacks the required capability gets 403; an
invisible/private non-member gets 404. Organization member suspension/removal is the other
governance exception and follows F02’s explicit handoff confirmation/audit contract.
Roster responses include active organization members only. Suspension retains the ProjectMember
row and disables linked ChannelMember access without deleting its unread watermark; reactivation
restores member/channel access but never restores ownership transferred during suspension.
ProjectSerializer.task_count / .done_task_count (added 2026-07-10, W-WRK-02): real integers, annotated on ProjectViewSet.get_queryset via Count("tasks", filter=Q(...), distinct=True) — never None on list/retrieve. task_count excludes tasks with archived_by_assistant=True; done_task_count is the same exclusion plus status="done". Both filters also pin tasks__organization_id to the requester’s org so a cross-org Task.project FK (same class of gap as owner_id, ES13) can never inflate another tenant’s count. Not annotated on create/update responses (the serializer instance from perform_create/update isn’t re-queried) — those still return null for both fields, unchanged from before.
6. UI / UX
/work/projects(ProjectsPage.tsx) — list + inline detail. Create dialog: one required field (name). The detail exposes owner, members, and linked channels using server capability flags; member/channel mutations remain pending until the response. Project detail sub-routes:/work/projects/:id/boardand/work/projects/:id/tasks.- Android/iOS project detail order: summary → owner → members → channels → field-day/work content. Owner/member rows expose one Manage members action when allowed. Transfer uses the same active-member picker, explains the consequence, and commits only after confirmation.
- An organization owner who is not the project owner sees only Recover ownership, never member/channel/edit/delete controls; the confirmation labels it as an audited override.
- Channel rows show name and unread count and open the existing F29 thread in one tap. New
channel appears only when the API returns
can_create_channel=true. - States: empty (“Dự án đầu tiên của bạn” + create), loading skeletons, status badges via
Badgevariants. - A roster or channel load failure leaves the other project sections visible with an inline retry. A legacy null owner renders “Owner not assigned”, never a fabricated person.
- Creator ownership and the first channel are automatic, never extra create-form questions.
- Project deletion confirmation names the linked-channel count and permanent message-history loss.
- Status
<select>on each project card for inline status change with optimistic update (added 2026-06-14, E7 closed). - Edit + Delete per project card (added 2026-06-19): Edit opens a shadcn
Dialogpre-filled with name/description/visibility →PATCH /projects/:id/; Delete opens a confirmationDialog→DELETE /projects/:id/(audit-logged viaAuditDestroyMixin, surfaced in F16 audit log). Wired throughuseUpdateProject/useDeleteProject(React Query). Test idsproject-edit-{id}/project-delete-{id}. - Real task counts on cards (added 2026-07-10, W-WRK-02): each card shows
{{count}} tasks(i18ntask_count_one/_other) and, oncetask_count > 0,· {{count}} done(done_task_count_one/_other) from the backend annotation in §5. A project with zero tasks renders0 taskshonestly (the field is a real0, notnull, so the count is never hidden); thedonefraction is only shown once there’s something to divide. - 5am test: list scannable by name + status color alone.
- i18n:
projects.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | CRUD happy path | backend | tests/test_api.py::TestProject, tests/test_lumi.py::TestProjectCRUD |
| 2 | Visibility: team/private hidden from outsiders | backend | tests/test_multitenancy.py::TestProjectVisibility |
| 3 | Cross-tenant blocked | backend | tests/test_lumi.py::TestTenantIsolation |
| 4 | List + detail render, create flow | e2e | describe("Projects") |
| 5 | task_count/done_task_count: mixed statuses, zero-task project, assistant-archived exclusion, cross-tenant isolation (incl. cross-org Task.project FK), retrieve endpoint, no N+1 on list | backend | tests/api/test_projects_tasks.py::TestProject (test_task_count_*, test_list_projects_task_counts_no_n_plus_one) |
| 6 | Cards render real counts; 0 tasks never hidden; null (create/update response) renders nothing | frontend | src/test/work/ProjectsPage.test.tsx |
| 7 | Creator becomes owner/member and default channel is created atomically | backend | backend/tests/work/test_project_members_channels.py |
| 8 | Add/remove/transfer governance, inactive/cross-tenant denial, owner-removal guard | backend | backend/tests/work/test_project_members_channels.py |
| 9 | Explicit members can read member-scoped projects; non-members remain hidden | backend | backend/tests/work/test_project_members_channels.py |
| 10 | Mobile owner/member/channel sections, server-capability gates, partial failure, and thread routing | shared JVM + Maestro | P60 mobile suite |
| 11 | Web owner/member/channel sections, owner-only capability matrix, committed-response mutations, and deletion consequence | frontend + Chromium | P60 W01/W02/W05/W07 |
| 12 | Project update/delete are owner-only; org owner override can only transfer after explicit confirmation/audit | backend + web/mobile | P60 P25/P26 |
8. Current Behavior
Shipped foundation. Projects support CRUD, visibility, status, and team assignment.
visibility="public" was removed (migration 2026-06-10). Project creation now makes the creator
the immutable request-time owner and first ProjectMember, creates the protected default linked
channel in the same transaction, and rejects client-supplied ownership. Project detail exposes the
active roster, linked channels, and server-derived governance capabilities without query growth as
member/channel counts increase. Only the current non-viewer project owner can update, delete,
add/remove members, transfer ownership, or create channels; viewers cannot receive ownership and
remain read-only if legacy data names them as owner. An organization owner has only the explicit
confirmed and audited ownership-recovery path. Suspension/removal transfers owned projects
atomically before access revocation. Shared Android/iOS project detail now exposes owner, member,
transfer, and linked-channel controls from server capabilities and routes exact channel ids to the
canonical Messages thread; E02–E08 passed on the approved emulator and simulator. Web, macOS,
named-device, native screen-reader-service, and store proof remain later P60 slices.
9. Error States
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | Missing required name | 400 {"name": ["This field is required."]} | Backend independent of frontend <input required> |
| ES2 | Invalid visibility value | 400 {"visibility": ["\"invalid\" is not a valid choice."]} | test_create_project_rejects_public_visibility (generalizes) |
| ES3 | Invalid status value | 400 {"status": ["\"invalid\" is not a valid choice."]} | Fixed; test_patch_invalid_status_returns_400 |
| ES4 | Cross-tenant project access | 404 | test_project_tenant_isolation; never 403 (no leak of existence) |
| ES5 | Non-member access to private project | 404; explicit project members can read it | P60 P11 replaces the legacy owner-only test |
| ES6 | Non-team-member access to team project | 404 | test_team_project_not_visible_to_non_team_members |
| ES7 | Updating/deleting another org’s project | 404 | Queryset scoping; test_project_tenant_isolation |
| ES8 | Deleting a project with tasks and linked channels | Explicit irreversible confirmation names the linked-channel count; tasks get project_id=NULL, while linked channels/messages cascade so no frozen-ACL orphan remains | test_delete_project + P60 C13 |
| ES9 | Frontend load failure | toast.error(t('error_load')) | No E2E test |
| ES10 | Frontend create failure | toast.error(t('error_create')) — form preserved | No E2E test |
| ES11 | Name > 255 chars | 400 — DRF auto-validator; frontend has no maxLength | test_create_project_name_too_long_returns_400 |
| ES12 | Invalid team_id on create | 400 — FK validation | test_create_project_nonexistent_team_id_returns_400 |
| ES13 | Cross-org owner_id accepted | 200 — Django doesn’t validate FK org consistency | test_create_project_cross_org_owner_accepted; no serializer guard |
| ES14 | Remove current owner | 409 owner_transfer_required | No rows or channel access change |
| ES15 | Transfer to inactive/foreign member | 404 | Do not disclose whether a foreign membership exists |
| ES16 | Duplicate project member | 400 already_project_member | Stable validation; no duplicate rows |
| ES17 | Default linked channel archive/delete | 400 default_channel_required | Project keeps one ready conversation |
| ES18 | Roster/channels partial mobile failure | Keep loaded sections visible with inline retry | Never blank the whole detail |
| ES19 | Create 51st linked channel | 409 linked_channel_limit | Existing channels and memberships unchanged |
| ES20 | Readable project PATCH/DELETE by non-owner | 403; invisible private project remains 404 | No project/channel/message mutation |
| ES21 | Org-owner transfer override without confirmation | 409 owner_override_confirmation_required with consequence | No mutation |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md Project state machine.
11. Implementation Audit (2026-06-12)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Title-only create (defaults: active, org visibility) | ✅ match | test_create_project; serializer omits name from read_only |
| 2 | Status + owner visible in list per row (no click required) | ✅ match | ProjectSerializer exposes owner_name and status; test_list_projects |
| 3 | visibility="public" removed; data-migrated to "organization" | ✅ match | Migration 0003_alter_project_visibility.py; test_create_project_rejects_public_visibility |
| 4 | Status lifecycle UI (pause/archive/active inline select on card) | ✅ match | Added 2026-06-14; ProjectsPage.tsx status <select>; E7 closed |
| 5 | Team-visibility with team=None still follows explicit project membership (E2 superseded by P60 P12) | ✅ match | test_owner_keeps_project_visible_when_team_visibility_has_no_team; owner/member still sees it, outsider does not |
| 6 | Owner SET_NULL on member delete (E5) | ✅ match | on_delete=SET_NULL; test_owner_deleted_sets_project_owner_null |
| 7 | Status: no FSM — any transition allowed (archived → active) by design | ✅ match | test_patch_archived_project_to_active_succeeds; intentional |
| 8 | start_date > due_date accepted — no cross-field validation | ⚠️ gap | test_start_date_after_due_date_accepted; no check_constraint or serializer guard |
| 9 | ?team_id= filter bypasses visibility (E14) — admin shortcut, not security boundary | ✅ match | By design; test_team_id_filter_bypasses_visibility |
| 10 | Cross-org owner_id accepted on create (ES13) | ⚠️ gap | No FK org validation in serializer; test_create_project_cross_org_owner_accepted |
| 11 | Visual QC: Vietnamese project cards show localized status badges, not backend literals | ✅ match | 2026-06-18 screenshot pass; ProjectsPage.tsx passes translated status_* labels to StatusBadge; ProjectsPage.test.tsx |
Summary: 8/11 match; 3 gaps (team=None visibility trap, start/due date validation, cross-org owner FK).
Edge Cases Catalog (formerly §10, 2026-06-12; E1–E16): Key items integrated above. Remaining: E1 empty org → empty list; E3 visibility team→org transition (immediate, tested); E6 status no FSM (row 7); E8 start > due (row 8); E9 past due date accepted; E10 duplicate names allowed (by design, no unique constraint); E11 name max_length (ES11); E12 tasks orphaned on delete (ES8); E13 concurrent creation (no race — UUIDs unique); E14 ?team_id= bypass (row 9); E15 pagination (DRF default); E16 owner=None on create (200, visible to all org members). Full catalog in git history (2026-06-12 changelog entry).
12. Open Questions
- None. Pressure to add PM features goes to the What-Not-To-Build list, not here.
Forward reference — F97: Project Progress Updates (F97) adds GET /api/projects/<id>/progress/?days=7, nullable project FK on Risk and DecisionRequest, project disambiguation in the pulse extraction pipeline, and ProjectProgressCard above the kanban board. See docs/specs/F97-project-progress-updates.md.
13. Bug / Change Log
2026-07-30 — Specify creator ownership, membership, and linked conversations
Type: Behavior Change
Summary:
- Project creator becomes owner and member automatically.
- Owners can add/remove members and atomically transfer ownership.
- Every project receives one inherited private channel and may have more.
- Shared mobile detail owns the complete owner/member/channel workflow.
Reason:
- Nullable owner without a roster leaves accountability ambiguous; independently configured channels duplicate setup and can retain stale access.
Impact:
- F04 owns project accountability and membership; F29 owns channel access materialization; F30 owns complete Android/iOS behavior; ADR-050 defines the shared boundary.
Tests:
- Planned in
docs/test-plan/project-team-channels.mdcases P01–P26, C13–C18, W01–W07, and M01–M10/M20.
2026-07-13 — Contract Graph migration slice
Type: Contract and proof traceability
Summary:
- Added the canonical F04 CUE contract for the six base project CRUD endpoint surfaces and attached stable proof IDs to existing CRUD, visibility, and task-count tests.
- Regenerated the Contract Graph reports. F04 is intentionally
partial: the audit’s team-null visibility, start-after-due, and cross-org owner foreign-key gaps remain open, as do browser and named Android, iOS, and macOS runtime proofs.
Impact: No product behavior change. Board, nested task, custom status, field-day, and progress routes remain owned by F49, F90, and F97 rather than being falsely attributed to F04.
Tests: The Contract Graph verifier executes the selected F04 pytest proof records; remaining security
and runtime gaps are explicit in contracts/features/F04.cue.
2026-07-13 — Viewer project writes denied
Type: Security fix
Summary: Contract Graph proof collection found that ProjectViewSet applied IsOrgMember to every
action, so a viewer could create, update, or delete a project despite the F04 contract defining
viewers as read-only. Added IsProjectWriter for non-viewer mutations and kept member-level writes
allowed as specified.
Tests: TestProject.test_viewer_cannot_create_project; the F04 Contract Graph verifier now executes
the role-deny proof successfully.
2026-07-10 — Real task_count/done_task_count annotations (W-WRK-02)
Type: Bug Fix (correctness gap → real data)
Summary: task_count was declared on ProjectSerializer since the card UI was built, but ProjectViewSet.get_queryset never annotated it — every card either hid the count or (if rendered unconditionally) showed null. Registry audit (docs/tests/web/web-screen-audit.md row W-WRK-02) capped the screen’s Feat score at 7 pending this fix; docs/performance/F04-projects.md G1 flagged the same dead field. Fixed by annotating task_count (all tasks excluding archived_by_assistant=True) and a new done_task_count (same exclusion + status="done") via Count(..., filter=Q(...), distinct=True) on the existing queryset — one query for the whole list, no N+1. Both filters also pin to the requester’s org defensively, since Task.project has no DB-level same-org constraint (same gap class as owner_id, ES13/row 10). ProjectsPage.tsx now renders {{count}} tasks · {{count}} done; a zero-task project renders 0 tasks honestly instead of hiding the field. See §5, §6, §7.
Verification: backend/tests/api/test_projects_tasks.py::TestProject (8 new cases) · pnpm test -- src/test/work/ProjectsPage.test.tsx (3 new cases) · tsc --noEmit 0 errors · check-design-tokens.mjs clean · check-locale-parity.mjs clean (61 locales).
2026-06-19 — Add Edit + Delete affordances to project cards
Type: Feature (CRUD completion)
Summary: Backend ProjectViewSet (ModelViewSet) always supported PATCH/DELETE, but ProjectsPage.tsx only exposed create + inline status change — no way to edit name/description/visibility or delete a project. Added a pre-filled Edit dialog and a confirmation-gated Delete, wired through the previously-unused useUpdateProject/useDeleteProject hooks; migrated the page to React Query. Deletes are audit-logged (F16). i18n keys added to en/vi. See §6.
2026-06-18 — Visual QC: Localize project status badges
Type: UX Fix
Summary:
- The Vietnamese Projects screenshot showed raw backend status text (
active) on every project card. - Project cards now pass translated
status_*labels into the sharedStatusBadge, while preserving the existing badge color mapping.
Verification:
frontend/screenshots/06-projects/01-projects-list.pngpnpm test -- src/test/ProjectsPage.test.tsxpnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "06-projects"
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /projects -> /work/projects (incl. /work/projects/:id/board and /work/projects/:id/tasks) (app-wide IA restructure; API paths unchanged).
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 (narrative) → §11 (10-row table); §10 Edge Cases absorbed into §11 note; §11 Error States → §9; §12 Open Questions → §12; §13 Bug/Change Log stays.
- §11 audit: 10 rows, 7 ✅, 3 gaps (team=None visibility, start/due validation, cross-org owner FK).
2026-06-14 — Status lifecycle UI added (E7 closed)
Type: Feature
Summary:
- Added status
<select>to each project card inProjectsPage.tsx; user can change status inline. - Status locale keys added to all locale files.
2026-06-12 — Add Edge Cases (E1–E16) and Error States (ES1–ES13) to F04 spec
Type: Documentation Change
2026-06-12 — Check off F04 ACs as implemented (D-r22)
Type: Documentation Change
Summary:
- Verified all 4 ACs against shipped code and tests; marked
[x].
2026-06-10 — Remove Project.visibility="public"
Type: Breaking Change
Summary:
- Removed
"public"fromVISIBILITY_CHOICES; migration data-migrates existing rows to"organization". ProjectViewSet.get_querysetno longer matchesvisibility="public".- Removed “public” option from
ProjectsPage.tsxandvisibility_publici18n key.