Skip to content
bitzorcas
中EN

Reference

I18n translation scopes and persistence

A deep guide to the SysTranslation and SysLanguage models, three-scope precedence, unique indexes, write identity, status, read models, dual-ORM behavior, and the MasterData ownership mismatch.

Last updated

I18n speaks in terms of translations, but MasterData Infrastructure owns the physical record types. Public contracts do not expose those persistence classes, yet the Infrastructure-to-Infrastructure reference and governance declaration still disagree.

1. Owner of the two tables

SysTranslationCatalogRecord and SysLanguageCatalogRecord live under BitzOrcas.Platform.MasterData.Infrastructure.Persistence. I18n Infrastructure directly references that project and creates IEntitySet<T> adapters around those types.

I18n.Contracts
DTO + enum

I18n.Application
ports + handlers

I18n.Infrastructure
read/write adapters

MasterData.Infrastructure
SysTranslation + SysLanguage

IEntitySet
SqlSugar / EF Core

The governance marker declares only Authorization and omits MasterData. A durable design should move these records and mappings to the I18n owner or let MasterData expose a narrow public port and then make governance match reality.

2. SysTranslation fields

FieldCurrent contract
TranslationKeyRequired, maximum 300
LanguageCodeRequired, maximum 20, not canonicalized
ScopeTypeint expected to be 0/1/2, with no visible database check constraint
TenantIdInherited; Platform writes "0"
OfficeIdInherited string; no office writes "0"
ContextOptional, maximum 200, not used for selection
ValueRequired, maximum 2,000
Statusint; reads accept only Active=0
IsDeleted/DeleteTimeInherited soft-delete metadata

The table declares tenant and soft-delete metadata. The read-model predicate does not explicitly mention IsDeleted. Dual-ORM tests must prove the global filter rather than documentation inferring behavior from attributes alone.

3. Business uniqueness

The unique index is:

TranslationKey + LanguageCode + ScopeType + TenantId + OfficeId

It provides the final guard for upsert. Context, status, and version do not participate. Platform and Tenant rows must keep OfficeId at "0"; malformed nonzero values can otherwise bypass logical uniqueness. Language casing can also produce duplicates depending on database collation.

4. Scope encoding

TranslationScope fixes Platform=0, Tenant=1, and TenantOffice=2. The repository assigns TenantId "0" for Platform and current tenant or "0" otherwise. It retains current OfficeId only for TenantOffice and writes "0" for the other scopes.

Validate scope context before persistence
// Intended rule: tenant-owned scopes require a real tenant.
if (scope is TranslationScope.Tenant or TranslationScope.TenantOffice
&& string.IsNullOrWhiteSpace(currentTenantId))
return Errors.TenantRequired();
// An office override must not degrade into OfficeId=0.
if (scope is TranslationScope.TenantOffice && currentOfficeId is null)
return Errors.OfficeRequired();
// Platform writes should use a dedicated trusted-platform policy.
await platformPolicy.EnsureCanManageTranslationsAsync(currentActor, cancellationToken);

This target guard is now enforced. The handler rejects context-missing writes before reaching the repository: a non-Platform scope without a trusted tenant returns I18nErrors.TranslationInvalidInput; a TenantOffice scope without a valid OfficeId fails likewise. The Platform-scope writer identity is covered in §9.

5. Candidate query

The store filters language, Active status, and optional keys or prefix. When TenantId is present, it reads only the current tenant and "0"; when TenantId is absent, it adds no tenant predicate and can read rows for every tenant before merging. A no-tenant caller is therefore a high-risk path.

An office row participates only when its OfficeId matches. Tenant and Platform rows are not checked for their canonical OfficeId/TenantId combinations, so damaged rows can affect precedence.

6. Winner algorithm

Equivalent form of the current merge
var merged = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Ascending scope: Platform, then Tenant, then Office.
foreach (var row in rows.OrderBy(item => item.ScopeType))
{
// Office rows participate only when they match the current office exactly.
if ((TranslationScope)row.ScopeType == TranslationScope.TenantOffice
&& row.OfficeId != effectiveOfficeId)
continue;
merged[row.TranslationKey] = row.Value;
}

The unique index normally makes the result deterministic inside each scope. Invalid ScopeType values, case duplicates, soft-delete differences, or malformed tenant/office combinations can still make the outcome provider-dependent.

7. Prefix and explicit keys

When keys are nonempty, the store filters by that collection and ignores KeyPrefix. Empty keys mean “all,” and only then is Prefix pushed down. Whether StartsWith is case sensitive and index-friendly depends on provider and collation.

