GuidanceContent is both the domain aggregate and persistence model. Platform and tenant content share one table. Because TenantId is nullable, a normal global tenant filter would hide platform rows; each store query must express scope explicitly.
1. Table and indexes
All three indexes are lookup indexes, not business uniqueness. The database permits several Draft or Published rows for the same tenant/route/component/language/role. Sorting implicitly decides which row wins.
2. Field contract
| Field | Current constraint |
|---|---|
| ContentId | required, max 36; placeholder "0" is reassigned by store |
| RouteKey | required, trimmed, max 300 |
| ComponentId | optional, trimmed, max 120 |
| Title | required, trimmed, max 200 |
| BodyMarkdown | required, trimmed, large text |
| RequiredRole | optional single role, max 120 |
| TenantId | optional, max 36 |
| LanguageCode | max 10; zh-CN/en-US today |
| ContentVersion | positive; Create 1, Revise +1 |
| StatusName | strict Draft/Published text |
BodyMarkdown has no product-level size limit and no HTML, link, image, or dangerous-protocol validation. The server returns raw text; secure rendering remains an unfixed cross-client contract.
3. Create semantics
// Create trims fields and validates persistence column bounds.var created = GuidanceContent.Create( contentId: "0", routeKey: "/cases", componentId: "case-form", title: "Case entry", bodyMarkdown: "Enter the client and cause of action first.", requiredRole: "Attorney", tenantId: currentTenantId, languageCode: LanguageCode.EnUs, clock);
// The store replaces the placeholder with a persistent ID.if (created.IsFailure) return created.Error;Create Command derives TenantId specially: omission means the current tenant for a regular caller, but null platform content for the platform tenant. The platform tenant may explicitly target another tenant. The guard does not prove that the target tenant exists.
4. Language behavior
LanguageCode.Normalize explicitly recognizes en-US and maps every other input to zh-CN, including a typo, null, or a future locale. Create and queries share this behavior. Persistence Restore is strict and accepts only exact zh-CN/en-US.
A commercial API should reject unknown locale values and make fallback a product policy. Otherwise en-GB or zh-TW silently resolves to Simplified Chinese.
5. State machine
Repeated Publish returns Guidance.Content.AlreadyPublished. Revise requires a body and treats a null title as “keep current.” It always returns to Draft. Scheduled, Archived, Rejected, and Unpublished states do not exist.
6. Product consequence of same-row revision
Revising Published content immediately turns that row into Draft, so Contextual no longer finds it. The old body is overwritten. ContentVersion is only a counter on the current row, not a queryable revision. The system cannot show who published which body or roll it back.
GA should separate Draft from immutable PublishedRevision, or retain complete history plus a CurrentPublishedVersion pointer. Editing must not mutate the live read model.
7. Save semantics
The store allocates an ID for the placeholder and then chooses Add or Update by global ID. Save does not revalidate caller tenancy and accepts no expected version. Tenant safety depends on the handler guard and aggregate TenantId.
// The handler checks object tenancy before mutating the same-row aggregate.var content = (await store.GetByIdAsync(contentId, cancellationToken)).Value;var access = GuidanceApplicationGuards.EnsureCanMutate(currentUser, content!);if (access.IsFailure) return access.Error;
// Revise mutates this aggregate back to Draft; Publish mutates the same instance.var revised = content!.Revise(newTitle, newBodyMarkdown, clock);return revised.IsFailure ? revised.Error : await store.SaveAsync(content, cancellationToken);The base aggregate may expose a framework Version, but the HTTP command has no ETag/ExpectedVersion. Do not promise stable optimistic conflicts until real dual-ORM concurrent tests prove them.
8. Soft delete
Delete Handler reads and guards the aggregate, then calls DeleteWhereAsync(content.Id == contentId). The store returns no affected count and adds no TenantId to the delete predicate. IDs are intended to be global, but TOCTOU, repeated delete, and concurrent publish need integration evidence.
There is no restore API, retention rule, or history-reference policy. Existing AI conversations may retain the deleted body; content deletion does not close sessions.
9. Persistence restoration
Restore strictly checks required fields, lengths, language, positive version, enum value, and consistent IsDeleted/DeleteTime. An unregistered StatusName throws instead of becoming Draft. Tests cover this fail-closed behavior.
Migrations for the shared platform/tenant table also require globally unique ContentId. Recovery must verify null-Tenant semantics, status, soft-delete state, and framework Version.
10. Natural-key design
A target key should include scope, TenantId, RouteKey, ComponentId, LanguageCode, and AudiencePolicy. Several edit branches may have Drafts, but the current Published pointer must be unique. Filtered indexes require provider-specific migration evidence.
When RequiredRole evolves into Audience Policy, the key should reference a stable PolicyId/hash rather than a mutable role name.
11. Markdown security
Markdown may contain raw HTML, javascript: links, tracking pixels, oversized data URLs, nested payloads, or prompt injection. Persistence is not a sanitizer. Administration preview and end-user rendering must share an allowlist sanitizer, URL policy, CSP, and asset-proxy rules.
The model path needs a separate classification policy. Browser-safe Markdown is not necessarily appropriate to send to an external provider.
12. Test matrix
| Scenario | Current evidence | GA addition |
|---|---|---|
| required fields/length | aggregate implementation | HTTP error contract |
| unknown persisted state | application test | migration isolation |
| selection/soft delete | in-memory and dual ORM | concurrency/affected count |
| revise Published | implementation | live revision remains visible |
| duplicate natural key | unrestricted | unique conflict |
| concurrent revise/publish | absent | ETag/no lost update |
| Markdown attacks | absent | end-to-end rendering security |
| session after deletion | absent | prompt revocation/cleanup |
13. Review commands
# Aggregate fields, states, and same-row revision.rg -n "BitzTable|ContentVersion\+\+|Status = GuidanceStatus|BodyMarkdown" \ src/Platform/Guidance/BitzOrcas.Platform.Guidance.Contracts/Content -g '*.cs'
# Immutable publication, history, and concurrency APIs should have no matches.rg -n "PublishedRevision|CurrentPublished|Rollback|ExpectedVersion|ETag" \ src/Platform/Guidance -g '*.cs'Guidance overview · Contextual selection and authorization · Testing and GA