A folder template consists of a root record, its current nodes, and historical node snapshots. This is an explicit multi-table persistence model rather than a one-to-one domain aggregate mirror. The store owns trusted-tenant propagation, key remapping, and replacement writes.
1. Three-table model
The root and nodes carry soft-delete metadata. Deleting a template physically removes current nodes and marks the root deleted. Historical versions are not physically deleted with the root. No public API reads that history, so snapshots are persistence evidence today, not a usable versioning product.
2. Graph invariants
ValidateNodeGraph is shared by Create and Update. It verifies:
- every ID is nonblank;
- IDs are request-unique with ordinal comparison;
- each nonblank ParentNodeId names another request node;
- walking from any node to the root does not form a cycle;
- depth, including the node itself, does not exceed
MaxNodeDepth = 10.
It does not validate field lengths or formats for Name, expression, icon, color, or description. It does not cap total nodes, forbid sibling duplicates or repeated SortOrder, or allowlist TargetType. Oversized values may surface only as persistence failures.
var nodes = new TemplateNodeDefinition[]{ // Request IDs are local graph handles, not stable external identifiers. new("root", null, "Root", null, null, null, null, 10), new("phase", "root", "Phase", null, null, null, null, 20), new("deliverable", "phase", "Deliverable", null, null, null, null, 30)};
var result = await mediator.Send(new CreateFolderTemplate.Command( "Project layout", null, "KnowledgeBase", nodes), cancellationToken);
// Successful response nodes contain the final persistent IDs.return result.Value!.Nodes;3. Temporary-ID remapping
The store preallocates a final ID for every NewTemplateNodeRecord.Id, builds a lookup, remaps each ParentNodeId to its parent’s final ID, and then calls AddRange. The algorithm does not depend on database-generated key order and can be shared by both ORM adapters.
If a parent is not in the lookup, ResolveParentId raises a storage error. The normal handler rejects this earlier. The store also repeats nonblank/unique ID checks, providing defense at the persistence boundary.
4. Trusted tenant boundary
Read methods require the caller-supplied TenantId to match ICurrentTenant.Tenant.EffectiveTenantId. Writes use EffectiveTenantId instead of trusting an external tenant field. Update invokes EnsureCurrentTenant and EnsureSnapshotOwnership, rejecting roots or nodes from another tenant when constructing history.
var template = await store.GetTemplateAsync( currentUser.User.TenantId, templateId, cancellationToken);
// Do not accept a client tenant ID and pass it directly into a write.if (template is null) return DocsErrors.Template.NotFound(templateId);5. Create semantics
Create fixes the root version at 1.0.0 and IsActive = true. An empty TargetType falls back to KnowledgeBase; any other string is stored unchanged. The application pre-checks duplicate names, while the database enforces unique (TenantId, Name).
Two concurrent creates can both pass the pre-check before one loses the unique race. Unless shared exception mapping identifies that index, the loser may receive an infrastructure error rather than Docs.Template.NameAlreadyExists. Both ORMs need a real concurrent test.
6. Update and version-number behavior
Update validates the new graph, loads the root, checks name conflicts, loads the old nodes, and increments the final component of CurrentVersion. 1.0.0 becomes 1.0.1; 1.2 becomes 1.3; an unparsable last component becomes 1. This is not complete Semantic Versioning and carries no major/minor product meaning.
The store appends an old-node snapshot, updates root fields/version, deletes current nodes, then inserts the new nodes. Atomicity depends on the command transaction pipeline and adapter using the same unit of work. The store method does not open its own transaction.
7. Snapshot payload
The JSON snapshot includes old node Id, ParentNodeId, Name, DynamicNameExpression, Icon, Color, Description, and SortOrder. Serialization uses generated DocumentStructureJsonSerializerContext, preserving AOT compatibility.
It omits template Name, Description, TargetType, IsActive, SnapshotSchemaVersion, actor, and policy version. A future reader cannot reconstruct the complete historical template. Old persistent node IDs also require remapping during rollback and cannot simply be copied into another template.
{ "snapshotSchemaVersion": 1, "templateVersion": "1.0.1", "template": { "name": "Project layout", "targetType": "KnowledgeBase" }, "nodes": [ { "id": "old-node-id", "parentNodeId": null, "name": "Root", "sortOrder": 10 } ]}This envelope is a target contract, not the current serialized shape.
8. Replacement writes and concurrency
Two editors can both read 1.0.0, calculate 1.0.1, append snapshots, and let the later commit overwrite the earlier node set. The root has no conditional version update, and history has no unique (TenantId, TemplateId, VersionNumber) constraint. Commercial GA requires an ETag/version precondition and a typed conflict result.
Replacement cost grows with node count and increases lock and transaction-log pressure. Measure hundred- and thousand-node templates before choosing among full replacement, a diff-based update, or immutable PublishedVersion plus Draft.
9. Delete semantics
Delete loads the tenant-scoped template, removes current nodes, and soft-deletes the root. Historical versions remain, but no restore-template API exists. Favorite references to the template remain. Categories already created by Apply remain and carry no TemplateId/version provenance.
Delete racing with Update or Apply is unspecified. Apply may read IsActive, another request may delete the template, and the first request may continue creating categories.
10. Choose an explicit target version model
Two coherent options are available:
- Mutable template: every update stores a complete snapshot and exposes history, diff, and rollback; Apply records the exact version.
- Immutable published versions: editors change a Draft, publication creates an immutable version, and Apply accepts only Published.
Both options require snapshot schema versioning, concurrency tokens, complete metadata, actor/time, change notes, rollback audit, and migration for old snapshots.
11. Evidence matrix
| Scenario | Current evidence | GA addition |
|---|---|---|
| duplicate temporary ID | handler test | HTTP error contract |
| missing parent | handler test | deep/cross-template references |
| cycle and depth | implementation | depth 10/11 and large-graph tests |
| key remapping | handler/store tests | dual-ORM parity |
| cross-tenant snapshot | store test | HTTP/integration attack |
| concurrent same name | none | unique-conflict mapping |
| concurrent update | none | ETag and no lost update |
| insert failure mid-update | none | transaction rollback |
12. Review commands
# Graph, version, snapshot, and replacement-write surfaces.rg -n "ValidateNodeGraph|MaxNodeDepth|NodesSnapshotJson|DeleteWhereAsync|AddNodesAsync" \ src/Platform/DocumentStructure -g '*.cs'
# Version reading, rollback, and concurrency contracts should currently have no matches.rg -n "ListTemplateVersions|RollbackTemplate|SnapshotSchemaVersion|ConcurrencyToken" \ src/Platform/DocumentStructure -g '*.cs'DocumentStructure overview · Template preview and apply · Testing and GA