Spec: F81 — Universal Comment System
Status: shipped (backend + frontend; E2E written, pending live-stack run)
1. Problem & CEO Job
Serves trust the signal and decide fast. Commenting is fragmented: Tasks use TaskComment,
Deals use DealComment, and Hub docs have none — DocViewer has no comment UI and Doc has no
comment model or route. Each discussion surface re-implements persistence, APIs, notifications, and
its React panel, so a CEO must leave a policy doc, brief, or risk for chat and lose the decision’s
context. F81 provides one system for every supported object (doc, task, deal, brief, decision, risk,
commitment, announcement, Pulse), with Notion-style document anchors, notifications, and shareable
links that reopen the focused comment.
2. User Stories & Acceptance Criteria
- As a member, I want to comment on a document, so that I can ask a question where the
content is.
- Given an open doc, when I post a comment, then it appears in the doc’s comment panel and is visible to other org members on that doc.
- Given a doc in another org, when I try to read or post its comments, then I get 404/403 (tenant isolation).
- As a member, I want to select text in a doc and attach an inline comment, so that the
discussion is anchored to the exact passage (like Notion/Google Docs).
- Given I select a passage and comment, then the comment stores a text anchor and the passage is visually highlighted; clicking the highlight opens that comment thread.
- Given the doc text later changes, when the anchored quote can no longer be found, then the comment is shown as “orphaned”/general (never lost, never crashes).
- As any participant, I want to reply to a comment and resolve a thread, so that finished
discussions collapse.
- Given a root comment, when I reply, then the reply nests under it ordered by time.
- Given a thread, when I resolve it, then it is marked resolved with who/when and hidden from the default (unresolved) view but still reachable.
- As a mentioned user, I want a notification with a direct link, so that I can jump
straight to the comment.
- Given a comment body contains
@name, when posted, then that member gets an in-appNotificationEvent(categorymention) whoseroutedeep-links to the object with?comment=<id>, and a push if theirpush_mentionspref is on. - Given I am a prior participant in a thread (author or replier) and not the commenter, when a
new reply is posted, then I get a
mention-style notification (thread follow) unless I muted it. - Given I open a
?comment=<id>link, then the object opens, the comment scrolls into view and is briefly highlighted.
- Given a comment body contains
- As the comment author, I want to edit or delete my own comment.
- Given my own comment, when I edit it, then
edited_atis set and the new body shows. - Given my own comment, when I delete it, then it is soft-deleted (body replaced with a tombstone)
so replies keep their context; others’ comments I cannot edit/delete (403) unless I have
the effective
members.managecapability.
- Given my own comment, when I edit it, then
3. Scope Boundaries
- In: one
commentsDjango app with a soft-polymorphicCommentmodel(target_type, target_id); list/create/edit/delete/resolve/reply API scoped per target;@mention+ thread-follow notifications with deep links; a reusable ReactCommentThread+useCommentshooks; doc-panel comments and doc inline comments wired intoDocViewer; partition-ready schema (see §4). - Out (deliberately, with homes):
- Migrating existing
TaskComment/DealCommentto the new table — not in this spec. F81 is additive; new surfaces useComment. A later migration spec can backfill. (Avoids a risky data-move on shipped features mid-feature.) - Rich text inside comments (markdown body only). A single file attachment per comment IS
supported (
Comment.uploadFK → presigned S3 upload), and the composer offers a basic emoji picker (inserts unicode into the body — not stored reactions). - Real-time websocket push of new comments — polling cadence reuses the F77 pattern (60s) + refetch on focus. WebSockets deferred (same call as F29).
- Stored emoji reactions on a comment (a reaction table) and comment search in global search (F80) — future. (Inline emoji in the body is supported; reactions are not.)
- Inline comments on surfaces other than Hub docs (tasks/deals get the panel only for now).
- Migrating existing
4. Data Model
New app apps/comments, one model Comment (extends UUIDModel → uuid7 PK, created_at/updated_at).
class Comment(UUIDModel):
TARGET_CHOICES = [
("doc", "Doc"), ("task", "Task"), ("deal", "Deal"),
("announcement", "Announcement"), ("brief", "Brief"),
("decision", "Decision"), ("risk", "Risk"), ("commitment", "Commitment"),
("pulse", "Pulse"),
]
organization = FK(Organization, CASCADE, related_name="comments") # tenant key — on every row
target_type = CharField(max_length=32, choices=TARGET_CHOICES) # soft polymorphic ref…
target_id = UUIDField() # …no ContentType coupling
parent = UUIDField(null=True, blank=True) # soft root id; tenant/target validated on write
author = FK(OrganizationMember, CASCADE, related_name="authored_comments")
body = TextField()
anchor = JSONField(default=dict, blank=True) # inline anchor; {} = panel/top-level thread
resolved_at = DateTimeField(null=True, blank=True)
resolved_by = FK(OrganizationMember, null=True, blank=True, SET_NULL, related_name="+")
edited_at = DateTimeField(null=True, blank=True)
deleted_at = DateTimeField(null=True, blank=True) # soft delete — preserves thread integrity
class Meta:
db_table = "comments"
ordering = ["created_at"]
indexes = [
Index(fields=["organization", "target_type", "target_id", "created_at"]), # list a target
Index(fields=["organization", "parent"]), # fetch replies
]
Inline anchor shape (W3C-style text selectors — robust on plain markdown, the no-regret choice; no
fragile block IDs since docs are body_md plain text):
{ "quote": "exact selected text", "prefix": "≤32 chars before", "suffix": "≤32 chars after",
"start": 1234, "end": 1290 }
Frontend re-anchors by searching quote (disambiguated by prefix/suffix); start/end are hints.
Quote not found → comment renders as general (orphaned), never lost.
Thread model: a root comment has parent = null (and may carry an anchor). A reply sets
parent to the root. resolved_* applies to roots. Replies inherit the root’s target.
Partition-readiness (explicit, MVP-simple, no-regret)
The table is designed so native Postgres partitioning is a mechanical later step, not a rewrite —
per ENGINEERING.md → Scale-Ready Design
(“make the future upgrade mechanical, don’t perform it now”). It
mirrors F29 messaging and F77 NotificationEvent:
- uuid7 PK (time-sortable via
core.models.uuid7) → keyset pagination + future range partitioning. organization_idon every row → the tenant hash-partition key; every query filters by it.- Keyset pagination from day one: list by
(created_at, id)watermark (?after=<id>), never OFFSET. (Notifications can adopt the same cursor later — mechanical.) - No inbound FKs to
commentsfrom other tables; targets are referenced out by(type,id)soft ref — so a partition swap never breaks a foreign key. - No global unique constraints or self-FKs (PK only).
parentis a soft UUID root id validated inside(organization,target_type,target_id)by the write path. - Composite indexes lead with
organization_idso they remain prunable per partition.
We do not create partitions now (no premature optimization). Documented future path: hash-partition
comments by organization_id (8–16 partitions) once a single tenant’s volume warrants it; the
ORM/queries need no change because they already filter by organization_id and paginate by keyset.
Notification audit result: NotificationEvent already satisfies 1–6 above (uuid7 PK, org on every
row, composite indexes leading with org+member, no inbound FKs, dedupe_key indexed-not-unique, bounded
top-N reads). No migration needed. Only gap: it lacks a keyset “load more” cursor — out of scope
here, mechanical to add later.
5. API Contract
Base: /api/comments/. Auth: IsAuthenticated, IsOrgMember. Every query filtered by
organization=request.tenant; writes set organization + author from the request, never the client.
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| GET | /api/comments/?target_type=doc&target_id=<uuid>&resolved=false&after=<id> | org member | query params | {results:[Comment], next_after: id|null} keyset, page 50, replies embedded under roots | 400 missing target params |
| POST | /api/comments/ | org member | {target_type, target_id, body, parent?, anchor?} | 201 Comment | 400 invalid target/body, 404 parent in other tenant |
| PATCH | /api/comments/<id>/ | author or members.manage | {body} | 200 Comment (sets edited_at) | 403 not author/capable, 404 cross-tenant |
| DELETE | /api/comments/<id>/ | author or members.manage | — | 204 (soft delete: deleted_at, body tombstoned) | 403, 404 |
| POST | /api/comments/<id>/resolve/ | org member | {resolved: bool} | 200 Comment (resolved_at/resolved_by set/cleared) | 400 if not a root, 404 |
Comment JSON: {id, target_type, target_id, parent, author, author_name, author_email, body, anchor, resolved_at, resolved_by, resolved_by_name, edited_at, is_deleted, created_at, reply_count, replies:[Comment]}.
Deep link: notifications carry route per target_type via a single comment_deeplink(target_type, target_id, comment_id) helper (e.g. doc → /hub?doc=<id>&comment=<cid>, task →
/work/projects/<proj>/tasks?task=<id>&comment=<cid> when project known, else /work/tasks?...).
No AI output → no EvidenceSource (n/a).
6. UI / UX
- Routes/components:
frontend/src/components/comments/CommentThread.tsx— reusable panel: list roots (unresolved by default, toggle to show resolved), composer with@-mention autocomplete, reply, edit, delete, resolve. Driven byuseComments(targetType, targetId).frontend/src/hooks/queries/useComments.ts— TanStack Query list + create/edit/delete/resolve mutations with optimistic add; query key['comments', targetType, targetId].frontend/src/components/comments/InlineCommentLayer.tsx— for docs: wraps rendered markdown, captures text selection → “Comment” affordance, paints highlights for anchored comments, click → opens that thread in the panel.- Wire into
frontend/src/pages/hub/DocViewer.tsx: a “Discuss”/comments toggle opening a rightCommentThreadfortarget_type="doc", plus the inline layer. Honor?comment=<id>(open panel, scroll, highlight).
- States: empty (“No comments yet — start the discussion”), loading (skeleton rows), error (retry), populated, no-permission (composer hidden for viewer role if applicable; read stays).
- shadcn:
Button,Textarea,Avatar,Badge,Popover(mention autocomplete + selection affordance),Tooltip,ScrollArea,Dialog(delete confirm) — all already incomponents/ui/. - i18n namespace
comments(en+vitogether). Keys:panel_title,composer_placeholder,post,reply,edit,save,cancel,delete,delete_confirm_title,delete_confirm_body,resolve,reopen,resolved_by,show_resolved,hide_resolved,empty_title,empty_body,orphaned_hint,mention_no_results,comment_on_selection,deleted_tombstone,error_load,error_post,success_resolved,reply_count. - DESIGN.md: use
bg-card,text-foreground,text-muted-foreground,border-border, highlight usesbg-[var(--color-amber-soft)]; TASTE.md pre-flight applies (intentional hover/focus on highlights, hierarchy via author/time scale).
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Create + list comments on a doc target (happy path) | backend | backend/tests/test_comments.py::TestComment |
| 2 | Cross-tenant isolation: org B cannot read/post/patch/delete org A’s comments | backend | backend/tests/test_comments.py |
| 3 | Reply nesting + reply_count; replies embedded under root | backend | same |
| 4 | Resolve/reopen sets/clears resolved_at/resolved_by; resolve on non-root → 400 | backend | same |
| 5 | Edit by author sets edited_at; edit by non-author/non-admin → 403; owner/admin can | backend | same |
| 6 | Soft delete tombstones body, keeps replies; only author or members.manage | backend | same |
| 13 | Mobile task/deal rows expose edit/delete to authors and members.manage, including custom roles | Android JVM | TaskDetailScreenModelTest, DealDetailScreenModelTest |
| 14 | Mobile capability lookup failure fails closed for non-authors while author ownership remains valid | Android JVM | same |
| 7 | @mention creates NotificationEvent(category=mention) with ?comment= route; thread-follow notifies prior participants, not the commenter | backend | backend/tests/test_comments.py |
| 8 | Keyset pagination: after=<id> returns next page, no overlap | backend | same |
| 9 | Missing target_type/target_id → 400 | backend | same |
| 10 | useComments + CommentThread render/post/reply/resolve (MSW) | frontend unit | frontend/src/test/CommentThread.test.tsx |
| 11 | Inline anchor: select text → comment → highlight; orphaned anchor renders general | frontend unit | frontend/src/test/InlineCommentLayer.test.tsx |
| 12 | Doc discuss flow: open doc, post comment, see it; ?comment= focuses it | e2e | frontend/tests/e2e/lumi.spec.ts describe("Document comments") |
Backend coverage must stay ≥97% (apps/+core/); aim ≥99% on the new app. New E2E describe required.
8. Current Behavior
The universal comments backend and web document surfaces are shipped. Task and deal mobile detail
screens consume the universal /api/comments/ endpoint, including author self-edit/delete and
members.manage-gated non-author actions. DocViewer still owns the web document comment UI; the
“Discuss” button exists only on AnnouncementCard and merely navigates to
/messages?topic=<title> (no real thread linkage).
9. Error States
- Load failure → panel shows error state with retry;
toast.error(t('error_load')); query retries off for mutations, on for reads (TanStack default). - Post/edit/delete failure → optimistic update rolled back;
toast.error(t('error_post')). - Cross-tenant / not-author-or-capability → backend 403/404; frontend surfaces generic error, never leaks existence.
- Notification send failure is swallowed server-side (never breaks comment creation) and logged —
mirrors
_notify_comment_mentionsinapps/work/views.py. - Orphaned inline anchor → comment shown as general with
orphaned_hint; no crash.
10. Diagrams & Docs Touched
backend/DIAGRAMS.md— addcommentsapp +Commentto the ER/app map; note soft-polymorphic ref.frontend/DIAGRAMS.md— noteCommentThread/InlineCommentLayershared components;?comment=deep-link handling in Hub route.GLOSSARY.md— add Comment (universal), Inline comment / anchor, Thread / resolve; disambiguate fromTaskComment/DealComment.docs/MVP_SCOPE.md— new F81 row.- ADR — add ADR-019: Soft-polymorphic universal comments (target_type,target_id) over GenericForeignKey; partition-ready by organization_id. Records the no-regret DB decision.
11. Implementation Audit (2026-07-14)
The canonical Contract Graph declaration is contracts/features/F81.cue. The current code-vs-doc audit is F81-universal-comments-audit.md. The API-focused contract owns all five comment endpoints and collected backend proof; browser/native runtime and live notification delivery evidence remain explicit gaps.
Built (backend + frontend; E2E written, pending live-stack run).
Shipped and verified:
- Backend
apps/comments:Commentmodel + migrationcomments.0001_initial, keyset-paginatedCommentViewSet(no DRF OFFSET), serializer,services.py(mention + thread-follow notifications viaparse_mentions+send_push_to_member,comment_deeplinkwith?comment=). Registered in INSTALLED_APPS +config/urls.py. 51 tests pass (count corrected 2026-07-02; the suite grew from the 34 recorded at the 2026-06-21 audit as visibility/upload/backfill/validation cases were added — see §13), new app 100% covered, suite ≥97%. Usesrequest.organization_id/request.membership(this repo’sIsOrgMemberattrs; there is norequest.tenant) — isolation enforced identically. - Frontend:
commentsApi,useComments(optimistic + 60s poll),CommentThread,MentionComposer,InlineCommentLayer,lib/commentAnchor.ts; wired intoDocViewer(Discuss toggle + inline layer +?comment=focus/flash). i18ncommentsns (en + real vi; English fallback copied to other locales per repo pipeline). 15 unit tests pass (count corrected 2026-07-02:CommentThread.test.tsx12 +InlineCommentLayer.test.tsx3), tsc clean, design-tokens guard clean. - Deep-link completion (added during integration):
DocsTabnow auto-opens the doc named by?doc=, andHubPageopens the Docs tab when?doc=is present — without these, a comment notification deep link landed on the Announcements tab and never opened the doc/comment.
Known deviations (accepted):
InlineCommentLayersurfaces anchored comments as clickable amber chips (orphaned = strikethrough) rather than painting in-text background spans — Range surgery over sanitized markdown HTML is fragile/a11y-risky. Anchor/orphan logic is fully implemented + unit-tested; upgradeable to span wrapping later without touching the pure anchor lib.ScrollArea/Tooltipshadcn primitives don’t exist incomponents/ui/; usedoverflow-y-auto+title/ariainstead (shadcn-only rule → use what exists).- E2E
describe("Document comments (F81)")added tofrontend/tests/e2e/lumi.spec.tsbut not run here (no local backend on :8000; the dev frontend points at the shared dev API). Verify withmake e2ebefore flipping the E2E registry cell to ✅.
Pre-existing/unrelated (not F81): test_multitenancy.py::TestInvitationLinks::test_create_invitation_link
fails on a clean tree (stale max_uses==1 assertion vs the invite-default change in ba1cfad0/791f06e0);
a pending crm activity_type migration also predates this work.
12. Open Questions / Decisions
- Decided: soft-polymorphic
(target_type, target_id)over DjangoGenericForeignKey— the repo avoids ContentType entirely, and soft refs are partition-friendly (no cross-table join, nodjango_content_typecoupling). → ADR-019. - Decided: additive — do not migrate
TaskComment/DealCommentnow. - Open: whether tasks/deals should switch their panels to
CommentThreadlater (tracked in ADR-019 follow-ups), and whether to add a global/c/<id>resolver route (deferred; per-target routes suffice).
13. Bug / Change Log
2026-07-19 — Pulse threads and first-comment author notification
Type: Feature extension
Summary:
- Added
pulseas a universal-comment target. Create and list validate both tenant ownership and the caller’s F103 Pulse visibility before exposing the thread. - The first peer comment now notifies the Pulse author explicitly. Self-comments remain restrained; mention and prior-participant de-duplication still apply.
- Notification routes include the member, Pulse, and comment identifiers; Android and iOS resolve the opaque route only through their typed allowlisted Pulse-thread destinations.
- Shared KMP Pulse history loads one discussion lazily at a time to avoid an N+1 comment fan-out.
Proof: backend/tests/pulse/test_pulse_comments.py and
android/shared/src/commonTest/.../PulseThreadScreenModelTest.kt.
2026-07-14 — Contract Graph ownership and proof alignment
Type: Documentation and proof-traceability change
Summary:
- Added canonical
contracts/features/F81.cueownership for all five/api/comments/surfaces. - Attached stable proof IDs for tenant isolation, replies, visibility, edit/delete authorization, resolution, pagination, upload linkage, and notification-event creation.
- Kept browser/native runtime and live notification delivery gaps explicit rather than inferring them from source inspection or backend tests.
2026-07-13 — Comment parent made a validated soft reference
The parent wire field remains the same nullable UUID, but no longer creates a self-FK
that must be rewritten for an organization-hash partition. Reply lookup, re-rooting,
visibility, tenant validation, serialization, and soft-delete behavior remain unchanged.
2026-07-02 — Markdown rendering for comment bodies on mobile (KMP)
Type: Enhancement (mobile)
Summary:
- The shared Compose (KMP) mobile shell now renders comment bodies as formatted
markdown instead of raw text. Added a single reusable themed component
LumiMarkdownText(incom.locuno.lumi.shared.ui, wrappingcom.mikepenz:multiplatform-markdown-renderer-m3:0.43.0) skinned with Lumi design tokens, and wired it intoCommentRowinandroid/shared/src/commonMain/kotlin/com/locuno/lumi/shared/ui/taskdetail/TaskDetailScreen.kt. Renders bold/italic, headings, lists, links, inline code, code blocks, blockquotes, and horizontal rules; blank input renders nothing; plain text renders as a paragraph. - Same component adopted across other authored bodies (announcements, messages, chat, brief) so all long-form surfaces share one markdown renderer.
Reason: comment bodies are authored markdown (body_md); mobile previously showed raw syntax.
Impact: iOS + Android + desktop (shared Compose) comment surfaces render formatted markdown.
2026-07-02 — Spec drift fix: correct §11 test counts (backend 34 → 51, frontend 17 → 15)
Type: Documentation (spec accuracy, docs-only)
- §11 recorded 34 backend tests and 17 frontend unit tests from the 2026-06-21 audit. Actual today:
backend/tests/test_comments.pyhas 51 tests (grew asTestCommentVisibility/TestCommentUpload/TestCommentBackfillMigration/TestListValidationcases landed — see the dated entries below), and the frontend has 15 (CommentThread.test.tsx12 +InlineCommentLayer.test.tsx3). §11 counts updated to match the files. The historical point-in-time counts in earlier §13 entries are left intact.
2026-07-02 — Spec drift fix: checked all §2 ACs; status updated to shipped
Type: Documentation (spec accuracy — ACs were [ ] despite §11 confirming all 11 items shipped).
Summary:
- All 11
[ ]ACs in §2 flipped to[x]— confirmed shipped against §11 Implementation Audit (backend 34 tests ✅, frontend 17 unit tests ✅, deep-link +?comment=focus working ✅). - Spec status updated from “agreed” → “shipped (backend + frontend; E2E written, pending live-stack run)”.
- No code changes.
2026-06-21 — Comment attachments + emoji picker
Type: Feature
Summary:
Commentgains an optionaluploadFK (outbound →uploads.Upload, partition-safe per ADR-019); create validates the upload is org-owned + completed; a comment may carry text OR an attachment. Serializer exposesupload,upload_filename,upload_content_type,upload_byte_size,upload_url.- Frontend
CommentThreadcomposer: attach-a-file button (presign → S3 → complete, reusing F34 upload hooks) with a pending chip, attachment link rendered under each comment; a basic emoji picker inserts unicode into the body. New i18n keys:attach_file,remove_attachment,add_emoji.
Reason:
- User request: “users can upload attachment when comment, can use some basic emoji?”
Impact:
- All comment surfaces (doc/task/deal/…) gain a single attachment + emoji insertion. No reactions table.
Tests:
- backend
test_comments.pyTestCommentUpload (upload required-completed, org-owned, text-or-upload). - frontend
CommentThread.test.tsx: emoji insertion + attachment-link render.
2026-06-21 — Consolidate TaskComment + DealComment into universal Comment
Type: Refactor · Tech-debt removal (ADR-019 §3 amendment)
Summary:
Commentgainsvisibility(shared|private, defaultshared) andorigin(user|system|ai, defaultuser) — ported fromDealComment(F50 §2.6). The list view ANDs a visibility filter for non-managers (_is_manager): they seeshared+ their ownprivate; managers see all. Applies to roots AND replies, everytarget_type.Commentgains an optionaluploadFK (org-owned + completedUpload); a comment must carry text OR an attachment.- Data migration
comments.0003_migrate_task_deal_commentsbackfillswork.TaskComment→Comment(target_type="task")andcrm.DealComment→Comment(target_type="deal", visibility, origin), preservingcreated_at(auto_now_add set on insert, then corrected via queryset.update()which bypasses auto_now, in batches of 500). Idempotent: skips a row matching(org, target, author, body, created_at).DealComment.originimport/confirmation_linkcollapses to the narrowersystem. - Removed:
TaskCommentmodel/serializer/admin +TaskCommentViewSet(/api/task-comments/) + theTaskViewSet.comments/remove_commentnested actions +_notify_comment_mentions;DealCommentmodel/serializer +DealViewSet.comments/delete_commentactions +_notify_mentions+ the now-unused_is_org_manager. Table drops:work.0013_delete_taskcomment,crm.0018_alter_crmactivity_activity_type_delete_dealcomment(both depend oncomments.0003so the backfill runs first).ORIGIN_CHOICESkept inapps/crm/models.py(still used byDealActivity).
Reason:
- Two parallel comment tables were tech debt and
TaskCommenthad noorganization_id(a tenant-key gap on a high-growth table). Consolidating now, while volumes are small, is cheap.
Impact:
- All comment traffic flows through
/api/comments/; the legacy per-surface endpoints are gone (the frontend is built in parallel against/api/comments/only). No data loss — created_at and author preserved; deal visibility/origin preserved.
Tests:
test_comments.py:TestCommentVisibility(7),TestCommentUpload(6),TestCommentBackfillMigration(migration-executor end-to-end: seed legacy rows via historical models, migrate forward, assert target/visibility/origin/created_at). Old endpoint tests intest_work_graph.py,test_crm_extensions.py,test_coverage_gaps_r01.pyremoved (behavior now covered here). Full suite: 3305 pass (1 pre-existing unrelatedtest_create_invitation_linkfailure), coverage 98.96%.
2026-06-21 — Post-merge code-review hardening
Type: Bug Fix · Performance Fix
Summary:
- Validate
target_id/after/parentas UUIDs at the API boundary → malformed ids now return 400 instead of 500 (DjangoUUIDFieldraisedValueError). - Mutation echoes (create/edit/resolve) attach an empty
_replies, so the serializer’s reply fallback never fires an unbounded SELECT (_serialize_onehelper). Client refetches the list. notify_commentbulk-fetches mention + thread-follow members in twoid__inqueries instead of one SELECT per participant.- Soft-delete tombstone is now the language-neutral sentinel
"[deleted]"(never user-facing; the frontend renders the localizeddeleted_tombstonekeyed offis_deleted).
Reason:
- Code review (0 CRITICAL, 1 HIGH = the 500s; plus N+1 and i18n MEDIUM/LOW). Tenant isolation, permission gates, and notification-swallowing were confirmed correct — no change needed there.
Impact:
- No API-shape change. Malformed ids fail cleanly; fewer queries per comment create on busy threads.
Tests:
test_comments.py: 3 new malformed-UUID→400 cases (TestListValidation). 37 pass; app 99% covered.
2026-06-21 — Universal comment system (F81) created
Type: Feature
Summary:
- New
commentsapp: soft-polymorphicCommentmodel, keyset-paginated API (list/create/edit/delete/resolve/reply),@mention+ thread-follow notifications with?comment=deep links. ReusableCommentThread+useComments; doc panel + Notion-style inline comments wired intoDocViewer. Partition-ready schema (uuid7, org key, keyset, soft refs).
Reason:
- Documents could not be commented on at all (“click discuss, still cannot comment”); commenting was fragmented across per-surface tables. One scalable system unifies it.
Impact:
- Hub docs gain comments (panel + inline). Other surfaces can adopt
CommentThread. No change to existingTaskComment/DealCommentrows. No notification migration (already partition-ready).
Tests:
backend/tests/test_comments.py(isolation, threading, resolve, edit/delete, mentions, keyset),frontend/src/test/CommentThread.test.tsx,InlineCommentLayer.test.tsx, e2eDocument comments.
Notes:
- Inline anchors use W3C text-quote selectors (robust on plain markdown); orphaned anchors degrade to
general comments. Future: partition
commentsbyorganization_idwhen a tenant warrants it — mechanical, no query change.
2026-07-12 — Mobile delete affordance capability parity
Type: Bug Fix · Mobile parity
Summary:
- Shared task and deal comment rows now grant edit/delete to the author or users with the backend
members.managecapability. Preset role names are no longer used as a client-side proxy. - Task detail reuses its existing effective-capability lookup; deal detail now loads the same
/api/permissions/me/contract. A failed lookup fails closed for other users while preserving author self-edit/delete.
Reason:
- Task detail hid custom-role managers who had
members.manage, while deal detail passed an empty role and hid every non-author delete action, even when the backend would allow it.
Tests:
- Shared JVM model tests cover capability granted, missing, and failed lookup paths for task/deal
detail;
CommentSectionderives the row affordance from author ownership plus the capability.