Numbering safety depends less on formatting than on choosing the next value under a tenant, business field, and prefix. The current source uses an allocation-fact table and final uniqueness, not a database sequence.
1. Counter partition
The query key is TableName + Field + Prefix; the adapter adds Tenant. Prefix contains every non-number segment, so date, branch, and business-field changes create independent partitions.
The implementation loads every matching fact through ListAsync and computes Max in memory. It does not execute database MaxAsync, so large partitions cause read amplification.
2. Allocation fact
A new fact stores TableName, Field, SequenceNumber, full string, Prefix, IsActivated, IsSubNumber, and ParentSequenceNumber. The unique index covers the full string, not Prefix+SequenceNumber.
If a changed format maps different counters to one string, the final index still rejects the duplicate.
3. Concurrent conflict
Under favorable transaction visibility this prevents a committed duplicate string, but it is not an atomic increment. Whether a transaction remains usable after a unique violation depends on adapter, database, and outer transaction behavior.
4. Exception recognition
IsUniqueConstraintViolation uppercases the message and matches UNIQUE CONSTRAINT, UNIQUE KEY, DUPLICATE KEY, CANNOT INSERT DUPLICATE KEY, 2627, or 2609.
It does not inspect structured provider codes or unwrap inner exceptions. Localized, non-SQL-Server, or wrapped errors can bypass retry.
5. Three retries
Only _records.AddAsync catches unique conflicts. Business-table duplicate checks also loop at most three times, but do not write a fact that advances MAX. When business data contains the candidate and facts do not, each retry can regenerate the same value and exhaust.
The comment that inconsistent business data is automatically skipped is therefore stronger than the algorithm.
6. Business MAX continuation
When a fact partition is empty, the generator can call an owner implementation:
public sealed class InvoiceSerialContext(IInvoiceReadStore invoices) : IBusinessSerialContext{ // ① The business owner parses the prefix and runs a parameterized query. public Task<long> GetMaxSequenceFromBusinessTableAsync( string prefix, CancellationToken cancellationToken) => invoices.GetMaxSerialCounterAsync(prefix, cancellationToken);
// ② Numbering receives only a duplicate Boolean, not table-query access. public Task<bool> ExistsInBusinessTableAsync( string value, CancellationToken cancellationToken) => invoices.SerialExistsAsync(value, cancellationToken);}Never concatenate request.TableName into SQL. The callback exists specifically to keep query ownership safe.
7. Continuation race
Two first calls can both see no facts, read the same business MAX, and race to insert. The unique index lets one retry; an isolation level that cannot see the other commit may still exhaust all attempts.
Migration should preload a waterline or establish it in a dedicated transaction instead of leaving first-time continuation to a hot request path.
8. Placeholder mode
SequenceNumberRequest.IsEnabled=false inserts a fact with IsActivated=false. It still participates in MAX, so it becomes a gap unless compensation is enabled.
There is no public API to activate a selected placeholder; only a compensating rule can pick the smallest inactive fact automatically.
9. Compensation
On the first attempt with IsCompensate=true, the store finds the smallest inactive, non-deleted fact under the same Table/Field/Prefix, assigns IsActivated=request.IsEnabled and ModifyTime, then returns it.
Passing false again leaves the fact inactive, so a later call can compensate it again.
10. Compensation race
Compensation is List→First→Update without a version or conditional activation predicate. Concurrent requests can select the same row and both return success. This path also skips the business-table duplicate callback.
Commercial use requires an atomic claim and affected-row check before returning a value.
11. Parent and child values
Non-null ParentSequenceNumber only sets IsSubNumber=true and stores the parent string. It does not verify parent existence, tenant, activation, hierarchy, or format, and parent is not part of the counter key.
This is relationship metadata, not a protected sub-number model.
12. Transaction boundary
The port comments say generation does not manage transactions and relies on a Command TransactionPipelineBehavior. The current source has no production Command consumer.
When allocation and aggregate writes use different stores or transactions, a number may be reserved without business data, or business data may commit without the fact. The product must explicitly choose its gap policy.
13. Recommended integration
// ① Validate business input before allocation inside a transactional handler.var allocation = await numbering.GenerateAsync(request, cancellationToken);if (allocation.IsFailure) return Result.Failure(allocation.Error);
// ② Persist the returned string as an immutable business fact.invoice.AssignSerialNumber(allocation.Value!.SequenceNumberStr);await invoices.AddAsync(invoice, cancellationToken);
// ③ A no-gap claim still requires proof of one shared atomic transaction.return Result.Success();14. Hardening direction
Prefer a provider-neutral compare-and-swap or atomic-waterline port over loading a full partition. Compensation should use the same atomic claim primitive. Retain full-string uniqueness as the last defense.
15. Inspection commands
# MAX+1, business fallback, compensation, and exception strings.sed -n '200,360p' src/Platform/Numbering/*Infrastructure/SequenceNumber/NumberingSequenceNumberStore.cs
# No current lock, atomic increment, or conditional claim.rg -n "DistributedLock|Interlocked|Increment|CompareAndSwap|UpdateWhereAsync" src/Platform/Numbering -g '*.cs'