shipped foundation

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.
  • 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 receives 404, never a disclosure-bearing 403.
  • 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, then owner is my active organization membership, I appear once in members, and one private default channel exists.
  • 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 (legacy public removed — see §13). owner → OrganizationMember, team → Team. The database remains nullable for legacy/deleted-member compatibility, but every new project sets owner to request.membership; ordinary create/update requests cannot select an owner.
  • work.ProjectMember — project + OrganizationMember, unique per pair. It has no role column: accountability lives in Project.owner; membership only grants participation.
  • F29 Channel.project links zero or more private channels to one project with on_delete=CASCADE. The protected first linked channel has is_parent_default=True without overloading F29’s organization-wide is_default; ChannelMember materializes active ProjectMember rows, including while the channel is archived. One project may have at most 50 linked channels.

5. API Contract

MethodPathMin roleNotes
GET list/retrieve/api/projects/viewerVisibility-scoped reads
POST/api/projects/memberCreator becomes owner/member; supplied owner is rejected
PATCH/api/projects/<id>/non-viewer project ownerOwner-only field/status update
DELETE/api/projects/<id>/non-viewer project ownerOwner-only irreversible delete; linked-history consequence must be confirmed
GET/POST/api/projects/<id>/members/visible member / project owner for POSTList or add an active same-org member
DELETE/api/projects/<id>/members/<id>/project ownerRemove 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 POSTList 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/board and /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 Badge variants.
  • 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 Dialog pre-filled with name/description/visibility → PATCH /projects/:id/; Delete opens a confirmation Dialog → DELETE /projects/:id/ (audit-logged via AuditDestroyMixin, surfaced in F16 audit log). Wired through useUpdateProject/useDeleteProject (React Query). Test ids project-edit-{id}/project-delete-{id}.
  • Real task counts on cards (added 2026-07-10, W-WRK-02): each card shows {{count}} tasks (i18n task_count_one/_other) and, once task_count > 0, · {{count}} done (done_task_count_one/_other) from the backend annotation in §5. A project with zero tasks renders 0 tasks honestly (the field is a real 0, not null, so the count is never hidden); the done fraction is only shown once there’s something to divide.
  • 5am test: list scannable by name + status color alone.
  • i18n: projects.

7. Test Plan

#CaseTypeWhere
1CRUD happy pathbackendtests/test_api.py::TestProject, tests/test_lumi.py::TestProjectCRUD
2Visibility: team/private hidden from outsidersbackendtests/test_multitenancy.py::TestProjectVisibility
3Cross-tenant blockedbackendtests/test_lumi.py::TestTenantIsolation
4List + detail render, create flowe2edescribe("Projects")
5task_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 listbackendtests/api/test_projects_tasks.py::TestProject (test_task_count_*, test_list_projects_task_counts_no_n_plus_one)
6Cards render real counts; 0 tasks never hidden; null (create/update response) renders nothingfrontendsrc/test/work/ProjectsPage.test.tsx
7Creator becomes owner/member and default channel is created atomicallybackendbackend/tests/work/test_project_members_channels.py
8Add/remove/transfer governance, inactive/cross-tenant denial, owner-removal guardbackendbackend/tests/work/test_project_members_channels.py
9Explicit members can read member-scoped projects; non-members remain hiddenbackendbackend/tests/work/test_project_members_channels.py
10Mobile owner/member/channel sections, server-capability gates, partial failure, and thread routingshared JVM + MaestroP60 mobile suite
11Web owner/member/channel sections, owner-only capability matrix, committed-response mutations, and deletion consequencefrontend + ChromiumP60 W01/W02/W05/W07
12Project update/delete are owner-only; org owner override can only transfer after explicit confirmation/auditbackend + web/mobileP60 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

