The authoritative read path uses unified aggregates and QueryShape. Search, cache, file, and cloud-drive services are at different maturity levels. Before relying on an interface, verify who calls it, whether it has an HTTP endpoint, and whether it persists anything.
1. Read path
Lists use dedicated projections and cap PageSize at 100. Details load complete aggregates, including embedded JSON. Main-resource reads use User.TenantId, not EffectiveTenantId, and apply neither member nor ACL predicates.
| Query | Read behavior | Boundary |
|---|---|---|
| Document detail | Tenant + Id, full aggregate | includeVersions is ignored |
| Document list | Tenant plus optional KB/category/status/type/keyword | Keyword covers Title/Description only |
| Knowledge-base tree | Load all tenant KBs, recurse in memory | No access filter or cycle detection |
| Category tree | Load Tenant + KB categories, recurse in Application | No cycle or access guard |
| Whiteboard detail/list | Tenant predicate | Detail includes full Data; members ignored |
| Collaboration session | Id or DocumentId + Active | No tenant predicate |
2. In-memory search algorithm
DocumentSearchService is not an external full-text index. On a cache miss it reads the first page of at most 200 summaries, filters and scores them in process, then paginates that subset.
Title contains adds 0.7, title prefix adds 0.2, short-title density adds up to roughly 0.09, and description contains adds 0.3, capped at 1.0. Content is never searched. Documents after candidate 200 are invisible, and TotalCount means “matches in this subset,” not the tenant-wide result count.
3. Search has no HTTP route
Search and suggestion messages implement IAuthorizedRequest but lack [GenerateEndpoint]. The following is an in-process Mediator call, not evidence of /api/v1/documents/search:
// This message is available through Mediator, not a generated HTTP route.var result = await mediator.Send( new SearchDocumentsQuery( Keyword: "refund", KnowledgeBaseId: "kb-support", CategoryId: null, ContentType: ContentType.Markdown, Status: DocumentStatus.Published, Tags: ["support"], PageIndex: 1, PageSize: 20), cancellationToken);
// Preserve dependency failure instead of presenting it as a real empty result.if (result.IsFailure) return Result.Failure<SearchView>(result.Error);
// Results cover at most the first 200 candidates returned by the read model.return SearchView.From(result.Value!);An HTTP product route needs explicit endpoint generation, resource visibility, input limits, stable ordering, real totals, and contract tests.
4. Highlighting and XSS
The highlighter inserts <mark> into raw Title/Description without HTML encoding. If the UI renders the result as raw HTML, an original title such as <img src=x onerror=alert(1)>refund remains executable.
Prefer structured fragments or ranges:
type HighlightPart = { text: string; matched: boolean };
export function renderTitle(parts: HighlightPart[]): HTMLElement { const container = document.createElement("span");
// Structured ranges choose element boundaries; source values remain text only. for (const part of parts) { const node = part.matched ? document.createElement("mark") : document.createElement("span"); // textContent encodes untrusted source text. node.textContent = part.text; container.append(node); }
return container;}Until the response contract changes, clients must not feed the highlight string directly to innerHTML.
5. Cache and index-event gaps
Search cache TTL is two minutes. The key contains raw tenant, keyword, filters, sorted tags, and page values, exposing search terms to cache diagnostics and allowing oversized input to expand keys.
When the read model fails, ExecuteSearchAsync returns an empty successful result. The outer GetOrSetAsync can cache that result despite the comment saying failures are not cached, creating a two-minute false negative.
Five event contracts and subscribers exist, but no producer for those integration events was found. The aggregate raises domain events only for Publish and Archive, and no bridge to the five integration contracts was found. The subscriber suppresses non-cancellation exceptions to prevent retry storms.
DocumentCacheService provides a tenant-scoped ten-minute detail cache, but no query/command handler calls it. It is registered infrastructure, not active read caching.
6. The document file service is not Files
| Method | Actual behavior | Not provided |
|---|---|---|
| UploadAsync | Validates basic fields, logs a warning, returns stream length | No byte persistence or StorageKey update |
| DownloadAsync | Returns UTF-8 bytes of Document.Content | No object-store read or conversion |
| PDF/Word branch | Labels text bytes with PDF/DOCX MIME and extension | Not a valid PDF or DOCX file |
// Store and finalize binary bytes through Files first.var asset = await files.UploadAndFinalizeAsync(source, cancellationToken);
// Documents owns only the business relationship to a stable FileId.var attachment = DocumentAttachment.Create( documentId, asset.FileId, displayName: "refund-evidence.pdf");
// Download authorization and temporary URLs remain in Files.return await files.GetDownloadAccessAsync(attachment.FileId, cancellationToken);Markdown-to-PDF requires an explicit rendering/export use case that generates real bytes and stores them through Files.
7. Dormant storage and view fields
Document.SetStorage and IncrementViewCount exist. Source search finds the aggregate definitions and unit coverage for view count, but no application handler calls them. StorageKey/FileSize may remain defaults, details do not increment views, and ViewCount must not drive billing, recommendation, or SLA reporting.
A separate, deduplicated analytics event path avoids write contention on the document aggregate.
8. Cloud-drive port
ICloudSyncPort exposes Upload, ListFiles, Search, and ProviderName. CloudDriveAdapter maps a vendor ICloudDriveProvider into neutral Id/Name/Size/ContentType/DownloadUrl/ModifiedTime records.
The adapter has no registration attribute, no observed host registration for ICloudSyncPort, and no Documents caller. A real sync product still needs credential ownership, remote-path mapping, cursors, conflict/deletion policies, idempotency, retry/rate limits, reconciliation, URL security, scheduling, and operator status.
9. Production indexing shape
Keep the in-memory service as a development/small-data fallback. A production index needs stable EventId idempotency, tenant partitions, ACL visibility, retryable failures, and an index-rebuild operation.
10. Required tests
- recall and TotalCount with more than 200 documents;
- dependency failures must not become cached empty successes;
- HTML, Unicode, long terms, and cache-key limits;
- index visibility after member/ACL changes;
- a true lightweight projection for includeVersions=false;
- PDF/Word responses cannot be mislabeled text;
- cloud-drive duplicate, throttled, and partial-failure paths;
- tenant predicates on every collaboration-session query.