Comments attaches a thread to any business resource through (EntityType, EntityId) and forms replies through ParentCommentId. The current implementation provides tenant-scoped storage, four generic permissions, a client-supplied CommentId idempotency key, a 30-minute author edit window, soft-deleted placeholders, and migration from legacy Ticket.CommentsJson.
1. The boundary the module actually owns
Comments owns:
- the generic
SysCommentsupport row; - the CommentId idempotency key and parent reference;
- body, author, edit time, and soft-deletion state;
- the thread-tree DTO and deleted placeholders;
- author and hard-coded admin-role rules for edit/delete;
- one-time migration from Ticket.CommentsJson to SysComment.
It does not currently own subject existence or access decisions, moderation, mention parsing, notifications, spam prevention, attachments, reactions, search, pagination, events, retention policy, or GDPR erasure orchestration.
2. Current end-to-end structure
Contracts contains the public DTOs. Application owns the permission catalog, requests, handlers, and store port. Infrastructure implements that port with the provider-neutral IEntitySet<CommentThreadSupportRecord>. The module marker declares only an Authorization dependency, yet Application references Tickets.Contracts and Infrastructure directly references Tickets.Infrastructure for legacy migration. That is inconsistent with both the governance declaration and any claim of narrow-port isolation.
3. HTTP surface
| Method and route | Message | Permission action | Current rule |
|---|---|---|---|
POST /api/comments/ | CreateComment.Command | Create | non-empty inputs, Body ≤ 10,000, idempotency, valid parent |
GET /api/comments?entityType=&entityId= | GetComments.Query | View | load current-tenant rows including deleted, build tree in memory |
PUT /api/comments/{id} | UpdateComment.Command | Update | author within 30 minutes or admin role |
DELETE /api/comments/{id} | DeleteComment.Command | Delete | author or admin; clear body and soft-delete |
Update selects StandardCommand timeout. Create, Delete, and Get do not select an explicit timeout. None of the four routes declares a rate-limit policy. The {id} route value is the persistence Id, not the client-supplied CommentId; SDKs must retain the distinction.
The exact permission strings are comments.comment.view/create/update/delete. They can decide who may invoke Comments, but every ResourceDescriptor is only (comments, comment): it contains neither EntityType nor EntityId and cannot answer who may access ticket-42.
4. Data model
SysComment is a tenant- and soft-delete-aware table:
| Field | Limit | Meaning |
|---|---|---|
| CommentId | 36 | client idempotency key; may reuse the old TicketComment id |
| EntityType | 50 | free-form client string; there is no subject registry |
| EntityId | 36 | target resource id; no owner validation is performed |
| ParentCommentId | 36, nullable | the parent CommentId within the subject |
| AuthorId | 36 | UserId string; Create writes "0" when no user exists |
| Body | 10,000, nullable | bounded on create/update; cleared on deletion |
| EditedAt | nullable | last edit time; deletion actor/time/reason are not stored |
The unique index is (TenantId, EntityType, EntityId, CommentId). Additional indexes cover EntityType+EntityId, ParentCommentId, and AuthorId without an explicit TenantId prefix; validate real execution plans before relying on them for a large multi-tenant table.
5. Creating a root comment or reply
POST /api/comments/ HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "commentId": "019c64f8-55a7-7d7f-9da7-b60f53bcf9e7", "entityType": "Document", "entityId": "doc-42", "parentCommentId": null, "body": "Step two needs a rollback example."}The caller should generate CommentId once and reuse it on transport retries. The handler first looks up Tenant + Subject + CommentId and returns an existing row as success. A parent lookup includes the same TenantId, EntityType, and EntityId, so the normal HTTP path cannot create a cross-subject reply.
export async function createComment( subject: { type: string; id: string }, body: string, parentCommentId?: string,): Promise<CommentNode> { // Generate once before the first attempt; a timeout retry keeps this value. const commentId = crypto.randomUUID(); const payload = { commentId, entityType: subject.type, entityId: subject.id, parentCommentId: parentCommentId ?? null, body, };
for (let attempt = 1; attempt <= 2; attempt++) { const response = await fetch("/api/comments/", { method: "POST", credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), });
// Replay transport/server failures only; validation and authorization need action. if (response.ok) return response.json() as Promise<CommentNode>; if (response.status < 500 || attempt === 2) throw new Error(`comment create failed: ${response.status}`); } throw new Error("unreachable");}This is not the framework-wide IIdempotentRequest facility. Two first requests can both miss before insert; the unique index is the last guard, but the handler does not translate the concurrent unique violation into the existing result. Normal lookup also hides soft-deleted rows while the unique index can retain their key, so replay after deletion can become a database conflict.
6. Thread-query semantics
GetComments intentionally calls ListIncludingDeletedAsync so a deleted node can still hold visible replies. Its projection rules are:
- a null ParentCommentId makes a root;
- an orphan whose parent is absent is promoted to a root;
- children are ordered by CreateTime only;
- a deleted node has Body=null and IsDeleted=true but keeps Replies;
- a Create response has an empty Replies collection; fetch the thread to get its complete shape.
The query has no pagination, record cap, or depth limit. It loads the entire subject and recurses. A very deep chain can exhaust the stack, an imported cycle can recurse forever, and duplicate CommentId data makes ToDictionary fail. The normal unique index does not replace validation after imports or manual repair.
7. Most important current gaps
- EntityType/EntityId are free input, without a subject registry or owner policy.
- Documents.AllowComments, Ticket visibility/status, and equivalent owner rules are not enforced.
- Create/Get pass
User.TenantIdwhile the store also requiresEffectiveTenantId; impersonation can cause empty reads or broken idempotency. - Create does not require a user and falls back to AuthorId=
0. - Moderator behavior depends on the role string
admin, not a capability decision. - Handler validation does not cover identifier lengths declared by persistence metadata.
- Idempotency does not normalize concurrent insert or define replay after soft deletion.
- Threads have no paging, depth/reply limits, cycle guard, or deterministic tie-breaker.
- There are no create/edit/delete events, mentions, notifications, moderation records, or dedicated audit data.
- There is no feature catalog entry, so plans or tenants cannot switch Comments off through the feature system.
8. Reading path
- Thread lifecycle, idempotency, editing, and deletion
- Subject authorization, tenancy, and privacy
- Legacy Ticket-comment migration
- Testing, operations, and GA gates
Related chapters: Documents, Authorization, Multitenancy, and Auditing.
9. Source checks
# Routes, actions, and the generic permission catalog.rg -n 'GenerateEndpoint|AuthorizationAction|PermissionDefinition' \ src/Platform/Comments -g '*.cs'
# Subject policies, AllowComments, events, and notifications: expect no Comments hits.rg -n 'EnsureCanComment|AllowComments|CommentCreated|Notification|Mention' \ src/Platform/Comments -g '*.cs'
# Divergent tenant sources and the hard-coded administrator role.rg -n 'User.TenantId|EffectiveTenantId|AdminRole|\?\? "0"' \ src/Platform/Comments -g '*.cs'