diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index b4ef912..3b950e5 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -19,6 +19,12 @@
+
+
+
diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs
new file mode 100644
index 0000000..7ccc012
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs
@@ -0,0 +1,127 @@
+using LearnStack.SharedKernel.Identifiers;
+using LearnStack.SharedKernel.Persistence;
+using LearnStack.SharedKernel.Time;
+
+namespace LearnStack.SharedKernel.Domain;
+
+///
+/// Mutable aggregate base. Carries the audit columns every tenant-owned
+/// table mirrors (CreatedAt / CreatedBy / UpdatedAt /
+/// UpdatedBy / DeletedAt / DeletedBy / Version)
+/// and implements +
+/// so the EF global-query-filter and concurrency-token wiring picks them up
+/// uniformly. Audit columns are populated by /
+/// / , which command
+/// handlers call via the they already inject.
+///
+public abstract class AuditableEntity
+ : Entity, ISoftDelete, IOptimisticConcurrency
+ where TId : struct, IStronglyTypedId
+{
+ protected AuditableEntity(TId id)
+ : base(id)
+ {
+ }
+
+ // EF Core / ORM materialization ctor.
+ protected AuditableEntity()
+ {
+ }
+
+ public DateTimeOffset CreatedAt { get; protected set; }
+
+ public UserId CreatedBy { get; protected set; }
+
+ public DateTimeOffset? UpdatedAt { get; protected set; }
+
+ public UserId? UpdatedBy { get; protected set; }
+
+ public DateTimeOffset? DeletedAt { get; protected set; }
+
+ public UserId? DeletedBy { get; protected set; }
+
+ public uint Version { get; protected set; }
+
+ ///
+ /// Convenience projection of for in-process
+ /// callers (aggregate methods, application services, mappers). EF
+ /// global query filters should gate on directly
+ /// (e => e.DeletedAt == null) — is a
+ /// computed CLR property and is NOT guaranteed to translate to SQL by
+ /// EF Core's expression translator. Packet 7 wires the filters
+ /// accordingly.
+ ///
+ public bool IsDeleted => DeletedAt.HasValue;
+
+ ///
+ /// Stamps / on first
+ /// persist. Throws when the aggregate already has a non-default
+ /// — audit-trail integrity rules out silent
+ /// overwrites.
+ ///
+ public void MarkCreated(DateTimeOffset at, UserId by)
+ {
+ EnsureValidAuditInput(at, by);
+
+ if (CreatedAt != default)
+ {
+ throw new InvalidOperationException(
+ "MarkCreated has already been called on this aggregate; the created-at / created-by columns are immutable after first stamp.");
+ }
+
+ CreatedAt = at;
+ CreatedBy = by;
+ }
+
+ ///
+ /// Stamps / . Called by
+ /// aggregate methods that mutate state; the audit pipeline reads these
+ /// values when writing the audit entry.
+ ///
+ public void MarkUpdated(DateTimeOffset at, UserId by)
+ {
+ EnsureValidAuditInput(at, by);
+
+ UpdatedAt = at;
+ UpdatedBy = by;
+ }
+
+ ///
+ /// Marks the entity as soft-deleted. Also bumps
+ /// / so the
+ /// "last touched at" timestamp is monotonic — replication / sync /
+ /// reporting jobs that scan on UpdatedAt see soft-deletes
+ /// without keying off DeletedAt separately. The audit row still
+ /// classifies the action as a delete via its own operation type.
+ ///
+ public void SoftDelete(DateTimeOffset at, UserId by)
+ {
+ EnsureValidAuditInput(at, by);
+
+ DeletedAt = at;
+ DeletedBy = by;
+ UpdatedAt = at;
+ UpdatedBy = by;
+ }
+
+ // Audit metadata must always be meaningful: the default timestamp
+ // (0001-01-01) and the default UserId (Guid.Empty) are programmer-error
+ // sentinels rather than legitimate audit values. Fail loud at the call
+ // site rather than persisting them.
+ private static void EnsureValidAuditInput(DateTimeOffset at, UserId by)
+ {
+ if (at == default)
+ {
+ throw new ArgumentException(
+ "Audit timestamp must be a meaningful instant, not default(DateTimeOffset). Pass the value from IClock.UtcNow.",
+ nameof(at));
+ }
+
+ if (by.Value == Guid.Empty)
+ {
+ throw new ArgumentException(
+ "Audit actor must be a real UserId, not default(UserId). Pass the resolved ITenantContext.UserId.",
+ nameof(by));
+ }
+ }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Domain/DomainEvent.cs b/backend/src/LearnStack.SharedKernel/Domain/DomainEvent.cs
new file mode 100644
index 0000000..f3bcce0
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Domain/DomainEvent.cs
@@ -0,0 +1,31 @@
+namespace LearnStack.SharedKernel.Domain;
+
+///
+/// Base record for in-process domain events. Concrete events derive from
+/// this and add their own payload fields:
+///
+/// public sealed record CourseEnrolled(CourseId CourseId, UserId UserId) : DomainEvent;
+///
+/// // raised from inside an aggregate method:
+/// RaiseDomainEvent(new CourseEnrolled(Id, learnerId)
+/// {
+/// EventId = guids.NewUuidV7(),
+/// OccurredAt = clock.UtcNow,
+/// });
+///
+///
+///
+/// and are required init:
+/// every event MUST be stamped with the aggregate's injected
+/// IGuidFactory / IClock. Defaulting these to
+/// Guid.CreateVersion7() / DateTimeOffset.UtcNow would
+/// silently bypass the deterministic-test abstractions every command
+/// handler already threads in — which is the entire reason IClock
+/// and IGuidFactory exist (Standards 02 § Time).
+///
+public abstract record DomainEvent : IDomainEvent
+{
+ public required Guid EventId { get; init; }
+
+ public required DateTimeOffset OccurredAt { get; init; }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Domain/Entity.cs b/backend/src/LearnStack.SharedKernel/Domain/Entity.cs
new file mode 100644
index 0000000..e480164
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Domain/Entity.cs
@@ -0,0 +1,110 @@
+using System.Collections.ObjectModel;
+using LearnStack.SharedKernel.Identifiers;
+
+namespace LearnStack.SharedKernel.Domain;
+
+///
+/// Append-only / audit aggregate base. Carries identity and raises
+/// in-process s; does not carry the
+/// CreatedAt / UpdatedAt audit columns — those belong to
+/// mutable aggregates and live on .
+///
+///
+///
+/// The audit subsystem's own AuditEntry aggregate inherits this base
+/// directly, never , because audit rows
+/// are immutable once written. Architecture test
+/// AuditEntry_Inherits_Entity_Not_AuditableEntity guards that rule.
+///
+///
+/// Equality contract: identity-based, with three guards every aggregate
+/// inherits — transient entities ( equal to
+/// default(TId)) are never equal to each other (reference equality
+/// is the only match), runtime-type mismatches are never equal even when
+/// the underlying ID matches, and the hash code partitions transient
+/// instances apart so EF Core's change-tracker identity map and any
+/// HashSet in a collection navigation behave correctly.
+///
+///
+/// Domain-event collection state is lazily allocated: the backing
+/// List and its view are only
+/// created on first raise or first read. EF Core materialises every loaded
+/// aggregate through the parameterless ctor on read paths; the lazy
+/// approach keeps materialisation allocation-free for the common
+/// query case (paginated reads, projections), and pays the allocation only
+/// when an aggregate actually raises events (command paths).
+///
+///
+public abstract class Entity : IHasId, IHasDomainEvents
+ where TId : struct, IStronglyTypedId
+{
+ private List? _domainEvents;
+ private ReadOnlyCollection? _domainEventsView;
+
+ protected Entity(TId id)
+ {
+ Id = id;
+ }
+
+ // EF Core / ORM materialization ctor.
+ protected Entity()
+ {
+ }
+
+ public TId Id { get; protected init; }
+
+ ///
+ /// In-process domain events raised since the last .
+ /// Returns a cached wrapper rather
+ /// than the backing List directly so callers cannot downcast
+ /// and mutate the collection out from under the aggregate. Both the
+ /// backing list and the wrapper are lazily allocated on first
+ /// access / first raise.
+ ///
+ public IReadOnlyCollection DomainEvents =>
+ _domainEventsView ??= (_domainEvents ??= []).AsReadOnly();
+
+ protected void RaiseDomainEvent(IDomainEvent domainEvent)
+ {
+ ArgumentNullException.ThrowIfNull(domainEvent);
+ (_domainEvents ??= []).Add(domainEvent);
+ }
+
+ public void ClearDomainEvents() => _domainEvents?.Clear();
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not Entity other)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ // Cross-type guard: a Course and a hypothetical CourseDraft that both
+ // inherit Entity and share an Id value are still different
+ // runtime types and must not compare equal.
+ if (GetType() != other.GetType())
+ {
+ return false;
+ }
+
+ // Transient guard: two newly-constructed aggregates carry default(TId)
+ // until SaveChangesAsync stamps them. They must never collapse into
+ // each other in EF's change tracker or in a HashSet-backed navigation.
+ if (Id.Equals(default(TId)) || other.Id.Equals(default(TId)))
+ {
+ return false;
+ }
+
+ return Id.Equals(other.Id);
+ }
+
+ public override int GetHashCode() =>
+ Id.Equals(default(TId))
+ ? base.GetHashCode()
+ : HashCode.Combine(GetType(), Id);
+}
diff --git a/backend/src/LearnStack.SharedKernel/Domain/IDomainEvent.cs b/backend/src/LearnStack.SharedKernel/Domain/IDomainEvent.cs
new file mode 100644
index 0000000..f4078ca
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Domain/IDomainEvent.cs
@@ -0,0 +1,23 @@
+using MediatR;
+
+namespace LearnStack.SharedKernel.Domain;
+
+///
+/// In-process domain event raised by an aggregate method. Dispatched
+/// in-process by MediatR — the cross-module integration-event path
+/// (outbox + Dapr pub/sub) is a different mechanism per
+/// ADR-0010.
+///
+public interface IDomainEvent : INotification
+{
+ ///
+ /// Unique event identifier. UUIDv7 so insertion-order matches occurrence
+ /// order when an event is persisted for replay or debugging.
+ ///
+ Guid EventId { get; }
+
+ ///
+ /// UTC instant the event was raised.
+ ///
+ DateTimeOffset OccurredAt { get; }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs b/backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs
new file mode 100644
index 0000000..f443391
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs
@@ -0,0 +1,13 @@
+namespace LearnStack.SharedKernel.Domain;
+
+///
+/// Marker every entity that raises implements.
+/// The unit-of-work walks tracked entities, drains the events, and hands
+/// them to MediatR's in-process publisher on commit.
+///
+public interface IHasDomainEvents
+{
+ IReadOnlyCollection DomainEvents { get; }
+
+ void ClearDomainEvents();
+}
diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/FixedGuidFactory.cs b/backend/src/LearnStack.SharedKernel/Identifiers/FixedGuidFactory.cs
new file mode 100644
index 0000000..c01e387
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Identifiers/FixedGuidFactory.cs
@@ -0,0 +1,45 @@
+namespace LearnStack.SharedKernel.Identifiers;
+
+///
+/// Deterministic for tests. Returns the supplied
+/// sequence in order; throws once
+/// the sequence is exhausted so tests fail loud rather than silently
+/// reusing a default value.
+///
+///
+/// and draw from the
+/// same queue — the fixture does not synthesise version-7 / -4
+/// shapes from the supplied s. Callers that need to
+/// assert guid.Version == 7 (or 4) seed the queue with
+/// version-appropriate values (e.g. Guid.CreateVersion7() minted
+/// at test setup) so the fixture's output is the exact
+/// the test passes in. This keeps the fixture trivial; the production
+/// is where the version contract is
+/// enforced.
+///
+public sealed class FixedGuidFactory : IGuidFactory
+{
+ private readonly Queue _sequence;
+
+ public FixedGuidFactory(params Guid[] sequence)
+ {
+ ArgumentNullException.ThrowIfNull(sequence);
+ _sequence = new Queue(sequence);
+ }
+
+ public Guid NewUuidV7() => Dequeue();
+
+ public Guid NewUuidV4() => Dequeue();
+
+ private Guid Dequeue()
+ {
+ if (_sequence.Count == 0)
+ {
+ throw new InvalidOperationException(
+ "FixedGuidFactory sequence exhausted. Construct with enough GUIDs " +
+ "for the test, or switch to a different fixture.");
+ }
+
+ return _sequence.Dequeue();
+ }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/IAggregateRoot.cs b/backend/src/LearnStack.SharedKernel/Identifiers/IAggregateRoot.cs
new file mode 100644
index 0000000..e4e1042
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Identifiers/IAggregateRoot.cs
@@ -0,0 +1,16 @@
+namespace LearnStack.SharedKernel.Identifiers;
+
+///
+/// Marker for the root entity of an aggregate. Repositories accept and
+/// return only aggregate roots; entities inside an aggregate are reached
+/// through the root.
+///
+///
+/// The aggregate's strongly-typed identifier — per ADR-0023 every aggregate
+/// root carries an -shaped Vogen-emitted
+/// ID over .
+///
+public interface IAggregateRoot : IHasId
+ where TId : struct, IStronglyTypedId
+{
+}
diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs b/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs
new file mode 100644
index 0000000..5bf1d3e
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs
@@ -0,0 +1,28 @@
+namespace LearnStack.SharedKernel.Identifiers;
+
+///
+/// GUID minting abstraction. Application-side GUIDs flow through this
+/// interface so tests can pin the sequence deterministically.
+///
+///
+/// Two minting paths exist per ADR-0031 (PostgreSQL 18) + ADR-0023:
+/// app-side for aggregates that need the ID before
+/// SaveChangesAsync, and DB-side gen_uuid_v7() DEFAULT for
+/// high-volume append-only tables (audit_log, outbox_messages).
+/// This factory covers the app-side path only.
+///
+public interface IGuidFactory
+{
+ ///
+ /// Mints a UUIDv7 (.NET 9+ Guid.CreateVersion7). Preferred for
+ /// every new aggregate root identifier because the timestamp prefix
+ /// keeps DB-side indexes sorted by insertion order.
+ ///
+ Guid NewUuidV7();
+
+ ///
+ /// Mints a UUIDv4. Reserved for non-aggregate identifiers that should
+ /// not leak insertion-order information (e.g. external-facing tokens).
+ ///
+ Guid NewUuidV4();
+}
diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/IHasId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/IHasId.cs
new file mode 100644
index 0000000..24da32b
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Identifiers/IHasId.cs
@@ -0,0 +1,13 @@
+namespace LearnStack.SharedKernel.Identifiers;
+
+///
+/// Tagging interface for any entity that exposes an identifier of type
+/// . Kept separate from
+/// so child entities inside an aggregate
+/// can share the Id shape without claiming aggregate-root status.
+///
+public interface IHasId
+ where TId : struct, IStronglyTypedId
+{
+ TId Id { get; }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/SystemGuidFactory.cs b/backend/src/LearnStack.SharedKernel/Identifiers/SystemGuidFactory.cs
new file mode 100644
index 0000000..1aba624
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Identifiers/SystemGuidFactory.cs
@@ -0,0 +1,12 @@
+namespace LearnStack.SharedKernel.Identifiers;
+
+///
+/// Production backed by the BCL.
+/// Registered as a singleton at the composition root.
+///
+public sealed class SystemGuidFactory : IGuidFactory
+{
+ public Guid NewUuidV7() => Guid.CreateVersion7();
+
+ public Guid NewUuidV4() => Guid.NewGuid();
+}
diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs
new file mode 100644
index 0000000..7b99140
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs
@@ -0,0 +1,22 @@
+using Vogen;
+
+namespace LearnStack.SharedKernel.Identifiers;
+
+///
+/// Cross-cutting strongly-typed identifier for the platform-wide user.
+/// Lives in (per ADR-0023's
+/// cross-cutting value-object placement) so audit metadata and other
+/// kernel surfaces can reference users without depending on the Identity
+/// module that lands in Phase 02b. The Identity module consumes the same
+/// type once it ships; there is exactly one UserId shape.
+///
+///
+/// There is no UserId.New() convenience: new
+/// values are minted by aggregate methods through the injected
+/// IGuidFactory (UserId.From(guidFactory.NewUuidV7())) so
+/// tests can pin the value deterministically — see Standards 02 § Time.
+///
+[ValueObject(LearnStackVogenDefaults.IdMask)]
+public readonly partial record struct UserId : IStronglyTypedId
+{
+}
diff --git a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj
index f20e08c..a677d49 100644
--- a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj
+++ b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj
@@ -4,4 +4,28 @@
LearnStack.SharedKernel
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/LearnStack.SharedKernel/LearnStackVogenDefaults.cs b/backend/src/LearnStack.SharedKernel/LearnStackVogenDefaults.cs
new file mode 100644
index 0000000..2be0875
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/LearnStackVogenDefaults.cs
@@ -0,0 +1,26 @@
+using Vogen;
+
+namespace LearnStack.SharedKernel;
+
+///
+/// Canonical Conversions mask for every LearnStack-declared
+/// [ValueObject<T>] per ADR-0023. Lives at the SharedKernel
+/// root namespace because the mask covers both aggregate-root IDs
+/// (e.g. TenantId, UserId) and richer value objects
+/// (Email, Slug, LocaleCode, Money) — neither
+/// surface owns the constant exclusively.
+///
+public static class LearnStackVogenDefaults
+{
+ ///
+ /// Conversion set every aggregate-root ID and cross-cutting value object
+ /// opts into: EF Core value converter, System.Text.Json converter, and
+ /// the TypeConverter (which carries ASP.NET Core minimal-API and MVC
+ /// route-parameter binding — Vogen does not expose a separate
+ /// AspNetCoreRouteParameter flag).
+ ///
+ public const Conversions IdMask =
+ Conversions.EfCoreValueConverter
+ | Conversions.SystemTextJson
+ | Conversions.TypeConverter;
+}
diff --git a/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs b/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs
new file mode 100644
index 0000000..4348022
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs
@@ -0,0 +1,147 @@
+using System.Collections.ObjectModel;
+
+namespace LearnStack.SharedKernel.Localization;
+
+///
+/// Localization-key carrier for every user-facing message LearnStack
+/// returns to the frontend. The backend never returns raw English text;
+/// it returns a whose
+/// resolves to a translation on the client.
+///
+///
+///
+/// The lockey_ prefix invariant is enforced at the constructor:
+/// every key must match the format the frontend's
+/// i18n bundles ship under. Mis-prefixed keys fail loud at the
+/// point of construction rather than silently resolving to "missing
+/// translation" at render time. Per Phase 02a Packet 2.
+///
+///
+/// values are interpolated by the frontend as plain
+/// text (React text nodes, ICU MessageFormat substitution). Backend code
+/// must not place HTML / Markdown / template syntax into
+/// these values — the frontend resolves messages via text nodes, never
+/// dangerouslySetInnerHTML. Standards 11 § XSS covers the frontend
+/// side; this contract closes the backend side.
+///
+///
+public sealed record LocalizedMessage
+{
+ ///
+ /// The required prefix for every localization key. The frontend's
+ /// translation catalogues use the same prefix.
+ ///
+ public const string RequiredPrefix = "lockey_";
+
+ public LocalizedMessage(string key, IReadOnlyDictionary? @params = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+
+ if (!key.StartsWith(RequiredPrefix, StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ $"Localization key must start with '{RequiredPrefix}'. Got: '{key}'.",
+ nameof(key));
+ }
+
+ Key = key;
+ // Snapshot + wrap: the caller may mutate the original dictionary
+ // after construction, and IReadOnlyDictionary does not enforce
+ // immutability on its own (callers can downcast). A ReadOnlyDictionary
+ // wrapper over a defensive copy gives both: cast-safe at the API
+ // level and snapshot-stable at the data level. The empty case is
+ // normalised to null so serializers do not emit an unused
+ // "params": {} field.
+ Params = @params is { Count: > 0 }
+ ? new ReadOnlyDictionary(new Dictionary(@params))
+ : null;
+ }
+
+ ///
+ /// The localization key (always begins with ).
+ /// Routing logic and error-tracking provider tags use this verbatim;
+ /// Error.Code projects from this key with the prefix stripped.
+ ///
+ public string Key { get; }
+
+ ///
+ /// Optional ICU MessageFormat parameter set. Frontend interpolates
+ /// these into the resolved string as plain text (see
+ /// remarks for the safety contract). null when the message takes
+ /// no parameters.
+ ///
+ public IReadOnlyDictionary? Params { get; }
+
+ ///
+ /// Convenience factory equivalent to new LocalizedMessage(key, params).
+ ///
+ public static LocalizedMessage Of(
+ string key,
+ IReadOnlyDictionary? @params = null) =>
+ new(key, @params);
+
+ // Structural equality. The default record equality compares Params by
+ // reference (IReadOnlyDictionary has no structural equality contract);
+ // after the defensive-copy invariant two LocalizedMessages constructed
+ // from the same source dictionary hold separate copies and would no
+ // longer be equal. Override Equals + GetHashCode to compare the params
+ // key-by-key so equality matches the semantic contract.
+ public bool Equals(LocalizedMessage? other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ if (!string.Equals(Key, other.Key, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (Params is null && other.Params is null)
+ {
+ return true;
+ }
+
+ if (Params is null || other.Params is null || Params.Count != other.Params.Count)
+ {
+ return false;
+ }
+
+ foreach (var (k, v) in Params)
+ {
+ if (!other.Params.TryGetValue(k, out var otherValue) ||
+ !string.Equals(v, otherValue, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.Add(Key, StringComparer.Ordinal);
+ if (Params is not null)
+ {
+ // Order-independent: XOR each entry's hash so dictionary iteration
+ // order does not affect the bucket.
+ var paramsHash = 0;
+ foreach (var (k, v) in Params)
+ {
+ paramsHash ^= HashCode.Combine(k, v);
+ }
+
+ hash.Add(paramsHash);
+ }
+
+ return hash.ToHashCode();
+ }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Pagination/CursorPagination.cs b/backend/src/LearnStack.SharedKernel/Pagination/CursorPagination.cs
new file mode 100644
index 0000000..3206625
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Pagination/CursorPagination.cs
@@ -0,0 +1,54 @@
+namespace LearnStack.SharedKernel.Pagination;
+
+///
+/// Cursor-pagination request. Per Standards 04 § Pagination, cursor is the
+/// default for every list endpoint. The is an opaque
+/// token the server minted on a previous response; the client never parses
+/// it. defaults to and is
+/// capped at .
+///
+///
+/// The invariant lives in the property's init
+/// accessor, not in the constructor, so it covers every initialisation
+/// path: the constructor, object-initializer syntax
+/// (new CursorPagination { Limit = 0 }), and the record's
+/// with expression (request with { Limit = 0 }). Non-positive
+/// limits throw (kernel-level programmer-error guard); above-max limits
+/// are silently clamped to . API-layer
+/// FluentValidation should still turn malformed user input into
+/// Result.Fail(validation_failed) before the kernel sees the
+/// request; the kernel guards are the last line of defense, not the first.
+///
+public sealed record CursorPagination
+{
+ public const int DefaultLimit = 20;
+
+ public const int MaxLimit = 100;
+
+ private readonly int _limit = DefaultLimit;
+
+ public CursorPagination(string? Cursor = null, int Limit = DefaultLimit)
+ {
+ this.Cursor = Cursor;
+ this.Limit = Limit;
+ }
+
+ public string? Cursor { get; init; }
+
+ public int Limit
+ {
+ get => _limit;
+ init
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(value),
+ value,
+ $"Limit must be > 0. Got: {value}.");
+ }
+
+ _limit = value > MaxLimit ? MaxLimit : value;
+ }
+ }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Pagination/Page.cs b/backend/src/LearnStack.SharedKernel/Pagination/Page.cs
new file mode 100644
index 0000000..e352a2a
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Pagination/Page.cs
@@ -0,0 +1,28 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace LearnStack.SharedKernel.Pagination;
+
+///
+/// Cursor-paginated response. The are the page
+/// payload; carries the next/previous opaque
+/// cursors plus the boolean hints the client uses to render
+/// pagination controls.
+///
+///
+/// CA1000 (do not declare static members on generic types) is intentionally
+/// suppressed for : Page<T>.Empty mirrors
+/// the canonical factory pattern (Array.Empty<T>,
+/// ReadOnlyCollection<T>.Empty) and removes per-call-site
+/// boilerplate. The alternative (Page.Empty<T>() helper)
+/// forces callers to repeat T.
+///
+[SuppressMessage(
+ "Design",
+ "CA1000:Do not declare static members on generic types",
+ Justification = "Canonical empty-instance pattern (mirrors Array.Empty); Result.Ok/Fail uses the same lineage.")]
+public sealed record Page(IReadOnlyList Items, PageInfo PageInfo)
+{
+ public static Page Empty { get; } = new(
+ Array.Empty(),
+ new PageInfo(null, null, HasNext: false, HasPrevious: false));
+}
diff --git a/backend/src/LearnStack.SharedKernel/Pagination/PageInfo.cs b/backend/src/LearnStack.SharedKernel/Pagination/PageInfo.cs
new file mode 100644
index 0000000..374cbec
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Pagination/PageInfo.cs
@@ -0,0 +1,12 @@
+namespace LearnStack.SharedKernel.Pagination;
+
+///
+/// Cursor-pagination response envelope. Surface matches Standards 04
+/// § Pagination so OpenAPI generation produces a uniform shape across
+/// every list endpoint.
+///
+public sealed record PageInfo(
+ string? NextCursor,
+ string? PreviousCursor,
+ bool HasNext,
+ bool HasPrevious);
diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs
new file mode 100644
index 0000000..9100859
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs
@@ -0,0 +1,18 @@
+namespace LearnStack.SharedKernel.Persistence;
+
+///
+/// Marker for any entity whose updates use optimistic concurrency. EF Core
+/// configures as the row version token so concurrent
+/// updates fail with DbUpdateConcurrencyException — translated to a
+/// Result.Fail(LocalizedMessage.Of("lockey_concurrency_conflict"))
+/// per ADR-0032 § Sub-decision 6.
+///
+public interface IOptimisticConcurrency
+{
+ ///
+ /// Monotonically-increasing version counter. EF Core bumps this on
+ /// every SaveChangesAsync; aggregate code does not mutate it
+ /// directly.
+ ///
+ uint Version { get; }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Persistence/ISoftDelete.cs b/backend/src/LearnStack.SharedKernel/Persistence/ISoftDelete.cs
new file mode 100644
index 0000000..b95507b
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Persistence/ISoftDelete.cs
@@ -0,0 +1,23 @@
+using LearnStack.SharedKernel.Identifiers;
+
+namespace LearnStack.SharedKernel.Persistence;
+
+///
+/// Marker for any entity that participates in soft deletion. EF Core
+/// global query filters and the audit pipeline read this interface;
+/// callers never set /
+/// directly — use the aggregate's domain method (typically
+/// AuditableEntity.SoftDelete).
+///
+public interface ISoftDelete
+{
+ DateTimeOffset? DeletedAt { get; }
+
+ ///
+ /// The actor who soft-deleted the entity. Strongly-typed
+ /// () per Standards 02 § Strongly-Typed Identifiers
+ /// — no raw on the marker surface. EF's Vogen value
+ /// converter persists this as a UUID column.
+ ///
+ UserId? DeletedBy { get; }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Random/FixedRandom.cs b/backend/src/LearnStack.SharedKernel/Random/FixedRandom.cs
new file mode 100644
index 0000000..b4cbe84
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Random/FixedRandom.cs
@@ -0,0 +1,24 @@
+namespace LearnStack.SharedKernel.Random;
+
+///
+/// Deterministic for tests. Seeded
+/// reproduces the same sequence per run.
+///
+public sealed class FixedRandom : IRandom
+{
+ private readonly System.Random _random;
+
+ public FixedRandom(int seed)
+ {
+ _random = new System.Random(seed);
+ }
+
+ public int Next(int maxExclusive) => _random.Next(maxExclusive);
+
+ public int Next(int minInclusive, int maxExclusive) =>
+ _random.Next(minInclusive, maxExclusive);
+
+ public double NextDouble() => _random.NextDouble();
+
+ public void NextBytes(Span destination) => _random.NextBytes(destination);
+}
diff --git a/backend/src/LearnStack.SharedKernel/Random/IRandom.cs b/backend/src/LearnStack.SharedKernel/Random/IRandom.cs
new file mode 100644
index 0000000..295c5d4
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Random/IRandom.cs
@@ -0,0 +1,42 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace LearnStack.SharedKernel.Random;
+
+///
+/// Randomness abstraction for domain and application code. Production code
+/// never instantiates directly so tests can pin
+/// the sequence deterministically per Standards 02 § Time. Cryptographic
+/// randomness uses System.Security.Cryptography.RandomNumberGenerator
+/// and is out of scope for this abstraction.
+///
+///
+/// CA1716 (Next is a VB reserved keyword) is suppressed: LearnStack
+/// is C#-only per ADR-0032 and the Next name matches the BCL
+/// surface a future maintainer expects.
+///
+[SuppressMessage(
+ "Naming",
+ "CA1716:Identifiers should not match keywords",
+ Justification = "Mirrors System.Random.Next; LearnStack is C#-only per ADR-0032; no VB consumer affected.")]
+public interface IRandom
+{
+ ///
+ /// Returns a non-negative random integer less than .
+ ///
+ int Next(int maxExclusive);
+
+ ///
+ /// Returns a random integer in [minInclusive, maxExclusive).
+ ///
+ int Next(int minInclusive, int maxExclusive);
+
+ ///
+ /// Returns a random double in [0.0, 1.0).
+ ///
+ double NextDouble();
+
+ ///
+ /// Fills the destination span with random bytes.
+ ///
+ void NextBytes(Span destination);
+}
diff --git a/backend/src/LearnStack.SharedKernel/Random/SystemRandom.cs b/backend/src/LearnStack.SharedKernel/Random/SystemRandom.cs
new file mode 100644
index 0000000..220d6d4
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Random/SystemRandom.cs
@@ -0,0 +1,17 @@
+namespace LearnStack.SharedKernel.Random;
+
+///
+/// Production backed by .
+/// Thread-safe; registered as a singleton at the composition root.
+///
+public sealed class SystemRandom : IRandom
+{
+ public int Next(int maxExclusive) => System.Random.Shared.Next(maxExclusive);
+
+ public int Next(int minInclusive, int maxExclusive) =>
+ System.Random.Shared.Next(minInclusive, maxExclusive);
+
+ public double NextDouble() => System.Random.Shared.NextDouble();
+
+ public void NextBytes(Span destination) => System.Random.Shared.NextBytes(destination);
+}
diff --git a/backend/src/LearnStack.SharedKernel/Results/Error.cs b/backend/src/LearnStack.SharedKernel/Results/Error.cs
index 7bb6019..770fede 100644
--- a/backend/src/LearnStack.SharedKernel/Results/Error.cs
+++ b/backend/src/LearnStack.SharedKernel/Results/Error.cs
@@ -1,25 +1,178 @@
+using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
+using LearnStack.SharedKernel.Localization;
namespace LearnStack.SharedKernel.Results;
///
/// Result-pattern error payload used by .
+/// is the localised payload the frontend resolves;
+/// is the stable machine-readable identifier API
+/// consumers route on (Standards 04 § Problem Details and Standards 09
+/// § Forbidden — codes do not get localized, only resolved messages do).
///
///
+///
+/// is derived from Message.Key by stripping the
+/// invariant : a key of
+/// "lockey_validation_failed" projects as "validation_failed".
+/// This keeps the two contracts in sync by construction — there is no way
+/// to ship an whose Code disagrees with the
+/// localization key the frontend resolves.
+///
+///
+/// The constructor takes a defensive snapshot of
+/// (dictionary copy + per-key wrap) so
+/// callers cannot mutate the validation map after the Result is in flight
+/// — Problem Details bodies, audit rows, and structured logs all read
+/// after the handler returns, and a mutation
+/// between handler-return and serialisation would produce inconsistent
+/// output across the three sinks.
+///
+///
+/// Structural equality is overridden because
+/// uses reference equality by default — two Errors with the same Message
+/// and equivalent Details (built from different dictionary instances)
+/// would otherwise compare unequal.
+///
+///
/// CA1716 (avoid reserved language keywords as type names) is intentionally
/// suppressed: the project's Result+Error pattern follows the FluentResults /
/// Ardalis.Result lineage where the type is canonically named Error.
/// LearnStack is C#-only — there is no VB consumer to which the "Error"
/// keyword collision would surface. Per ADR-0032 § Error Model.
+///
///
[SuppressMessage(
"Naming",
"CA1716:Identifiers should not match keywords",
Justification = "Result+Error pattern — C#-only codebase per ADR-0032; no VB consumer affected.")]
-public sealed record Error(
- string Code,
- string Message,
- IReadOnlyDictionary? Details = null)
+public sealed record Error
{
- public static readonly Error None = new("none", string.Empty);
+ public Error(
+ LocalizedMessage message,
+ IReadOnlyDictionary>? details = null)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ Message = message;
+ Details = SnapshotDetails(details);
+ }
+
+ public LocalizedMessage Message { get; }
+
+ public IReadOnlyDictionary>? Details { get; }
+
+ ///
+ /// Stable machine-readable code derived from 's
+ /// Key with the
+ /// stripped. Used by Result.ToActionResult() and Problem Details
+ /// writers as the RFC 7807 code field — never localized, never
+ /// changes per locale (Standards 09 § Forbidden).
+ ///
+ public string Code => Message.Key[LocalizedMessage.RequiredPrefix.Length..];
+
+ public bool Equals(Error? other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ return Message.Equals(other.Message) && DetailsEqual(Details, other.Details);
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.Add(Message);
+
+ if (Details is not null)
+ {
+ var detailsHash = 0;
+ foreach (var (key, list) in Details)
+ {
+ var listHash = 0;
+ foreach (var msg in list)
+ {
+ listHash ^= msg.GetHashCode();
+ }
+
+ detailsHash ^= HashCode.Combine(key, listHash);
+ }
+
+ hash.Add(detailsHash);
+ }
+
+ return hash.ToHashCode();
+ }
+
+ private static ReadOnlyDictionary>? SnapshotDetails(
+ IReadOnlyDictionary>? source)
+ {
+ if (source is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ var snapshot = new Dictionary>(source.Count);
+ foreach (var (key, list) in source)
+ {
+ ArgumentNullException.ThrowIfNull(list);
+
+ // Per-element null check: a null inside the list would later NPE
+ // in GetHashCode / DetailsEqual. Fail fast at construction with
+ // the offending key in the message so the caller can locate the
+ // bug rather than chasing a NullReferenceException down the
+ // serialisation path.
+ var copy = new LocalizedMessage[list.Count];
+ for (var i = 0; i < list.Count; i++)
+ {
+ copy[i] = list[i] ?? throw new ArgumentException(
+ $"Error.Details['{key}'][{i}] is null. Every field-level entry must be a non-null LocalizedMessage.",
+ nameof(source));
+ }
+
+ snapshot[key] = new ReadOnlyCollection(copy);
+ }
+
+ return new ReadOnlyDictionary>(snapshot);
+ }
+
+ private static bool DetailsEqual(
+ IReadOnlyDictionary>? a,
+ IReadOnlyDictionary>? b)
+ {
+ if (a is null && b is null)
+ {
+ return true;
+ }
+
+ if (a is null || b is null || a.Count != b.Count)
+ {
+ return false;
+ }
+
+ foreach (var (key, listA) in a)
+ {
+ if (!b.TryGetValue(key, out var listB) || listA.Count != listB.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < listA.Count; i++)
+ {
+ if (!listA[i].Equals(listB[i]))
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
}
diff --git a/backend/src/LearnStack.SharedKernel/Results/IResultBase.cs b/backend/src/LearnStack.SharedKernel/Results/IResultBase.cs
new file mode 100644
index 0000000..26a0d16
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Results/IResultBase.cs
@@ -0,0 +1,30 @@
+using System.Diagnostics.CodeAnalysis;
+using LearnStack.SharedKernel.Localization;
+
+namespace LearnStack.SharedKernel.Results;
+
+///
+/// Non-generic surface every exposes. Pipeline
+/// behaviors operate on this contract so they can construct the correct
+/// concrete Result<TResponse> shape via the
+/// factory (ADR-0032 § Sub-decision 3).
+///
+///
+/// CA1716 (Error collides with VB's reserved keyword) is suppressed
+/// for the same reason it is suppressed on the record
+/// itself — LearnStack is C#-only per ADR-0032.
+///
+[SuppressMessage(
+ "Naming",
+ "CA1716:Identifiers should not match keywords",
+ Justification = "Result+Error pattern — C#-only codebase per ADR-0032; no VB consumer affected.")]
+public interface IResultBase
+{
+ bool IsSuccess { get; }
+
+ bool IsFailure { get; }
+
+ LocalizedMessage? SuccessMessage { get; }
+
+ Error? Error { get; }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Results/Result.Helpers.cs b/backend/src/LearnStack.SharedKernel/Results/Result.Helpers.cs
new file mode 100644
index 0000000..c7d47a6
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Results/Result.Helpers.cs
@@ -0,0 +1,68 @@
+using System.Reflection;
+using LearnStack.SharedKernel.Localization;
+
+namespace LearnStack.SharedKernel.Results;
+
+///
+/// Static helpers for constructing instances when
+/// the concrete generic parameter is known only at runtime (e.g. inside
+/// the MediatR ValidationBehavior / AuthorizationBehavior
+/// that short-circuit a handler without referencing the value type).
+/// Per ADR-0032 § Sub-decision 3.
+///
+public static class Result
+{
+ public static Result Ok(T value, LocalizedMessage? message = null) =>
+ Result.Ok(value, message);
+
+ public static Result Fail(Error error) => Result.Fail(error);
+
+ ///
+ /// Reflection-friendly failure factory used by MediatR pipeline
+ /// behaviors whose TResponse is itself a Result<TValue>.
+ /// Returns an instance of — not
+ /// Result<TResponse> — by reflecting over the closed
+ /// generic to invoke the correct Result<TValue>.Fail(error).
+ ///
+ ///
+ /// The reflected is cached per closed
+ /// in the nested
+ /// — initialised once on first
+ /// touch, zero per-call overhead afterwards. The hot path is the
+ /// MediatR pipeline behavior that runs on every command.
+ ///
+ public static TResponse FailFor(Error error)
+ where TResponse : IResultBase
+ {
+ ArgumentNullException.ThrowIfNull(error);
+
+ var fail = FailForCache.FailMethod
+ ?? throw new InvalidOperationException(
+ $"Result.FailFor requires TResponse to be a closed Result; got {typeof(TResponse).FullName}.");
+
+ return (TResponse)fail.Invoke(obj: null, parameters: [error])!;
+ }
+
+ private static class FailForCache
+ where TResponse : IResultBase
+ {
+ public static readonly MethodInfo? FailMethod = ResolveFailMethod();
+
+ private static MethodInfo? ResolveFailMethod()
+ {
+ var responseType = typeof(TResponse);
+ if (!responseType.IsGenericType
+ || responseType.GetGenericTypeDefinition() != typeof(Result<>))
+ {
+ return null;
+ }
+
+ return responseType.GetMethod(
+ nameof(Result.Fail),
+ BindingFlags.Public | BindingFlags.Static,
+ binder: null,
+ types: [typeof(Error)],
+ modifiers: null);
+ }
+ }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Results/Result.cs b/backend/src/LearnStack.SharedKernel/Results/Result.cs
index f64fd94..dc8c907 100644
--- a/backend/src/LearnStack.SharedKernel/Results/Result.cs
+++ b/backend/src/LearnStack.SharedKernel/Results/Result.cs
@@ -1,10 +1,14 @@
using System.Diagnostics.CodeAnalysis;
+using LearnStack.SharedKernel.Localization;
namespace LearnStack.SharedKernel.Results;
///
/// Result-pattern wrapper. Returned by every MediatR command/query handler
-/// per ADR-0032 § Error Model.
+/// per ADR-0032 § Error Model. Construction is funnelled through the
+/// / factories; the primary
+/// constructor is internal so callers cannot bypass the success-
+/// must-carry-value rule via positional record syntax.
///
///
/// CA1000 (do not declare static members on generic types) is intentionally
@@ -20,9 +24,47 @@ namespace LearnStack.SharedKernel.Results;
"Design",
"CA1000:Do not declare static members on generic types",
Justification = "Result+Error factory pattern per ADR-0032 — canonical shape across FluentResults / Ardalis.Result lineage.")]
-public sealed record Result(bool IsSuccess, T? Value, Error? Error)
+public sealed record Result : IResultBase
{
- public static Result Ok(T value) => new(true, value, null);
+ internal Result(bool isSuccess, T? value, Error? error, LocalizedMessage? successMessage = null)
+ {
+ IsSuccess = isSuccess;
+ Value = value;
+ Error = error;
+ SuccessMessage = successMessage;
+ }
- public static Result Fail(Error error) => new(false, default, error);
+ public bool IsSuccess { get; }
+
+ public bool IsFailure => !IsSuccess;
+
+ public T? Value { get; }
+
+ public Error? Error { get; }
+
+ public LocalizedMessage? SuccessMessage { get; }
+
+ ///
+ /// Constructs a success result. Throws when is
+ /// null: Standards 09 § Forbidden bans IsSuccess = true
+ /// with Value = null — if a payload-less success shape is needed,
+ /// model it as Result<Unit>.
+ ///
+ public static Result Ok(T value, LocalizedMessage? message = null)
+ {
+ if (value is null)
+ {
+ throw new ArgumentNullException(
+ nameof(value),
+ "Result.Ok cannot wrap a null value. Use Result for payload-less success per Standards 09 § Forbidden.");
+ }
+
+ return new Result(isSuccess: true, value: value, error: null, successMessage: message);
+ }
+
+ public static Result Fail(Error error)
+ {
+ ArgumentNullException.ThrowIfNull(error);
+ return new Result(isSuccess: false, value: default, error: error);
+ }
}
diff --git a/backend/src/LearnStack.SharedKernel/Results/Unit.cs b/backend/src/LearnStack.SharedKernel/Results/Unit.cs
new file mode 100644
index 0000000..0775fed
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Results/Unit.cs
@@ -0,0 +1,17 @@
+namespace LearnStack.SharedKernel.Results;
+
+///
+/// Empty value used as the payload of Result<Unit> when a
+/// command/query succeeds without returning data. Use this rather than
+/// Result<object?> with a null value (Standards 09 § Forbidden
+/// bans the latter).
+///
+public readonly record struct Unit
+{
+ ///
+ /// The single canonical value. All
+ /// instances are equal by definition; is just the
+ /// idiomatic spelling at call sites.
+ ///
+ public static Unit Value => default;
+}
diff --git a/backend/src/LearnStack.SharedKernel/Time/FixedClock.cs b/backend/src/LearnStack.SharedKernel/Time/FixedClock.cs
new file mode 100644
index 0000000..390f1dc
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Time/FixedClock.cs
@@ -0,0 +1,28 @@
+namespace LearnStack.SharedKernel.Time;
+
+///
+/// Deterministic for tests. Time advances only through
+/// / — never spontaneously.
+///
+///
+/// All stored instants are normalised to UTC offset (TimeSpan.Zero):
+/// the contract promises a UTC value, so an
+/// input carrying a non-zero offset is converted via
+/// at the boundary rather
+/// than silently returned to the caller with the wrong offset.
+///
+public sealed class FixedClock : IClock
+{
+ private DateTimeOffset _utcNow;
+
+ public FixedClock(DateTimeOffset utcNow)
+ {
+ _utcNow = utcNow.ToUniversalTime();
+ }
+
+ public DateTimeOffset UtcNow => _utcNow;
+
+ public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow.ToUniversalTime();
+
+ public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta).ToUniversalTime();
+}
diff --git a/backend/src/LearnStack.SharedKernel/Time/IClock.cs b/backend/src/LearnStack.SharedKernel/Time/IClock.cs
new file mode 100644
index 0000000..2022ba2
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Time/IClock.cs
@@ -0,0 +1,16 @@
+namespace LearnStack.SharedKernel.Time;
+
+///
+/// Wall-clock abstraction for domain and application code. Per Standards 02
+/// § Time, no production code reads DateTime.UtcNow /
+/// DateTimeOffset.UtcNow directly — every timestamp flows through
+/// so tests can pin time deterministically.
+///
+public interface IClock
+{
+ ///
+ /// Current UTC instant. Persisted timestamps are always UTC; conversion
+ /// to a presentation timezone happens at the surface boundary only.
+ ///
+ DateTimeOffset UtcNow { get; }
+}
diff --git a/backend/src/LearnStack.SharedKernel/Time/SystemClock.cs b/backend/src/LearnStack.SharedKernel/Time/SystemClock.cs
new file mode 100644
index 0000000..d1f37c3
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Time/SystemClock.cs
@@ -0,0 +1,10 @@
+namespace LearnStack.SharedKernel.Time;
+
+///
+/// Production implementation backed by the BCL system
+/// clock. Registered as a singleton at the composition root.
+///
+public sealed class SystemClock : IClock
+{
+ public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
+}
diff --git a/backend/src/Modules/Directory.Build.props b/backend/src/Modules/Directory.Build.props
new file mode 100644
index 0000000..c0109f8
--- /dev/null
+++ b/backend/src/Modules/Directory.Build.props
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj
index 3850793..14c940c 100644
--- a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj
+++ b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj
@@ -19,6 +19,14 @@
+
+
+
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs
new file mode 100644
index 0000000..45b66c4
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs
@@ -0,0 +1,148 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Identifiers;
+using LearnStack.SharedKernel.Persistence;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Domain;
+
+public sealed class AuditableEntityTests
+{
+ private static readonly DateTimeOffset T0 =
+ new(2026, 05, 21, 10, 00, 00, TimeSpan.Zero);
+
+ private static readonly UserId Actor =
+ UserId.From(Guid.Parse("019712ac-aaaa-7000-8000-000000000aaa"));
+
+ [Fact]
+ public void MarkCreated_SetsCreatedAtAndCreatedBy()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+
+ aggregate.MarkCreated(T0, Actor);
+
+ aggregate.CreatedAt.Should().Be(T0);
+ aggregate.CreatedBy.Should().Be(Actor);
+ aggregate.UpdatedAt.Should().BeNull();
+ aggregate.DeletedAt.Should().BeNull();
+ aggregate.IsDeleted.Should().BeFalse();
+ }
+
+ [Fact]
+ public void MarkCreated_CalledTwice_Throws()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+ aggregate.MarkCreated(T0, Actor);
+
+ var act = () => aggregate.MarkCreated(T0.AddHours(1), Actor);
+
+ act.Should().Throw().WithMessage("*already*");
+ }
+
+ [Fact]
+ public void MarkUpdated_SetsUpdatedAtAndUpdatedBy()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+ aggregate.MarkCreated(T0, Actor);
+ var laterActor = UserId.From(Guid.Parse("019712ac-bbbb-7000-8000-000000000bbb"));
+ var t1 = T0.AddHours(1);
+
+ aggregate.MarkUpdated(t1, laterActor);
+
+ aggregate.UpdatedAt.Should().Be(t1);
+ aggregate.UpdatedBy.Should().Be(laterActor);
+ aggregate.CreatedAt.Should().Be(T0, "create stays untouched");
+ }
+
+ [Fact]
+ public void SoftDelete_SetsDeletedColumns_AndAlsoBumpsUpdated()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+ aggregate.MarkCreated(T0, Actor);
+ var t1 = T0.AddDays(3);
+
+ aggregate.SoftDelete(t1, Actor);
+
+ aggregate.DeletedAt.Should().Be(t1);
+ aggregate.DeletedBy.Should().Be(Actor);
+ aggregate.IsDeleted.Should().BeTrue();
+ aggregate.UpdatedAt.Should().Be(t1, "SoftDelete bumps UpdatedAt for monotonic last-touched");
+ aggregate.UpdatedBy.Should().Be(Actor);
+ }
+
+ [Fact]
+ public void ISoftDelete_DeletedBy_IsStronglyTypedUserId()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+ aggregate.MarkCreated(T0, Actor);
+ aggregate.SoftDelete(T0.AddDays(1), Actor);
+
+ // The cast is the point of this test - the marker contract is what
+ // we are asserting. CA1859 would prefer the concrete type for
+ // performance.
+#pragma warning disable CA1859
+ ISoftDelete view = aggregate;
+#pragma warning restore CA1859
+
+ view.DeletedBy.Should().Be(Actor);
+ }
+
+ // Vogen prohibits constructing `default(UserId)` at compile time (VOG009),
+ // so the "empty actor" case is exercised via UserId.From(Guid.Empty) -
+ // the guard reads `by.Value == Guid.Empty`, which both forms hit.
+ private static readonly UserId EmptyActor = UserId.From(Guid.Empty);
+
+ [Fact]
+ public void MarkCreated_WithDefaultTimestamp_Throws()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+
+ var act = () => aggregate.MarkCreated(default, Actor);
+
+ act.Should().Throw().WithMessage("*default*");
+ }
+
+ [Fact]
+ public void MarkCreated_WithEmptyActor_Throws()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+
+ var act = () => aggregate.MarkCreated(T0, EmptyActor);
+
+ act.Should().Throw().WithMessage("*UserId*");
+ }
+
+ [Fact]
+ public void MarkUpdated_WithInvalidInputs_Throws()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+ aggregate.MarkCreated(T0, Actor);
+
+ var defaultAt = () => aggregate.MarkUpdated(default, Actor);
+ var emptyBy = () => aggregate.MarkUpdated(T0.AddHours(1), EmptyActor);
+
+ defaultAt.Should().Throw();
+ emptyBy.Should().Throw();
+ }
+
+ [Fact]
+ public void SoftDelete_WithInvalidInputs_Throws()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+ aggregate.MarkCreated(T0, Actor);
+
+ var defaultAt = () => aggregate.SoftDelete(default, Actor);
+ var emptyBy = () => aggregate.SoftDelete(T0.AddDays(1), EmptyActor);
+
+ defaultAt.Should().Throw();
+ emptyBy.Should().Throw();
+ }
+
+ [Fact]
+ public void NewlyConstructed_AggregateIsNotDeleted()
+ {
+ var aggregate = new TestAuditableAggregate(TestId.New());
+
+ aggregate.IsDeleted.Should().BeFalse();
+ aggregate.Version.Should().Be(0u);
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/DomainEventTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/DomainEventTests.cs
new file mode 100644
index 0000000..457783e
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/DomainEventTests.cs
@@ -0,0 +1,34 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Domain;
+using MediatR;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Domain;
+
+public sealed class DomainEventTests
+{
+ private static readonly DateTimeOffset T0 =
+ new(2026, 01, 01, 0, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public void IDomainEvent_IsAMediatRNotification()
+ {
+ typeof(INotification).IsAssignableFrom(typeof(IDomainEvent))
+ .Should().BeTrue("domain events dispatch in-process through MediatR");
+ }
+
+ [Fact]
+ public void Stamped_Event_CarriesTheSuppliedEventIdAndOccurredAt()
+ {
+ var eventId = Guid.CreateVersion7();
+
+ var @event = new TestDomainEvent("payload")
+ {
+ EventId = eventId,
+ OccurredAt = T0,
+ };
+
+ @event.EventId.Should().Be(eventId);
+ @event.OccurredAt.Should().Be(T0);
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/EntityTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/EntityTests.cs
new file mode 100644
index 0000000..17271c2
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/EntityTests.cs
@@ -0,0 +1,99 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Domain;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Domain;
+
+public sealed class EntityTests
+{
+ [Fact]
+ public void Equality_IsIdentityBased_ForPersistedIds()
+ {
+ var id = TestId.New();
+ var a = new TestAggregate(id);
+ var b = new TestAggregate(id);
+
+ a.Equals(b).Should().BeTrue();
+ a.GetHashCode().Should().Be(b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_AcrossDifferentIds_IsFalse()
+ {
+ var a = new TestAggregate(TestId.New());
+ var b = new TestAggregate(TestId.New());
+
+ a.Equals(b).Should().BeFalse();
+ }
+
+ [Fact]
+ public void Equality_TwoTransientEntities_AreNeverEqual()
+ {
+ // Both default-id ("not yet persisted") — must not collapse in the
+ // change tracker / HashSet-based collection navigations.
+ // The hash falls back to reference identity (object.GetHashCode);
+ // we only assert the Equals contract because RuntimeHelpers identity
+ // hashes are not guaranteed-distinct (collision is rare but legal).
+ var a = new TestAggregate();
+ var b = new TestAggregate();
+
+ a.Equals(b).Should().BeFalse();
+ }
+
+ [Fact]
+ public void Equality_TransientEntityEqualsItself_ByReference()
+ {
+ var a = new TestAggregate();
+
+ a.Equals(a).Should().BeTrue();
+ }
+
+ [Fact]
+ public void Equality_DifferentRuntimeType_SameId_IsFalse()
+ {
+ // Two entity types that share Entity and the same Id value
+ // must still compare false — they describe different aggregates.
+ var id = TestId.New();
+ Entity a = new TestAggregate(id);
+ Entity b = new TestAggregateSibling(id);
+
+ a.Equals(b).Should().BeFalse();
+ }
+
+ [Fact]
+ public void RaiseDomainEvent_AppendsToCollection()
+ {
+ var aggregate = new TestAggregate(TestId.New());
+ IDomainEvent @event = new TestDomainEvent("hello")
+ {
+ EventId = Guid.CreateVersion7(),
+ OccurredAt = DateTimeOffset.UtcNow,
+ };
+
+ aggregate.Raise(@event);
+
+ aggregate.DomainEvents.Should().ContainSingle().Which.Should().Be(@event);
+ }
+
+ [Fact]
+ public void ClearDomainEvents_EmptiesTheCollection()
+ {
+ var aggregate = new TestAggregate(TestId.New());
+ aggregate.Raise(new TestDomainEvent("a") { EventId = Guid.CreateVersion7(), OccurredAt = DateTimeOffset.UtcNow });
+ aggregate.Raise(new TestDomainEvent("b") { EventId = Guid.CreateVersion7(), OccurredAt = DateTimeOffset.UtcNow });
+
+ aggregate.ClearDomainEvents();
+
+ aggregate.DomainEvents.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void RaiseDomainEvent_WithNull_Throws()
+ {
+ var aggregate = new TestAggregate(TestId.New());
+
+ var act = () => aggregate.Raise(null!);
+
+ act.Should().Throw();
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/TestAggregate.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/TestAggregate.cs
new file mode 100644
index 0000000..0d3075b
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/TestAggregate.cs
@@ -0,0 +1,38 @@
+using LearnStack.SharedKernel.Domain;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Domain;
+
+///
+/// Test double for . Exposes
+/// so unit tests can drive the domain-event collector
+/// without inventing a fake aggregate per test.
+///
+internal sealed class TestAggregate : Entity
+{
+ public TestAggregate(TestId id) : base(id) { }
+
+ public TestAggregate() { }
+
+ public void Raise(IDomainEvent domainEvent) => RaiseDomainEvent(domainEvent);
+}
+
+///
+/// Second test entity type — same TId, different runtime type. Used to
+/// verify Entity<TId>'s cross-runtime-type equality guard.
+///
+internal sealed class TestAggregateSibling : Entity
+{
+ public TestAggregateSibling(TestId id) : base(id) { }
+}
+
+///
+/// Test double for .
+///
+internal sealed class TestAuditableAggregate : AuditableEntity
+{
+ public TestAuditableAggregate(TestId id) : base(id) { }
+
+ public TestAuditableAggregate() { }
+}
+
+internal sealed record TestDomainEvent(string Payload) : DomainEvent;
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/TestId.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/TestId.cs
new file mode 100644
index 0000000..05a2774
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/TestId.cs
@@ -0,0 +1,17 @@
+using LearnStack.SharedKernel;
+using LearnStack.SharedKernel.Identifiers;
+using Vogen;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Domain;
+
+///
+/// Synthetic Vogen-emitted ID used by the Entity / AuditableEntity / Vogen
+/// smoke tests. Lives in the test project (not SharedKernel) because
+/// concrete aggregate IDs belong to their owning module — but the emitter
+/// pipeline has to be exercisable end-to-end before any module ships one.
+///
+[ValueObject(LearnStackVogenDefaults.IdMask)]
+public readonly partial record struct TestId : IStronglyTypedId
+{
+ public static TestId New() => From(Guid.CreateVersion7());
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/ErrorTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/ErrorTests.cs
new file mode 100644
index 0000000..4786e61
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/ErrorTests.cs
@@ -0,0 +1,129 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Localization;
+using LearnStack.SharedKernel.Results;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel;
+
+public sealed class ErrorTests
+{
+ [Fact]
+ public void Code_StripsLockeyPrefix_FromMessageKey()
+ {
+ var error = new Error(LocalizedMessage.Of("lockey_forbidden"));
+
+ error.Code.Should().Be("forbidden",
+ "Standards 04 § Problem Details Code is the unprefixed stable identifier");
+ }
+
+ [Fact]
+ public void Code_ForValidationFailed_MatchesStandards09Catalogue()
+ {
+ var error = new Error(LocalizedMessage.Of("lockey_validation_failed"));
+
+ error.Code.Should().Be("validation_failed");
+ }
+
+ [Fact]
+ public void Ctor_NullMessage_Throws()
+ {
+ var act = () => new Error(message: null!);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Details_AreOptional()
+ {
+ var error = new Error(LocalizedMessage.Of("lockey_validation_failed"));
+
+ error.Details.Should().BeNull();
+ }
+
+ [Fact]
+ public void Details_CarryFieldLevelLocalizedMessages()
+ {
+ var details = new Dictionary>
+ {
+ ["email"] =
+ [
+ LocalizedMessage.Of("lockey_email_required"),
+ LocalizedMessage.Of("lockey_email_invalid"),
+ ],
+ };
+
+ var error = new Error(
+ LocalizedMessage.Of("lockey_validation_failed"),
+ details);
+
+ error.Details.Should().BeEquivalentTo(details);
+ error.Details!["email"].Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void Details_AreDefensivelyCopied()
+ {
+ // Caller mutation after construction must not leak into the Error;
+ // Problem Details bodies, audit rows, and logs all read Details
+ // after the handler returns, and divergent state across those
+ // sinks would be a real bug.
+ var emailErrors = new List
+ {
+ LocalizedMessage.Of("lockey_email_required"),
+ };
+ var details = new Dictionary>
+ {
+ ["email"] = emailErrors,
+ };
+
+ var error = new Error(LocalizedMessage.Of("lockey_validation_failed"), details);
+
+ // Mutate the caller's collections after the Error was constructed.
+ emailErrors.Add(LocalizedMessage.Of("lockey_email_invalid"));
+ details["password"] = [LocalizedMessage.Of("lockey_password_required")];
+
+ error.Details!["email"].Should().HaveCount(1, "the snapshot was taken at ctor time");
+ error.Details.ContainsKey("password").Should().BeFalse();
+ }
+
+ [Fact]
+ public void Equality_IsStructural_AcrossDetails()
+ {
+ // Record equality on IReadOnlyDictionary defaults to reference
+ // equality; the override compares Message + Details key-by-key.
+ var detailsA = new Dictionary>
+ {
+ ["email"] = [LocalizedMessage.Of("lockey_email_invalid")],
+ };
+ var detailsB = new Dictionary>
+ {
+ ["email"] = [LocalizedMessage.Of("lockey_email_invalid")],
+ };
+
+ var a = new Error(LocalizedMessage.Of("lockey_validation_failed"), detailsA);
+ var b = new Error(LocalizedMessage.Of("lockey_validation_failed"), detailsB);
+
+ a.Equals(b).Should().BeTrue();
+ a.GetHashCode().Should().Be(b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_DifferentDetails_AreNotEqual()
+ {
+ var a = new Error(
+ LocalizedMessage.Of("lockey_validation_failed"),
+ new Dictionary>
+ {
+ ["email"] = [LocalizedMessage.Of("lockey_email_invalid")],
+ });
+
+ var b = new Error(
+ LocalizedMessage.Of("lockey_validation_failed"),
+ new Dictionary>
+ {
+ ["email"] = [LocalizedMessage.Of("lockey_email_required")],
+ });
+
+ a.Equals(b).Should().BeFalse();
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/FixedGuidFactoryTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/FixedGuidFactoryTests.cs
new file mode 100644
index 0000000..d44f22e
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/FixedGuidFactoryTests.cs
@@ -0,0 +1,55 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Identifiers;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Identifiers;
+
+public sealed class FixedGuidFactoryTests
+{
+ private static readonly Guid G1 = Guid.Parse("019712ac-1111-7000-8000-000000000001");
+ private static readonly Guid G2 = Guid.Parse("019712ac-2222-7000-8000-000000000002");
+
+ [Fact]
+ public void NewUuidV7_ReturnsTheNextSequenceValue()
+ {
+ var factory = new FixedGuidFactory(G1, G2);
+
+ factory.NewUuidV7().Should().Be(G1);
+ factory.NewUuidV7().Should().Be(G2);
+ }
+
+ [Fact]
+ public void NewUuidV7_AfterSequenceExhausted_Throws()
+ {
+ var factory = new FixedGuidFactory(G1);
+ factory.NewUuidV7();
+
+ var act = () => factory.NewUuidV7();
+
+ act.Should().Throw()
+ .WithMessage("*exhausted*");
+ }
+
+ [Fact]
+ public void NewUuidV4_DrawsFromTheSameSequence()
+ {
+ var factory = new FixedGuidFactory(G1, G2);
+
+ factory.NewUuidV4().Should().Be(G1);
+ factory.NewUuidV4().Should().Be(G2);
+ }
+
+ [Fact]
+ public void MixedV7AndV4_PreservesVersionPerSeed()
+ {
+ // The fixture does not synthesise version-7/-4 shapes — callers
+ // seed version-appropriate Guids and the fixture returns them
+ // verbatim. Locks the documented shared-queue contract.
+ var v7 = Guid.CreateVersion7();
+ var v4 = Guid.NewGuid();
+ var factory = new FixedGuidFactory(v7, v4);
+
+ factory.NewUuidV7().Version.Should().Be(7);
+ factory.NewUuidV4().Version.Should().Be(4);
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/SystemGuidFactoryTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/SystemGuidFactoryTests.cs
new file mode 100644
index 0000000..040211b
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/SystemGuidFactoryTests.cs
@@ -0,0 +1,39 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Identifiers;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Identifiers;
+
+public sealed class SystemGuidFactoryTests
+{
+ [Fact]
+ public void NewUuidV7_ProducesVersion7Guid()
+ {
+ var factory = new SystemGuidFactory();
+
+ var guid = factory.NewUuidV7();
+
+ guid.Version.Should().Be(7);
+ }
+
+ [Fact]
+ public void NewUuidV4_ProducesVersion4Guid()
+ {
+ var factory = new SystemGuidFactory();
+
+ var guid = factory.NewUuidV4();
+
+ guid.Version.Should().Be(4);
+ }
+
+ [Fact]
+ public void NewUuidV7_TwoCalls_ProduceDifferentGuids()
+ {
+ var factory = new SystemGuidFactory();
+
+ var a = factory.NewUuidV7();
+ var b = factory.NewUuidV7();
+
+ a.Should().NotBe(b);
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/VogenIdEmissionTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/VogenIdEmissionTests.cs
new file mode 100644
index 0000000..f845e48
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/VogenIdEmissionTests.cs
@@ -0,0 +1,72 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.Text.Json;
+using FluentAssertions;
+using LearnStack.SharedKernel.Identifiers;
+using LearnStack.Tests.Unit.SharedKernel.Domain;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Identifiers;
+
+///
+/// End-to-end smoke test for the Vogen emitter pipeline (per ADR-0023).
+/// The synthetic declares the canonical annotation
+/// [ValueObject<Guid>(LearnStackVogenDefaults.IdMask)]; this
+/// fixture asserts the four emitted artefacts are wired:
+/// - System.Text.Json round trip (Conversions.SystemTextJson).
+/// - TypeConverter parse/format (Conversions.TypeConverter — the path
+/// ASP.NET Core minimal-API route binding and IConfiguration use).
+/// - projection.
+/// - Type-safe inequality between two arbitrary IDs.
+/// EF Core converter / minimal-API model binder are exercised at the
+/// integration-test layer when the first DbContext lands (Packet 6+).
+///
+public sealed class VogenIdEmissionTests
+{
+ [Fact]
+ public void IStronglyTypedId_Value_ExposesUnderlyingGuid()
+ {
+ var guid = Guid.CreateVersion7();
+ var id = TestId.From(guid);
+
+ ((IStronglyTypedId)id).Value.Should().Be(guid);
+ }
+
+ [Fact]
+ public void SystemTextJson_RoundTrip_PreservesValue()
+ {
+ var original = TestId.New();
+
+ var json = JsonSerializer.Serialize(original);
+ var decoded = JsonSerializer.Deserialize(json);
+
+ decoded.Should().Be(original);
+ }
+
+ [Fact]
+ public void TypeConverter_RoundTrips_ViaString()
+ {
+ // Conversions.TypeConverter is the artefact ASP.NET Core minimal-API
+ // route binding (and IConfiguration binding) rely on. Asserting the
+ // converter exists and round-trips proves the mask wired the
+ // TypeConverter the runtime will discover.
+ var original = TestId.New();
+ var converter = TypeDescriptor.GetConverter(typeof(TestId));
+
+ converter.Should().NotBeNull();
+ converter.CanConvertTo(typeof(string)).Should().BeTrue();
+ converter.CanConvertFrom(typeof(string)).Should().BeTrue();
+
+ var encoded = converter.ConvertToString(null, CultureInfo.InvariantCulture, original);
+ var decoded = (TestId)converter.ConvertFromString(null!, CultureInfo.InvariantCulture, encoded!)!;
+
+ decoded.Should().Be(original);
+ ((IStronglyTypedId)decoded).Value.Should().Be(((IStronglyTypedId)original).Value);
+ }
+
+ [Fact]
+ public void TwoIdsWithDifferentGuids_AreNotEqual()
+ {
+ TestId.New().Should().NotBe(TestId.New());
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Localization/LocalizedMessageTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Localization/LocalizedMessageTests.cs
new file mode 100644
index 0000000..3c1c83e
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Localization/LocalizedMessageTests.cs
@@ -0,0 +1,84 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Localization;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Localization;
+
+public sealed class LocalizedMessageTests
+{
+ [Fact]
+ public void Ctor_WithLockeyPrefix_Constructs()
+ {
+ var message = new LocalizedMessage("lockey_validation_failed");
+
+ message.Key.Should().Be("lockey_validation_failed");
+ message.Params.Should().BeNull();
+ }
+
+ [Fact]
+ public void Ctor_WithParams_RetainsThem()
+ {
+ var @params = new Dictionary { ["field"] = "email" };
+
+ var message = new LocalizedMessage("lockey_validation_required", @params);
+
+ message.Params.Should().BeEquivalentTo(@params);
+ }
+
+ [Fact]
+ public void Ctor_DefensivelyCopiesParams_SoCallerMutationsDoNotLeak()
+ {
+ var @params = new Dictionary { ["field"] = "email" };
+
+ var message = new LocalizedMessage("lockey_validation_required", @params);
+ @params["field"] = "MUTATED";
+
+ message.Params!["field"].Should().Be("email", "LocalizedMessage is immutable; the snapshot was taken at ctor time");
+ }
+
+ [Fact]
+ public void Ctor_EmptyParamsDictionary_NormalisesToNull()
+ {
+ // Avoids serializers emitting an unused "params": {} field.
+ var message = new LocalizedMessage(
+ "lockey_no_params",
+ new Dictionary());
+
+ message.Params.Should().BeNull();
+ }
+
+ [Theory]
+ [InlineData("validation_failed")]
+ [InlineData("lockEY_wrong_case")]
+ [InlineData("LOCKEY_uppercase")]
+ [InlineData("error.something")]
+ [InlineData(" lockey_leading_space")]
+ public void Ctor_WithoutLockeyPrefix_Throws(string badKey)
+ {
+ var act = () => new LocalizedMessage(badKey);
+
+ act.Should().Throw()
+ .WithMessage("*lockey_*");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Ctor_WithEmptyOrWhitespaceKey_Throws(string key)
+ {
+ var act = () => new LocalizedMessage(key);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Of_IsEquivalentToCtor()
+ {
+ var @params = new Dictionary { ["count"] = "5" };
+
+ var direct = new LocalizedMessage("lockey_too_many", @params);
+ var viaOf = LocalizedMessage.Of("lockey_too_many", @params);
+
+ viaOf.Should().Be(direct);
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Pagination/CursorPaginationTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Pagination/CursorPaginationTests.cs
new file mode 100644
index 0000000..96e3458
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Pagination/CursorPaginationTests.cs
@@ -0,0 +1,110 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Pagination;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Pagination;
+
+public sealed class CursorPaginationTests
+{
+ [Fact]
+ public void DefaultLimit_MatchesStandards04()
+ {
+ new CursorPagination().Limit.Should().Be(CursorPagination.DefaultLimit);
+ CursorPagination.DefaultLimit.Should().Be(20);
+ CursorPagination.MaxLimit.Should().Be(100);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Ctor_LimitNonPositive_Throws(int limit)
+ {
+ // Kernel-level programmer-error guard. The API-layer validation
+ // turns malformed user input into validation_failed BEFORE the
+ // kernel sees the request.
+ var act = () => new CursorPagination(Limit: limit);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Ctor_LimitAboveMax_ClampsToMaxLimit()
+ {
+ // Construction is the single chokepoint for the invariant - any
+ // CursorPagination instance is guaranteed to have Limit in
+ // [1, MaxLimit] without callers having to remember a separate
+ // normalisation step (the previous Normalised() method).
+ var request = new CursorPagination(Limit: 500);
+
+ request.Limit.Should().Be(CursorPagination.MaxLimit);
+ }
+
+ [Fact]
+ public void Ctor_LimitEqualMax_IsAccepted()
+ {
+ // Off-by-one boundary: MaxLimit itself is legal, not above.
+ var request = new CursorPagination(Limit: CursorPagination.MaxLimit);
+
+ request.Limit.Should().Be(CursorPagination.MaxLimit);
+ }
+
+ [Fact]
+ public void Ctor_LimitOne_IsAccepted()
+ {
+ var request = new CursorPagination(Limit: 1);
+
+ request.Limit.Should().Be(1);
+ }
+
+ [Fact]
+ public void Ctor_CarriesCursor()
+ {
+ var request = new CursorPagination(Cursor: "abc", Limit: 50);
+
+ request.Cursor.Should().Be("abc");
+ request.Limit.Should().Be(50);
+ }
+
+ [Fact]
+ public void ObjectInitializer_AndWithExpression_PreserveValuesAndInvariants()
+ {
+ // The Limit invariant lives in the init accessor, not the
+ // constructor, so it covers object-initializer syntax AND the
+ // record's `with` expression - neither can bypass the guard the
+ // ctor would otherwise be the only enforcer of.
+ var fromInit = new CursorPagination { Cursor = "abc", Limit = 50 };
+ var fromWith = fromInit with { Limit = 75 };
+
+ fromInit.Cursor.Should().Be("abc");
+ fromInit.Limit.Should().Be(50);
+
+ fromWith.Cursor.Should().Be("abc");
+ fromWith.Limit.Should().Be(75);
+ }
+
+ [Fact]
+ public void ObjectInitializer_ZeroLimit_Throws()
+ {
+ var act = () => new CursorPagination { Limit = 0 };
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void WithExpression_ZeroLimit_Throws()
+ {
+ var request = new CursorPagination(Limit: 50);
+
+ var act = () => request with { Limit = 0 };
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ObjectInitializer_AboveMaxLimit_Clamps()
+ {
+ var request = new CursorPagination { Limit = 500 };
+
+ request.Limit.Should().Be(CursorPagination.MaxLimit);
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Pagination/PageTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Pagination/PageTests.cs
new file mode 100644
index 0000000..0124a09
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Pagination/PageTests.cs
@@ -0,0 +1,29 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Pagination;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Pagination;
+
+public sealed class PageTests
+{
+ [Fact]
+ public void Empty_HasNoItems_AndNoCursors()
+ {
+ Page.Empty.Items.Should().BeEmpty();
+ Page.Empty.PageInfo.NextCursor.Should().BeNull();
+ Page.Empty.PageInfo.PreviousCursor.Should().BeNull();
+ Page.Empty.PageInfo.HasNext.Should().BeFalse();
+ Page.Empty.PageInfo.HasPrevious.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Constructed_PageCarriesItemsAndPageInfo()
+ {
+ var info = new PageInfo("next", "prev", HasNext: true, HasPrevious: true);
+
+ var page = new Page([1, 2, 3], info);
+
+ page.Items.Should().BeEquivalentTo([1, 2, 3]);
+ page.PageInfo.Should().Be(info);
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Random/FixedRandomTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Random/FixedRandomTests.cs
new file mode 100644
index 0000000..582788f
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Random/FixedRandomTests.cs
@@ -0,0 +1,41 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Random;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Random;
+
+public sealed class FixedRandomTests
+{
+ [Fact]
+ public void SameSeed_ProducesSameSequence()
+ {
+ var a = new FixedRandom(seed: 42);
+ var b = new FixedRandom(seed: 42);
+
+ Enumerable.Range(0, 10).Select(_ => a.Next(1_000_000))
+ .Should().Equal(
+ Enumerable.Range(0, 10).Select(_ => b.Next(1_000_000)));
+ }
+
+ [Fact]
+ public void Next_BoundedByMaxExclusive()
+ {
+ var random = new FixedRandom(seed: 1);
+
+ for (var i = 0; i < 100; i++)
+ {
+ random.Next(maxExclusive: 5).Should().BeInRange(0, 4);
+ }
+ }
+
+ [Fact]
+ public void NextBytes_FillsTheDestinationSpan()
+ {
+ var random = new FixedRandom(seed: 7);
+ var buffer = new byte[16];
+
+ random.NextBytes(buffer);
+
+ buffer.Should().NotEqual(new byte[16], "the seeded random fills bytes");
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/ResultTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/ResultTests.cs
index 439387d..2773162 100644
--- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/ResultTests.cs
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/ResultTests.cs
@@ -1,6 +1,8 @@
using FluentAssertions;
+using LearnStack.SharedKernel.Localization;
using LearnStack.SharedKernel.Results;
using Xunit;
+using ResultUnit = LearnStack.SharedKernel.Results.Unit;
namespace LearnStack.Tests.Unit.SharedKernel;
@@ -12,19 +14,104 @@ public void Ok_WhenGivenValue_ReturnsSuccessResult()
var result = Result.Ok(42);
result.IsSuccess.Should().BeTrue();
+ result.IsFailure.Should().BeFalse();
result.Value.Should().Be(42);
result.Error.Should().BeNull();
+ result.SuccessMessage.Should().BeNull();
+ }
+
+ [Fact]
+ public void Ok_WithNullReferenceValue_Throws()
+ {
+ // Standards 09 § Forbidden bans Result with IsSuccess=true and
+ // Value=null; the factory must reject this at the call site.
+ var act = () => Result.Ok(null!);
+
+ act.Should().Throw()
+ .WithMessage("*Result*");
+ }
+
+ [Fact]
+ public void Ok_WithUnit_IsTheCanonicalPayloadlessSuccess()
+ {
+ var result = Result.Ok(ResultUnit.Value);
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().Be(ResultUnit.Value);
+ }
+
+ [Fact]
+ public void Ok_WithSuccessMessage_RetainsIt()
+ {
+ var message = LocalizedMessage.Of("lockey_course_published");
+
+ var result = Result.Ok(42, message);
+
+ result.SuccessMessage.Should().Be(message);
}
[Fact]
public void Fail_WhenGivenError_ReturnsFailureResult()
{
- var error = new Error("test_error", "boom");
+ var error = new Error(LocalizedMessage.Of("lockey_business_rule_violation"));
var result = Result.Fail(error);
result.IsSuccess.Should().BeFalse();
+ result.IsFailure.Should().BeTrue();
result.Value.Should().Be(default);
result.Error.Should().Be(error);
}
+
+ [Fact]
+ public void FailFor_TResponseIsResultOfT_ReturnsConcreteResult()
+ {
+ // ADR-0032 § Sub-decision 3 shape: TResponse is Result; the
+ // factory MUST return Result, NOT Result>.
+ var error = new Error(LocalizedMessage.Of("lockey_validation_failed"));
+
+ var result = Result.FailFor>(error);
+
+ result.Should().BeOfType>();
+ result.IsFailure.Should().BeTrue();
+ result.Error.Should().Be(error);
+ }
+
+ [Fact]
+ public void FailFor_NonResultGeneric_Throws()
+ {
+ // FailFor is a pipeline helper; calling it with a non-Result
+ // type parameter is a coding bug and must fail loud rather than
+ // silently producing an unexpected shape.
+ var error = new Error(LocalizedMessage.Of("lockey_business_rule_violation"));
+
+ var act = () => Result.FailFor(error);
+
+ act.Should().Throw().WithMessage("*Result*");
+ }
+
+ [Fact]
+ public void IResultBase_IsImplementedByResult()
+ {
+ // The cast is the point of this test — pipeline behaviors operate
+ // through the non-generic IResultBase surface. CA1859 would prefer
+ // the concrete type for performance; suppressed locally because the
+ // explicit interface contract is what we are asserting.
+#pragma warning disable CA1859
+ IResultBase result = Result.Ok(7);
+#pragma warning restore CA1859
+
+ result.IsSuccess.Should().BeTrue();
+ result.Error.Should().BeNull();
+ }
+
+ // Helper type used by FailFor_NonResultGeneric_Throws.
+ private sealed record FakeResult(bool IsSuccess = true) : IResultBase
+ {
+ public bool IsFailure => !IsSuccess;
+
+ public LocalizedMessage? SuccessMessage => null;
+
+ public Error? Error => null;
+ }
}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Time/FixedClockTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Time/FixedClockTests.cs
new file mode 100644
index 0000000..cb2ee85
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Time/FixedClockTests.cs
@@ -0,0 +1,65 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Time;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Time;
+
+public sealed class FixedClockTests
+{
+ private static readonly DateTimeOffset T0 =
+ new(2026, 05, 21, 10, 00, 00, TimeSpan.Zero);
+
+ [Fact]
+ public void UtcNow_AfterConstruction_ReturnsConstructorValue()
+ {
+ var clock = new FixedClock(T0);
+
+ clock.UtcNow.Should().Be(T0);
+ }
+
+ [Fact]
+ public void Advance_AddsTheGivenInterval()
+ {
+ var clock = new FixedClock(T0);
+
+ clock.Advance(TimeSpan.FromMinutes(30));
+
+ clock.UtcNow.Should().Be(T0.AddMinutes(30));
+ }
+
+ [Fact]
+ public void SetUtcNow_ReplacesTheCurrentInstant()
+ {
+ var clock = new FixedClock(T0);
+ var target = T0.AddDays(7);
+
+ clock.SetUtcNow(target);
+
+ clock.UtcNow.Should().Be(target);
+ }
+
+ [Fact]
+ public void Ctor_NormalisesNonUtcOffsetToUtc()
+ {
+ // 10:00 in +03:00 == 07:00 UTC. The IClock.UtcNow contract requires
+ // a UTC-offset value; non-UTC inputs are normalised at the boundary.
+ var nonUtc = new DateTimeOffset(2026, 05, 21, 10, 00, 00, TimeSpan.FromHours(3));
+
+ var clock = new FixedClock(nonUtc);
+
+ clock.UtcNow.Offset.Should().Be(TimeSpan.Zero);
+ clock.UtcNow.Hour.Should().Be(7, "10:00 +03:00 is 07:00 UTC");
+ }
+
+ [Fact]
+ public void SetUtcNow_NormalisesNonUtcOffsetToUtc()
+ {
+ var clock = new FixedClock(T0);
+ var nonUtc = new DateTimeOffset(2026, 05, 21, 12, 00, 00, TimeSpan.FromHours(-5));
+
+ clock.SetUtcNow(nonUtc);
+
+ clock.UtcNow.Offset.Should().Be(TimeSpan.Zero);
+ clock.UtcNow.Hour.Should().Be(17, "12:00 -05:00 is 17:00 UTC");
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Time/SystemClockTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Time/SystemClockTests.cs
new file mode 100644
index 0000000..103c680
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Time/SystemClockTests.cs
@@ -0,0 +1,21 @@
+using FluentAssertions;
+using LearnStack.SharedKernel.Time;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.SharedKernel.Time;
+
+public sealed class SystemClockTests
+{
+ [Fact]
+ public void UtcNow_ReturnsCurrentInstant_WithinTolerance()
+ {
+ var clock = new SystemClock();
+
+ var before = DateTimeOffset.UtcNow;
+ var observed = clock.UtcNow;
+ var after = DateTimeOffset.UtcNow;
+
+ observed.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
+ observed.Offset.Should().Be(TimeSpan.Zero, "SystemClock returns UTC");
+ }
+}
diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md
index a5e2673..4c4cda6 100644
--- a/docs/architecture/15-event-and-outbox.md
+++ b/docs/architecture/15-event-and-outbox.md
@@ -156,12 +156,12 @@ public async Task> Handle(CreateEnrollmentCommand cmd, Can
EnrollmentId = enrollment.Id.Value,
LearnerId = cmd.LearnerId,
CourseId = cmd.CourseId,
- OccurredAt = DateTime.UtcNow
+ OccurredAt = _clock.UtcNow, // IClock per Standards 02 § Time
}, ct);
await _dbContext.SaveChangesAsync(ct); // aggregate + outbox row, atomic
- return Result.Success(MapToDto(enrollment), LocalizedMessage.Of("enrollment.created"));
+ return Result.Ok(MapToDto(enrollment), LocalizedMessage.Of("lockey_enrollment_created"));
}
```
diff --git a/docs/decisions/0023-strongly-typed-id-source-generator.md b/docs/decisions/0023-strongly-typed-id-source-generator.md
index 5c7f568..c925abf 100644
--- a/docs/decisions/0023-strongly-typed-id-source-generator.md
+++ b/docs/decisions/0023-strongly-typed-id-source-generator.md
@@ -200,7 +200,7 @@ Three things outweighed the appeal:
## Implementation Notes
-- **Package references:** `Directory.Packages.props` pins ``. **Every project that hosts `[ValueObject<>]` declarations** — `LearnStack.SharedKernel` (for cross-cutting value objects: `Email`, `Slug`, `LocaleCode`, `Money`) and **each `LearnStack.Modules..Domain`** (for its aggregate-root IDs) — adds ``. A `Directory.Build.props` rule under `backend/src/Modules/` keeps the per-module addition automatic when a new module is scaffolded. Source generators only run on projects that reference the generator package; transitive references do **not** carry the generator (this is a `PrivateAssets="all"` semantics constraint, not a Vogen quirk).
+- **Package references:** `Directory.Packages.props` pins `` (see Amendment 1 below for the rationale of the 6.x → 7.0.0 drift). **Every project that hosts `[ValueObject<>]` declarations** — `LearnStack.SharedKernel` (for cross-cutting value objects: `UserId`, `Email`, `Slug`, `LocaleCode`, `Money`) and **each `LearnStack.Modules..Domain`** (for its aggregate-root IDs) — adds `` plus a transitive `Microsoft.EntityFrameworkCore` reference (the Vogen-emitted EF converter requires it at compile time; the build-time exception is recorded in [Standards 01 § Dependency Direction](../standards/01-architecture-standards.md)). A `Directory.Build.props` rule under `backend/src/Modules/` keeps the per-module addition automatic when a new module is scaffolded. Source generators only run on projects that reference the generator package; transitive references do **not** carry the generator (this is a `PrivateAssets="all"` semantics constraint, not a Vogen quirk).
- **Naming convention (per Standards 02):** ID type names end in `Id`
(`TenantId`, `OrganizationId`, `CourseId`, …); value object types are named
for the concept (`Email`, not `EmailValueObject`).
@@ -244,7 +244,29 @@ Three things outweighed the appeal:
## Amendments
-_(none yet)_
+### Amendment 1 — Vogen 7.0.0 pin + architecture-test placement (2026-05-21)
+
+Two clarifications surfaced when the ADR met implementation in
+[Phase 02a Packet 2](../roadmap/phase-02a-kernel-tenancy.md):
+
+- **Pinned Vogen version is 7.0.0.** The ADR was written when 6.x was the
+ newest line; by the time `Directory.Packages.props` was wired the 6.0.x
+ series had been superseded on NuGet and the lowest available major was
+ `7.0.0`. The decision is unchanged — Vogen is still the chosen emitter
+ per the original "Decision" section; this amendment records the
+ concrete version pin for traceability. The previous-line placeholder
+ in Implementation Notes (`Version="..."`) is now read as `Version="7.0.0"`.
+
+- **`Aggregate_Roots_Use_StronglyTypedId` lands with the first aggregate,
+ not in Packet 2.** Implementation Notes originally said "lands in
+ Phase 02a Packet 2"; that placement is wrong because no module ships an
+ aggregate in Packet 2 (the first concrete aggregate IDs arrive in
+ Packet 6 — Tenancy schema foundations — and after). The test would have
+ been vacuously green for the entire Packet 2 → Packet 5 window. The
+ correct placement is alongside the first `IAggregateRoot` type,
+ catalogued under
+ [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md)
+ at that point.
## References
diff --git a/docs/glossary.md b/docs/glossary.md
index bb9891d..8a0d051 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -245,8 +245,16 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across
| Term | Definition |
|------|------------|
-| **`Result`** | The sealed record `(bool IsSuccess, T? Value, Error? Error)` in `LearnStack.SharedKernel.Results`. Application + Domain layer methods return `Result` for **expected** outcomes (validation failed, not found, forbidden, business-rule violation). Exceptions are reserved for **unexpected** bugs / infrastructure faults. See [09-error-handling.md § Two-Track Model](standards/09-error-handling.md) and [ADR-0032](decisions/0032-exception-handling-logging-and-observability.md). |
-| **`Error`** | The sealed record `(string Code, string Message, IReadOnlyDictionary? Details)` that travels inside `Result.Fail(error)`. `Code` is a stable English identifier from the 13-code catalogue ([09-error-handling.md § Result Type](standards/09-error-handling.md)); `Message` is localisable; `Details` carries field-level errors. |
+| **`Result`** | The sealed record in `LearnStack.SharedKernel.Results` implementing `IResultBase`: carries `IsSuccess`, `Value`, optional `Error`, and an optional `SuccessMessage` (a `LocalizedMessage`). Primary constructor is `internal`; callers go through `Ok` (throws on null value) / `Fail`. Application + Domain layer methods return `Result` for **expected** outcomes (validation failed, not found, forbidden, business-rule violation). Exceptions are reserved for **unexpected** bugs / infrastructure faults. `Result` is the payload-less success shape. See [09-error-handling.md § Two-Track Model](standards/09-error-handling.md) and [ADR-0032](decisions/0032-exception-handling-logging-and-observability.md). |
+| **`Error`** | The sealed record `(LocalizedMessage Message, IReadOnlyDictionary>? Details)` that travels inside `Result.Fail(error)`. `Code` is the **unprefixed stable identifier** (e.g. `"validation_failed"`) derived by stripping the `lockey_` prefix from `Message.Key` — so routing logic (`Result.ToActionResult()`, Problem Details writers) and frontend localization read consistent values without manual sync. Field-level `Details` flow as `LocalizedMessage` lists, keeping the prefix invariant uniform across the entire error payload. See [09-error-handling.md § Result Type](standards/09-error-handling.md). |
+| **`LocalizedMessage`** | The sealed record `(string Key, IReadOnlyDictionary? Params)` in `LearnStack.SharedKernel.Localization`. `Key` MUST begin with `lockey_` — enforced at the constructor — so the frontend's translation catalogues (keyed under the same prefix) resolve every user-facing message without raw English on the wire. Equality is structural (Key + Params); ctor defensively copies Params; empty params normalised to null. `Params` values must be plain text — the frontend resolves messages via React text nodes, never `dangerouslySetInnerHTML`. Per [Phase 02a Packet 2](roadmap/phase-02a-kernel-tenancy.md). |
+| **`Unit`** | The `readonly record struct` value used as `Result` when a command/query succeeds without returning data — replaces the `Result` with `IsSuccess = true` and `Value = null` shape Standards 09 § Forbidden bans. |
+| **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. |
+| **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject]`). Audit columns on `AuditableEntity` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. |
+| **`Entity` / `AuditableEntity`** | The two aggregate bases in `LearnStack.SharedKernel.Domain`. `Entity` is the append-only / audit-row base — identity, in-process domain events, identity-based equality with **transient + cross-runtime-type guards** so EF Core's change tracker / `HashSet` navigations behave correctly before keys are stamped. `AuditableEntity` is the mutable base — adds `CreatedAt/By`, `UpdatedAt/By`, `DeletedAt/By`, `Version`, and the `IsDeleted` projection by implementing `ISoftDelete` + `IOptimisticConcurrency`. `MarkCreated` throws on second call; `SoftDelete` also bumps `UpdatedAt` so "last touched" stays monotonic. `AuditEntry` (audit subsystem) inherits `Entity` — never `AuditableEntity` — by architecture-test rule. |
+| **`IDomainEvent`** | The marker interface (`: MediatR.INotification`) every in-process domain event implements. Raised from aggregate methods, collected by the unit of work, dispatched in-process by MediatR. The abstract `DomainEvent` base declares `EventId` and `OccurredAt` as `required init` so events are always stamped through `IGuidFactory` / `IClock` at the call site. Distinct from integration events, which cross module boundaries through the outbox + Dapr pub/sub per [ADR-0010](decisions/0010-cross-module-communication.md). |
+| **`CursorPagination` / `Page` / `PageInfo`** | The cursor-first pagination triple in `LearnStack.SharedKernel.Pagination` matching Standards 04 § Pagination. `CursorPagination(Cursor, Limit)` is the request (default `Limit = 20`, max 100; ctor throws on `Limit <= 0` — kernel-level guard); `Page(Items, PageInfo)` is the response; `PageInfo(NextCursor, PreviousCursor, HasNext, HasPrevious)` carries the opaque cursors the client never parses. |
+| **`LearnStackVogenDefaults.IdMask`** | The canonical `Conversions` mask every Vogen-emitted ID and value object opts into: `EfCoreValueConverter \| SystemTextJson \| TypeConverter`. Per [ADR-0023](decisions/0023-strongly-typed-id-source-generator.md) every aggregate-root ID writes `[ValueObject(LearnStackVogenDefaults.IdMask)]`. |
| **Pipeline Behavior** | A MediatR pipeline behavior — one of the eight canonical steps wrapping every command / query: `Validation → Logging → Audit → TenantContext → Authorization → Transaction → OutboxFlush → Handler`. The order is binding per [ADR-0032 § Sub-decision 2](decisions/0032-exception-handling-logging-and-observability.md); the architecture test `MediatR_Pipeline_Order_Matches_Canonical_Sequence` enforces it. |
| **`IExceptionHandler` (LearnStack L1)** | The `.NET 8+` exception-handler interface; `LearnStackExceptionHandler : IExceptionHandler` is the final catch site for every unhandled exception. Maps to Problem Details, attaches `correlationId`, records the OTel span error, calls `IErrorTrackingProvider.CaptureAsync` when `ShouldCapture(ex)` is true. See [ADR-0032 § Sub-decision 1](decisions/0032-exception-handling-logging-and-observability.md). |
| **Correlation ID** | The W3C `traceparent` value (or a derived UUID for fallback) that threads through every signal — logs, traces, audit rows, outbox rows, Hangfire payloads, Problem Details bodies — bound to a single user request or background operation. Same as `Activity.Current.TraceId` for HTTP requests; reconstructed from the outbox row / Hangfire payload / event envelope at consumer side. See [10-observability.md § Correlation](standards/10-observability.md). |
diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md
index 89dee84..a8cf727 100644
--- a/docs/roadmap/phase-02a-kernel-tenancy.md
+++ b/docs/roadmap/phase-02a-kernel-tenancy.md
@@ -29,16 +29,47 @@
> Decision-only; no code. Standards 02 § Strongly-Typed Identifiers and
> Standards 04 § Versioning cross-link to the new ADRs.
>
-> **Packet 2 — Shared Kernel core ⏳**
-> `IClock`, `IRandom`, `IGuidFactory` (deterministic-test abstractions per
-> Standards 02 § Time), `LocalizedMessage` (carrying the `lockey_` prefix
-> invariant for `Result.Fail` payloads), `Entity` (append-only / audit
-> aggregate base), `AuditableEntity` (mutable aggregate base with
+> **Packet 2 — Shared Kernel core ✅**
+> `IClock` + `SystemClock` / `FixedClock`, `IRandom` + `SystemRandom` /
+> `FixedRandom`, `IGuidFactory` + `SystemGuidFactory` / `FixedGuidFactory`
+> (deterministic-test abstractions per Standards 02 § Time);
+> `LocalizedMessage` carrying the `lockey_` prefix invariant at the
+> constructor (used by every `Result.Fail` and success-message payload);
+> `Error` refactored to wrap `LocalizedMessage` with a `Code` projection
+> over `Message.Key`; `Result` extended with `IResultBase`, success-message
+> overload, and the static `Result.FailFor(error)` factory ADR-0032's
+> `ValidationBehavior` consumes; `Entity` (append-only / audit
+> aggregate base) + `AuditableEntity` (mutable aggregate base with
> `CreatedAt` / `CreatedBy` / `UpdatedAt` / `UpdatedBy` / `DeletedAt` /
-> `DeletedBy` / `Version`), soft-delete + optimistic concurrency primitives,
-> domain-event model (in-process MediatR `INotification`), pagination model
-> (cursor-first), strongly-typed ID emitter wired per ADR-0023. Unit tests
-> for every primitive.
+> `DeletedBy` / `Version` plus the `IsDeleted` projection);
+> `ISoftDelete` + `IOptimisticConcurrency` marker interfaces;
+> `IDomainEvent : INotification` + `DomainEvent` base + `IHasDomainEvents`
+> aggregate-side collector; cursor-first pagination
+> (`CursorPagination` / `Page` / `PageInfo` matching Standards 04
+> § Pagination). Vogen 7.0.0 wired per ADR-0023 with
+> `LearnStackVogenDefaults.IdMask` carrying the canonical
+> `EfCoreValueConverter | SystemTextJson | TypeConverter` mask;
+> `IAggregateRoot` / `IHasId` interfaces require `TId :
+> IStronglyTypedId` so future module aggregates inherit the
+> constraint. Unit / architecture / contract suites all green in CI; the
+> `VogenIdEmissionTests` smoke test asserts the emitter pipeline
+> (Vogen `[ValueObject]` → `IStronglyTypedId.Value` → JSON
+> round-trip → `TypeConverter` round-trip) end-to-end via a synthetic
+> `TestId` in the test project.
+> Review pass folded in (commit `7c9133a`): `Entity` equality carries
+> transient + cross-runtime-type guards; `Result.FailFor` returns
+> the concrete `TResponse` via reflection (not `Result`);
+> `Error.Code` is the unprefixed stable identifier projected from
+> `Message.Key`; `Error.Details` flows `LocalizedMessage` lists so the prefix
+> invariant covers field-level errors; `Result.Ok` rejects null;
+> `UserId` is a SharedKernel-level Vogen value object used by
+> `AuditableEntity` instead of raw `Guid`; `DomainEvent.EventId` /
+> `OccurredAt` are `required init`; `MarkCreated` throws on second call;
+> `SoftDelete` bumps `UpdatedAt` for monotonic last-touched;
+> `CursorPagination` validates `Limit > 0` at the ctor. Standards 01
+> § Dependency Direction grows a "Build-time-only exceptions" sub-section
+> for the EF Core + MediatR references SharedKernel requires. Unit /
+> architecture / contract suites all green in CI.
>
> **Packet 3 — Cross-cutting foundation ⏳**
> Wires the [ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md)
diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md
index a9c2095..be3dc59 100644
--- a/docs/standards/01-architecture-standards.md
+++ b/docs/standards/01-architecture-standards.md
@@ -65,6 +65,26 @@ Forbidden edges:
- Module A → Module B.Domain
- Module A → Module B.Infrastructure
+### Build-time-only exceptions
+
+`SharedKernel` and every `Modules..Domain` project carries two sanctioned
+external NuGet references that the rules above would otherwise forbid:
+
+| Reference | Why | Used at | Sanctioning ADR |
+|-----------|-----|---------|-----------------|
+| `Microsoft.EntityFrameworkCore` | The Vogen-emitted `.EfCoreValueConverter` type per strongly-typed ID lives in the project that declares `[ValueObject]`. The emitted IL carries TypeRefs to EF Core; the consuming project must reference EF Core at compile time for the converter to load. Hand-written Domain code does **not** import EF Core types. | Compile-time only | [ADR-0023](../decisions/0023-strongly-typed-id-source-generator.md) |
+| `MediatR` | `IDomainEvent : INotification` so in-process aggregate events dispatch via MediatR's publisher (the canonical pipeline per [ADR-0010](../decisions/0010-cross-module-communication.md)). | Build-time + runtime (marker only) | [ADR-0010](../decisions/0010-cross-module-communication.md) |
+
+Both references are scoped to **build-time / IL-level dependencies for
+generated or marker shapes**, not to hand-written Domain code calling EF
+Core or MediatR APIs. The follow-up architecture test
+`Domain_Does_Not_Depend_On_Microsoft_EntityFrameworkCore_Except_Vogen_Emitted_Converters`
+catalogued under
+[21-architecture-tests-catalogue.md](21-architecture-tests-catalogue.md)
+encodes the exception (lands with the first Module.Domain aggregate in
+[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md)). Adding a
+third build-time reference to Domain or SharedKernel requires an ADR.
+
## Aggregate Ownership
- Every entity belongs to exactly one aggregate; aggregate is owned by exactly one module.
diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md
index e149ab6..705a4b2 100644
--- a/docs/standards/02-backend-coding.md
+++ b/docs/standards/02-backend-coding.md
@@ -1,7 +1,7 @@
# 02 — Backend Coding Standards
**Status:** Active
-**Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md), [ADR 0023 — Strongly-Typed ID Source Generator](../decisions/0023-strongly-typed-id-source-generator.md).
+**Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md), [ADR 0023 — Strongly-Typed ID Source Generator](../decisions/0023-strongly-typed-id-source-generator.md), [ADR 0031 — PostgreSQL Major Version](../decisions/0031-postgresql-major-version.md).
C# / .NET conventions for LearnStack backend code.
@@ -36,27 +36,37 @@ C# / .NET conventions for LearnStack backend code.
- **Records** for immutable value-like data: DTOs, integration events, configuration options.
- **Sealed classes** by default; open inheritance is the exception.
- **Structs** only for small, immutable, frequently-allocated values (≤ 16 bytes).
-- **Strongly-typed ids** (`record struct CourseId(Guid Value) : IStronglyTypedId`) for all entity identifiers. Never expose raw `Guid` on the public surface.
+- **Strongly-typed ids** (`partial record struct CourseId : IStronglyTypedId;` per the [Vogen pattern below](#strongly-typed-identifiers)) for all entity identifiers. Never expose raw `Guid` on the public surface.
- **Value objects** for domain concepts with invariants (e.g. `Email`, `Slug`, `LocaleCode`).
## Strongly-Typed Identifiers
-```csharp
-public readonly record struct CourseId(Guid Value) : IStronglyTypedId
-{
- public static CourseId New() => new(Guid.NewGuid());
- public override string ToString() => Value.ToString();
-}
-```
-
Per [ADR-0023](../decisions/0023-strongly-typed-id-source-generator.md), the
shared source generator is **[Vogen](https://github.com/SteveDunn/Vogen)**. The
canonical declaration uses Vogen's `[ValueObject(...)]` annotation on a
-partial `record struct`; Vogen emits:
+partial `record struct`:
+
+```csharp
+[ValueObject(LearnStackVogenDefaults.IdMask)]
+public readonly partial record struct CourseId : IStronglyTypedId;
+```
+
+Vogen emits per ID:
- EF Core value converter.
-- `JsonConverter`.
-- Minimal API model binder.
-- OpenAPI schema mapping (Swashbuckle / Microsoft.OpenApi schema filter).
+- `JsonConverter` (System.Text.Json).
+- TypeConverter (carries ASP.NET Core minimal-API + MVC route-parameter binding).
+- OpenAPI schema mapping (wired centrally in Packet 4 per ADR-0023 § Implementation
+ Notes).
+
+Construction:
+- New IDs in aggregate methods mint via the injected `IGuidFactory`:
+ `CourseId.From(guidFactory.NewUuidV7())`. **Never call `Guid.CreateVersion7()` /
+ `Guid.NewGuid()` directly in `Domain` / `Application` code** — Standards 02
+ § Time bans the symmetric `DateTime.UtcNow` for the same reason (deterministic
+ tests). High-volume append-only tables (`audit_log`, `outbox_messages`) prefer
+ DB-side `gen_uuid_v7()` (per [ADR-0031](../decisions/0031-postgresql-major-version.md)).
+- ID types do **not** expose a `New()` static — explicit `From(guidFactory.NewUuidV7())`
+ at the call site keeps the dependency surface honest.
The same annotation covers richer value objects (`Email`, `Slug`, `LocaleCode`,
`Money`) — the emitter shape is identical for IDs and value objects, with the
@@ -86,15 +96,40 @@ Two patterns coexist:
- **`Result`** for *expected* outcomes (validation failure, not found, conflict).
```csharp
-public sealed record Result(bool IsSuccess, T? Value, Error? Error)
+public sealed record Result : IResultBase
{
- public static Result Ok(T value) => new(true, value, null);
- public static Result Fail(Error error) => new(false, default, error);
+ internal Result(bool isSuccess, T? value, Error? error, LocalizedMessage? successMessage = null) { ... }
+
+ public bool IsSuccess { get; }
+ public bool IsFailure => !IsSuccess;
+ public T? Value { get; }
+ public Error? Error { get; }
+ public LocalizedMessage? SuccessMessage { get; }
+
+ // Throws when value is null — Standards 09 § Forbidden bans
+ // IsSuccess = true with Value = null. For payload-less success use
+ // Result.
+ public static Result Ok(T value, LocalizedMessage? message = null);
+ public static Result Fail(Error error);
}
-public sealed record Error(string Code, string Message, IReadOnlyDictionary? Details = null);
+public sealed record Error(
+ LocalizedMessage Message,
+ IReadOnlyDictionary>? Details = null)
+{
+ // Stable machine-readable identifier — Standards 04 § Problem Details
+ // "code". Derived from Message.Key by stripping the lockey_ prefix so
+ // the code never drifts from the localization key by construction.
+ public string Code => Message.Key[LocalizedMessage.RequiredPrefix.Length..];
+}
```
+`LocalizedMessage`'s constructor enforces the `lockey_` key prefix; the
+constructor of `Result` is `internal` so callers cannot bypass the
+`Ok` / `Fail` factory invariants via positional record syntax. See
+[09-error-handling.md § Result Type](09-error-handling.md) and
+[Phase 02a Packet 2](../roadmap/phase-02a-kernel-tenancy.md).
+
Use cases for `Result`:
- Validation outcomes.
- Optimistic concurrency conflicts.
diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md
index 0a8e3d8..ce38d92 100644
--- a/docs/standards/09-error-handling.md
+++ b/docs/standards/09-error-handling.md
@@ -30,19 +30,58 @@ Both end in **RFC 7807 Problem Details** at the API boundary.
## Result Type
```csharp
-public sealed record Result(bool IsSuccess, T? Value, Error? Error)
+public sealed record Result : IResultBase
{
- public static Result Ok(T value) => new(true, value, null);
- public static Result Fail(Error error) => new(false, default, error);
+ internal Result(bool isSuccess, T? value, Error? error, LocalizedMessage? successMessage = null) { ... }
+ public bool IsSuccess { get; }
+ public bool IsFailure => !IsSuccess;
+ public T? Value { get; }
+ public Error? Error { get; }
+ public LocalizedMessage? SuccessMessage { get; }
+
+ public static Result Ok(T value, LocalizedMessage? message = null); // throws on null value
+ public static Result Fail(Error error);
}
public sealed record Error(
- string Code,
- string Message,
- IReadOnlyDictionary? Details = null);
+ LocalizedMessage Message,
+ IReadOnlyDictionary>? Details = null)
+{
+ public string Code => Message.Key[LocalizedMessage.RequiredPrefix.Length..];
+}
+
+public sealed record LocalizedMessage(string Key, IReadOnlyDictionary? Params = null)
+{
+ public const string RequiredPrefix = "lockey_";
+ // ctor enforces Key.StartsWith(RequiredPrefix); see Phase 02a Packet 2.
+}
+
+public readonly record struct Unit { public static Unit Value { get; } }
```
-Standard error codes (machine-readable, stable):
+The `LocalizedMessage`'s `lockey_` prefix is invariant: the constructor
+rejects any key that does not start with `lockey_`. Frontend translation
+catalogues are keyed by the same prefix; backend code never returns raw
+English. `Error.Code` is a **stable, unprefixed** projection of
+`Message.Key` (the `lockey_` prefix is stripped). Routing logic
+(`Result.ToActionResult()`, Problem Details writers) reads `Code`; the
+frontend reads `Message.Key` for locale resolution — two surfaces in
+sync by construction. Per
+[Phase 02a Packet 2](../roadmap/phase-02a-kernel-tenancy.md) and
+[ADR-0032 § Error Model](../decisions/0032-exception-handling-logging-and-observability.md).
+
+`Result`'s primary constructor is `internal`; callers go through
+`Ok` / `Fail` so the success-must-carry-value rule
+(see § Forbidden) cannot be bypassed via positional record syntax.
+`Result` is the canonical payload-less success shape.
+
+Field-level errors in `Error.Details` flow as `LocalizedMessage` lists
+per key, so the `lockey_` invariant covers every user-facing string the
+API ships — not just the top-level message.
+
+Standard error codes (machine-readable, stable). The table uses the
+unprefixed shape that travels on the Problem Details `code` field; the
+matching localization key adds the `lockey_` prefix.
| Code | Meaning | HTTP |
|------|---------|------|
@@ -157,26 +196,35 @@ All API errors are **RFC 7807 Problem Details**:
```json
{
"type": "https://errors.learnstack.dev/validation",
- "title": "Validation failed",
+ "title": "lockey_validation_failed",
"status": 400,
"code": "validation_failed",
- "detail": "One or more fields are invalid.",
+ "messageKey": "lockey_validation_failed",
"instance": "/v1/courses",
"correlationId": "01H...",
"errors": {
- "title": ["Title is required."],
- "slug": ["Slug already exists in this tenant."]
+ "title": [
+ { "key": "lockey_title_required" }
+ ],
+ "slug": [
+ { "key": "lockey_slug_already_exists_in_tenant", "params": { "slug": "intro" } }
+ ]
}
}
```
Rules:
- `type` is a stable URL.
-- `code` is the machine-readable identifier.
-- `detail` is human-readable but **safe to display** (no internal info).
+- `code` is the machine-readable identifier — the unprefixed `Error.Code`
+ (Standards 04 § Problem Details).
+- `messageKey` is the `LocalizedMessage.Key` (always begins with `lockey_`)
+ the frontend resolves against its i18n catalogue. The legacy
+ `detail` field is omitted — backend never returns raw English.
- `instance` is the request path.
- `correlationId` matches the trace id.
-- `errors` is field-level detail (validation only).
+- `errors` is field-level detail, each entry a `LocalizedMessage` payload
+ (`key` + optional `params`) so the frontend resolves field-level messages
+ through the same path as the top-level one.
## Validation Errors