#ErrorResponseNotes
ES1Missing required name400 {"name": ["This field is required."]}Backend independent of frontend <input required>
ES2Invalid visibility value400 {"visibility": ["\"invalid\" is not a valid choice."]}test_create_project_rejects_public_visibility (generalizes)
ES3Invalid status value400 {"status": ["\"invalid\" is not a valid choice."]}Fixed; test_patch_invalid_status_returns_400
ES4Cross-tenant project access404test_project_tenant_isolation; never 403 (no leak of existence)
ES5Non-member access to private project404; explicit project members can read itP60 P11 replaces the legacy owner-only test
ES6Non-team-member access to team project404test_team_project_not_visible_to_non_team_members
ES7Updating/deleting another org’s project404Queryset scoping; test_project_tenant_isolation
ES8Deleting a project with tasks and linked channelsExplicit irreversible confirmation names the linked-channel count; tasks get project_id=NULL, while linked channels/messages cascade so no frozen-ACL orphan remainstest_delete_project + P60 C13
ES9Frontend load failuretoast.error(t('error_load'))No E2E test
ES10Frontend create failuretoast.error(t('error_create')) — form preservedNo E2E test
ES11Name > 255 chars400 — DRF auto-validator; frontend has no maxLengthtest_create_project_name_too_long_returns_400
ES12Invalid team_id on create400 — FK validationtest_create_project_nonexistent_team_id_returns_400
ES13Cross-org owner_id accepted200 — Django doesn’t validate FK org consistencytest_create_project_cross_org_owner_accepted; no serializer guard
ES14Remove current owner409 owner_transfer_requiredNo rows or channel access change
ES15Transfer to inactive/foreign member404Do not disclose whether a foreign membership exists
ES16Duplicate project member400 already_project_memberStable validation; no duplicate rows
ES17Default linked channel archive/delete400 default_channel_requiredProject keeps one ready conversation
ES18Roster/channels partial mobile failureKeep loaded sections visible with inline retryNever blank the whole detail
ES19Create 51st linked channel409 linked_channel_limitExisting channels and memberships unchanged
ES20Readable project PATCH/DELETE by non-owner403; invisible private project remains 404No project/channel/message mutation
ES21Org-owner transfer override without confirmation409 owner_override_confirmation_required with consequenceNo mutation

10. Diagrams & Docs Touched

backend/DIAGRAMS.md Project state machine.

11. Implementation Audit (2026-06-12)

#ClaimStatusEvidence
1Title-only create (defaults: active, org visibility)✅ matchtest_create_project; serializer omits name from read_only
2Status + owner visible in list per row (no click required)✅ matchProjectSerializer exposes owner_name and status; test_list_projects
3visibility="public" removed; data-migrated to "organization"✅ matchMigration 0003_alter_project_visibility.py; test_create_project_rejects_public_visibility
4Status lifecycle UI (pause/archive/active inline select on card)✅ matchAdded 2026-06-14; ProjectsPage.tsx status <select>; E7 closed
5Team-visibility with team=None still follows explicit project membership (E2 superseded by P60 P12)✅ matchtest_owner_keeps_project_visible_when_team_visibility_has_no_team; owner/member still sees it, outsider does not
6Owner SET_NULL on member delete (E5)✅ matchon_delete=SET_NULL; test_owner_deleted_sets_project_owner_null
7Status: no FSM — any transition allowed (archived → active) by design✅ matchtest_patch_archived_project_to_active_succeeds; intentional
8start_date > due_date accepted — no cross-field validation⚠️ gaptest_start_date_after_due_date_accepted; no check_constraint or serializer guard
9?team_id= filter bypasses visibility (E14) — admin shortcut, not security boundary✅ matchBy design; test_team_id_filter_bypasses_visibility
10Cross-org owner_id accepted on create (ES13)⚠️ gapNo FK org validation in serializer; test_create_project_cross_org_owner_accepted
11Visual QC: Vietnamese project cards show localized status badges, not backend literals✅ match2026-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.md cases 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 shared StatusBadge, while preserving the existing badge color mapping.

Verification:

  • frontend/screenshots/06-projects/01-projects-list.png
  • pnpm test -- src/test/ProjectsPage.test.tsx
  • pnpm 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 in ProjectsPage.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" from VISIBILITY_CHOICES; migration data-migrates existing rows to "organization".
  • ProjectViewSet.get_queryset no longer matches visibility="public".
  • Removed “public” option from ProjectsPage.tsx and visibility_public i18n key.