An empty user-controlled prefix retrieves the entire language dictionary. Endpoints need prefix length and grammar checks, response bounds, and a permission decision about who may export a complete tenant language pack.

8. SaveTranslation transaction window

Save checks that Key, LanguageCode, and Value are nonblank, then upserts. Input normalization is centralized in I18nRequestInput: values are trimmed and length caps enforced (Key 300, LanguageCode 20, Value 2000, Context 200); BCP-47 validity is checked via CultureInfo.GetCultureInfo (a parse failure returns failure); the Scope enum is constrained with Enum.IsDefined. Invalid input is rejected before reaching the repository, rather than relying on the ORM or database to fail later.

"NotificationPublisher""LocalResourceSync""Local cache""TranslationRepository""Save handler""NotificationPublisher""LocalResourceSync""Local cache""TranslationRepository""Save handler"Upsert ActiveRemoveByTag(i18n)Translation Updated + timestamp versioni18n.translations.changedsuccess

There is no explicit unit of work, transaction, or outbox around the sequence. sourceVersion is the current Unix millisecond rather than a database revision. The notification field named keyPrefix contains the full key.

9. Platform-write identity

Platform-scope writes are no longer gated on caller type alone. The handler now requires the caller to satisfy both conditions: hold the WellKnownPermissionCodes.RootCrossTenant permission and pass ActorKey.TryCreatePlatformOperator as a stable Host/System platform operator. When both hold, the handler constructs a RootCrossTenantOperation.Authorize(user, RootCrossTenantOperations.I18nPlatformTranslationManage, ...) capability ticket and passes it to the repository, which re-validates the operation code i18n.platform-translation.manage via rootOperation.EnsureOperation(...). The error message is explicit: only a stable Host/System platform operator holding the cross-tenant permission may write Platform-level translations.

Platform text affects every tenant and is shared supply-chain configuration. The implementation has tightened from “any Application caller may write” to a triple check of cross-tenant permission, platform-operator identity, and an audit capability ticket.

10. TranslationStatus is not a publication workflow

The enum declares Active, NeedsReview, and Deprecated. Save always writes Active, and read paths return only Active. There is no command to submit review, approve, publish, deprecate, or restore.

If immediate publication is intentional, remove misleading workflow states and document that choice. A commercial content operation instead needs Draft Revision and Published Projection with author, reviewer, timestamps, reason, diff, and rollback target.

11. SysLanguage catalog

The language table is unique by (TenantId, Code) and stores Name, DisplayName, Icon, IsDefault, and IsDisabled. The read model requires exact current TenantId and does not merge a platform catalog.

The MasterData CSV contains zh-CN and en-US with blank TenantId, while the seeder matches only Code. That treats a tenant-marked table as global owner data. The seed, query, and per-tenant uniqueness semantics do not yet form a complete provisioning model.

12. Concurrency and idempotency

Upsert performs read-before-insert/update with no expected version. Two concurrent first writes can both miss and let uniqueness fail one request; the handler does not reread a unique conflict into an idempotent success. Concurrent updates can silently last-write-wins.

GA needs ETag/ExpectedVersion or request idempotency. Database upsert and conflict mapping must expose the same Result/Problem Details behavior across SqlSugar and EF Core.

13. Test matrix

ScenarioCurrent evidenceGA addition
Three-scope precedenceBasic dual-ORM parityInvalid rows and every supported database
Unique keyMetadata and schema initConcurrent first write, casing, soft-delete recreation
Language listHandler unit and paritySeed, new tenant, platform merge policy
Platform callerRootCrossTenant + platform operator + capability ticketReal HTTP identity and permission matrix
Missing tenant/officeHandler fail-closesZero-side-effect evidence under concurrency and dirty combinations
Input validationTrim + length + BCP-47 + Scope enumCollation and dual-ORM consistency
StatusActive queryReal publication workflow or simplification
Save notificationsNo failure injectionFailure at DB/cache/sync/notification stages
Multi-ORMScenario parityCollation, prefix, and concurrency error contract

14. Inspection commands

Terminal window
# Physical owner, unique key, and precedence implementation.
rg -n "SysTranslationCatalogRecord|UX_SysTranslation|effectiveTenant|OrderBy\(t => t.ScopeType\)" \
src/Platform/I18n src/Platform/MasterData -g '*.cs'
# Publication, audit, concurrency, and outbox should currently have no matches.
rg -n "PublishTranslation|ReviewedBy|ExpectedVersion|Outbox|IdempotencyKey" \
src/Platform/I18n -g '*.cs'

I18n overview · Localizer, cache, and resources · MasterData module

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%