---
name: backend-data-layer
description: >
  Generates Domain entities on the project-local ExtensionBaseEntity shim
  (soft-delete + domain events over SmartStack.Domain.Common.BaseEntity),
  domain events, EF Core configurations with schema targeting, and migrations.
phase: development/backend
cli: cli/
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# Data Layer — Entities + Events + EF Core + Migration

Generates domain entities, domain events, and their EF Core configurations
following SmartStack conventions. Verified contract (package + project shims —
the historical `SmartStack.Core.Domain` namespace does NOT exist in any package):
- `{ns}.Domain.Common.ExtensionBaseEntity` — project-local shim shipped by
  `ss init` (`ExtensionBaseEntity.cs.template`): `DeletedAt` + domain events over
  the package's `SmartStack.Domain.Common.BaseEntity` (`Id`, `CreatedAt`,
  `UpdatedAt`, `ExtensionData`)
- `SmartStack.Domain.Common.ITenantEntity` / `IOptionalTenantEntity` /
  `IOwnedEntity` / `IAssignedEntity`
- `SmartStack.Domain.Support.Events.IDomainEvent` — **requires `OccurredAt`**
- `{ns}.Infrastructure.Persistence.SchemaConstants` — project-local shim
  (`SchemaConstants.cs.template`), resolved via the configuration's enclosing
  namespace (no using)

## Entity Pattern

- `ExtensionBaseEntity` inheritance (Id, CreatedAt, UpdatedAt + DeletedAt + domain events list)
- Tenant: `ITenantEntity` (strict) / `IOptionalTenantEntity` / none
- Private parameterless constructor + static `Create()` factory
- `Update(...)` mutator with required-field validation
- `SoftDelete()` / `Restore()` toggles `DeletedAt`
- Domain events emitted on Create/Update/SoftDelete/Restore

## Domain Events

Each entity gets four companion event records in `{Entity}Events.cs`:

```csharp
public record EmployeeCreatedEvent(Guid EmployeeId, DateTime OccurredAt) : IDomainEvent;
public record EmployeeUpdatedEvent(Guid EmployeeId, DateTime OccurredAt) : IDomainEvent;
public record EmployeeDeletedEvent(Guid EmployeeId, DateTime OccurredAt) : IDomainEvent;
public record EmployeeRestoredEvent(Guid EmployeeId, DateTime OccurredAt) : IDomainEvent;
```

Clients can subscribe via MediatR `INotificationHandler<EmployeeCreatedEvent>`.

## Schema targeting

The `schemaTarget` input decides the DB schema:
- `"core"` → `SchemaConstants.Core` — for SmartStack built-in extensions only
- `"extensions"` → `SchemaConstants.Extensions` (default) — for client modules

Client projects should always leave `schemaTarget` to default. `"core"` is
reserved for SDK-level seeding.

## Table & column naming (aligned with SmartStack.app)

- **Table**: `{domainPrefix}_{PluralName}` (PascalCase plural) in the selected
  schema — e.g. `aff_Demandes`, `support_Tickets`, `hrm_Employees`.
- **`domainPrefix`**: the entity's **business-domain** prefix from `entité.md`
  (`Préfixe table: aff_` → `aff`), lowercase alphanumeric. **Never `ext`** —
  `ext` is the migration prefix of the `extensions` schema, not a table prefix.
  `validate.ts` **hard-rejects** `ext`/`extensions`/`core` as a `domainPrefix`
  (fail-closed), so `[extensions].[ext_X]` can never be generated.
- **Columns**: **PascalCase, EF Core default mapping — NO `HasColumnName`.**
  SmartStack.app never remaps columns (`FirstName`, `CreatedAt`, `ClientId`),
  so neither do we. snake_case columns are forbidden.
- **FK columns**: `{TargetEntity}Id` (PascalCase), default mapping.

## Indexes

Always generated (EF Core default index names — no `HasDatabaseName` override):
- an index on `CreatedAt` — sort/filter by recency
- an index on `TenantId` (if tenant mode ≠ none)

Additionally, any field flagged `indexed: true` in the spec gets its own index.

## EF Core Configuration Pattern

Columns use EF Core's **default PascalCase mapping** — no `HasColumnName`. Only
constraints (required string / maxLength) and indexes are declared.

```csharp
public class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
    public void Configure(EntityTypeBuilder<Employee> builder)
    {
        builder.ToTable("hrm_Employees", SchemaConstants.Extensions);
        builder.HasKey(e => e.Id);
        builder.HasQueryFilter(e => e.DeletedAt == null);

        builder.HasIndex(e => e.CreatedAt);
        builder.HasIndex(e => e.TenantId);

        builder.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
        builder.Property(e => e.LastName).IsRequired().HasMaxLength(100);
    }
}
```

The configuration's `HasQueryFilter` is the **soft-delete filter only**. Tenant
isolation is NOT declared here — an `IEntityTypeConfiguration` has no access to
the current tenant. It lives in `ExtensionsDbContext.OnExtensionModelCreating`
as the **named "Tenant" filter**: `scaffold-entity` maintains one
`ApplyNamed{Strict|Optional}TenantFilter<{FQEntity}>(modelBuilder);` line per
tenant-scoped entity between the `<<< TENANT-FILTERS BEGIN/END >>>` markers
(strict ↔ `ITenantEntity`, optional ↔ `IOptionalTenantEntity`; `tenantMode:
'none'` removes the line). Without that line the entity's reads are
**cross-tenant** — the socle applies no automatic filter to extension entities.
Named (EF 10) so it composes with the soft-delete filter and lifts
independently of the "DataScope" filter (`IgnoreTenantScope()` /
`IgnoreDataScope()`). Gate: DEV-API-032.

## Relationships (foreign keys)

Same-module foreign keys are declared in the `relations[]` input — **not** as
bare `Guid` fields. Each owning relation (`many-to-one` / `one-to-one`) emits, in
generated code: the FK column (`{TargetEntity}Id` by default, or `foreignKey`),
a reference **navigation property**, and a **real EF Core constraint**
`HasOne(...).WithMany()/.WithOne().HasForeignKey(...).OnDelete(DeleteBehavior.X)`.

`onDelete` mirrors the BA cascade vocabulary (`create-data-model/levels/relationships.md`):

| `onDelete` | EF `DeleteBehavior` | Use when |
|------------|---------------------|----------|
| `restrict` (default) | `Restrict` | a rule blocks deletion of the parent |
| `cascade` | `Cascade` | children can't exist without the parent |
| `set-null` | `SetNull` | detach on delete (requires `nullable: true`) |
| `no-action` | `NoAction` | self-references (SQL can't cascade a cycle) |

`one-to-many` / `many-to-many` declared on the principal emit only the inverse
collection navigation (`ICollection<T>`); the FK column lives on the owning side.

Relation spec entry (same-module):

```json
{ "type": "many-to-one", "targetEntity": "Contact", "foreignKey": "ContactId", "nullable": false, "onDelete": "restrict" }
```

Cross-module / Core variant — add `targetScope` (and `targetTable`/`targetSchema` for `core`):

```json
{ "type": "many-to-one", "targetEntity": "Department", "foreignKey": "DepartmentId", "nullable": true, "onDelete": "restrict", "targetScope": "core", "targetTable": "ref_Departments", "targetSchema": "core" }
```

**Cross-module / Core references are ALSO real FKs.** A navigation property can't
span the two DbContexts, but the FK CONSTRAINT can — it is ONE physical database
(`core` + `extensions` schemas). `cross-module` → FK to the real type, no
navigation; `core` → FK to a generated `*Reference` principal stub mapped to the
Core table (`ExcludeFromMigrations`), a real cross-schema constraint (see
`relationships.md`). The ONLY non-FK `*Id` columns are the identity/audit
allowlist (`lib/fk-allowlist.ts`). Audit **DEV-DAT-008** fails the build (err) when
ANY declared relationship has no matching FK constraint with the declared cascade.

## Global search registration

Every **list entity** should be surfaced by the platform global search (`GET /api/search`). The search is
a registry of providers — extension entities are invisible to it until registered, and there is **no
auto-scan** of the `extensions` schema (a blind scan would leak cross-tenant rows and bypass per-section
RBAC). You do not write a provider per entity: register each one via
`AddExtensionSearch<ExtensionsDbContext>(…)` in `{Project}.Infrastructure/DependencyInjection.cs` (the
`<<< EXTENSION-SEARCH-DI >>>` markers), declaring only the permission, the route and the display metadata —
the generic engine discovers the text columns, scopes (tenant + row) and paginates.

For an entity bound to section `app.module.section`: permission = `app.module.section.read`,
route = `/app/module/section/{id}`, add `TenantScoped()` for every `ITenantEntity` (defense in depth
over the named "Tenant" context filter — which can be lifted, or missing on a pre-seam app), and add
`RestrictTo(...)` only to mirror a row-level list rule.

**Do NOT hand-write the block** — run the deterministic generator `cli/scaffold-extension-search` AFTER the
screens exist (it needs the entity↔section binding from the SmartListView screens). It patches the
`<<< EXTENSION-SEARCH-DI >>>` markers idempotently (and inserts the full `AddExtensionSearch<…>` wrapper +
the one `using` if the project predates the seam — so it also back-fills existing apps via `ss upgrade`):

```bash
# Assemble the spec with build-spec.ts (sections + permissions + list screens) → temp JSON, then:
npx --prefer-offline tsx skills/development/backend/data-layer/cli/scaffold-extension-search/index.ts \
  --spec-file /tmp/extension-search-spec.json --outdir /path/to/target-app
```

The spec (`ExtensionSearchSpec`): one `entities[]` row per list entity — `entityName` (fully-qualified, e.g.
`Test.Domain.Entities.Task`), `categoryKey`, `label`, `icon`, `permission` (`…​.read`), `route` (with `{id}`),
`tenantScoped`, optional `rowScope { bypassPermission, ownerProperty }`. `build-spec.ts` derives all of it
from the menu sections + permission paths + list screens.

**Existing projects / `ss upgrade`** — the same CLI can DISCOVER searchable entities straight from the live
code (Core seed providers → sections/permissions, `ExtensionsDbContext` + Domain → entities, screen
controllers → entity↔section), no spec needed:
`… scaffold-extension-search/index.ts --discover --project <app-dir> --app <AppCode>`. `ss upgrade` runs this
automatically (fail-soft, honours `--dry-run`). It is **FAIL-CLOSED on row-scope**: an entity whose section
carries `.assign/.approve/.reject` is NOT auto-registered — it is reported so a human adds the
`.RestrictTo(...)` (search must never reveal a row the list hides). Discovery is best-effort: anything it
cannot bind deterministically (i18n permission mismatches, sub-resources) is reported for review, never
guessed.

→ Full pattern, conventions and worked example: **`references/global-search.md`**.

## Time-entry refs registration

HR time entries can be **imputed onto CLIENT extension entities** (a project, a mandate, a cost center…).
These "external refs" are a registry of `ITimeEntryRefProvider` — **Core ships no built-in provider**, and an
entity is invisible to the time-entry picker until registered. Unlike global search, there is **no** "register
every list entity" rule: **imputation is an opt-in business decision** — having a list screen does NOT mean
time should be booked against an entity, so nothing is ever auto-registered. You do not write a provider per
entity: declare each dimension via `AddExtensionTimeEntryRefs<ExtensionsDbContext>(…)` in
`{Project}.Infrastructure/DependencyInjection.cs` (the `<<< TIME-ENTRY-REFS-DI >>>` markers), stating the
`refType`, label, icon and the mandatory display column — the generic engine handles search, scoping,
write-time validation and label snapshotting.

The picker renders in the core time-entry UI with **ZERO client frontend code**. It stays inert until the
tenant admin enables the feature (HR → Time settings → external refs, `Hr / TimeEntryExternalRefsEnabled`,
default off). `refType` is **persisted** on `core.hr_TimeEntries.ExternalRefType` — keep it stable + unique.

| Fluent option | Meaning |
|---|---|
| `Entity<T>(refType, label, icon)` | the dimension: stable kebab key + display label + lucide icon |
| `.WithDisplay(x => x.Prop)` | **mandatory** option label — an Expression (SQL-translated search/order) |
| `.WithSubtitle(x => x.Prop)` | optional secondary text (code, client name…) |
| `.ActiveWhen(x => …)` | optional "still accepts imputations" predicate (inactive = not pickable) |
| `.RequirePermission("…")` | optional per-dimension permission gate (`PermissionMatcher`) |
| `.TenantScoped()` | inject an explicit `TenantId == current` filter (defense in depth over the named "Tenant" context filter) |
| `.Order(n)` | optional picker order (default 100) |

**Do NOT hand-write the block** — run the deterministic generator `cli/scaffold-time-entry-refs`. It patches
the `<<< TIME-ENTRY-REFS-DI >>>` markers idempotently (and inserts the full `AddExtensionTimeEntryRefs<…>`
wrapper + the one `using` if the project predates the seam):

```bash
# Register the declared dimensions from an explicit spec file:
npx --prefer-offline tsx skills/development/backend/data-layer/cli/scaffold-time-entry-refs/index.ts \
  --spec-file /tmp/time-entry-refs-spec.json --outdir /path/to/target-app
```

The spec (`TimeEntryRefsSpec`): one `entities[]` row per imputation dimension — `entityName` (fully-qualified,
e.g. `Test.Domain.Entities.Project`), `refType` (stable kebab), `label`, `icon`, `display` (property name,
mandatory), optional `subtitle`, `activeWhen` (raw C# lambda body or full lambda), `permission`,
`tenantScoped` (default true), `order`. There is **no BA-derived (`build-spec`) mode**: imputation is a
business decision, not derivable from the BA pagespecs — a list screen does not imply an entity should
receive time bookings. The CLI is **spec-file + discover only**.

**Existing projects / `ss upgrade`** — `… scaffold-time-entry-refs/index.ts --discover --project <app-dir>
--app <AppCode>`. Discovery is intentionally **conservative**: it **NEVER auto-registers** a dimension. It
back-fills the **commented** seam wrapper when the project predates it, and returns every
`ExtensionsDbContext` entity as a `candidate` (with a ready-to-paste commented registration line) for a human
to promote. `ss upgrade` runs this automatically (fail-soft, honours `--dry-run`).

The extension reads booked hours back through `ICoreDataService.GetTimeRefTotalsAsync(refType, refIds, from?,
to?)` for its own pages (e.g. an hours column on a project list); the HR time report gains a per-ref breakdown
automatically.

→ Full contract, conventions and worked example: **`references/time-entry-refs.md`**.

## Exportable datasets (your data in the HR export wizard)

The platform's export wizard (`/hr/export`) **composes and merges** several HR sources into ONE file —
the hours an employee worked *and* the holidays they took over the same month, on the same row.
Register an `IExportDatasetProvider` and your entities become a selectable **source** in it, with
**ZERO client frontend code**: the catalogue is permission-filtered server-side. The four formats
(CSV / XLSX / PDF / JSON), the column picker, the header renaming, the live preview, the targeting
step, the saved templates and the schedules all come for free.

```csharp
public sealed class OrdersExportProvider(IExtensionsDbContext context) : IExportDatasetProvider
{
    public string DatasetKey => "ventes.commandes";               // PERSISTED — never rename
    public string RequiredPermission => "ventes.commandes.export";
    public ExportDatasetKind Kind => ExportDatasetKind.Facts;     // dated events
    public string LabelFor(string lang) => lang == "fr" ? "Commandes" : "Orders";
    // DescribeAsync → the columns · DescribeFiltersAsync → the filters · QueryAsync → the rows
}
```

Register it inside the `<<< EXPORT-DATASETS-DI >>>` markers of
`{Project}.Infrastructure/DependencyInjection.cs`. **No scaffolder**: a dataset's fields, its measures
and its pivots are business decisions a column-mapping DSL cannot express.

Five rules, each of which is a silent bug if broken:

1. **Rows key on a `User` id** (`ExportRow.SubjectUserId`) — resolve `HrEmployee.UserId` if your entity
   keys on the employment record. **Identity and period columns belong to the ENGINE**: emit your own
   and a merged export carries three near-identical "Employé" columns.
2. **`Kind = Attributes`** for anything that DESCRIBES the person (cost centre, mandate) rather than a
   dated event — otherwise you get a second, half-empty row family instead of enriching the existing
   one, which is the whole point of the module.
3. **Never re-filter on `ctx.DepartmentIds`.** The engine resolves the wizard's targeting itself and
   hands you the union "departments ∪ named people" in `ctx.UserIds`, before your provider runs — your
   existing `if (ctx.UserIds.Count > 0)` narrowing inherits it for free. Re-filtering turns that union
   into an intersection and loses everyone named outside those departments.
4. **An emptied filter means NOTHING, not "your defaults"** (v3.66). `ctx.Filter(key, fallback)`
   returns the fallback when the key is ABSENT and an EMPTY collection when the caller unticked
   everything — return `ExportDatasetResult.Empty` on the latter. Advertise every default-applied
   value in `DefaultValues`, "all of them" included, or the wizard cannot render the two states apart.
5. **Never `IgnoreQueryFilters()`** — the row perimeter of an export IS the perimeter of the
   corresponding list, and a guard test scans the IL for it. Declare truncation explicitly
   (`IsTruncated`), and localize the field labels for `ctx.LanguageCode`: they become the HEADERS
   INSIDE the file, and a scheduled run has no browser to translate anything.

→ Full contract, worked provider, formatter seam and traps: **`references/export-datasets.md`**.

## Home-page KPIs (application / module presentation pages)

Every application and module WITHOUT a page of its own already renders a **generic presentation page**
(header, description, one card per child) built from the permission-filtered menu — you scaffold
nothing for it. What the menu cannot know is the module's **numbers**: register an
`INavigationStatsProvider` and that page gains its KPI banner, with **ZERO client frontend code**.

```csharp
public sealed class OrdersStatsProvider(IExtensionsDbContext context) : INavigationStatsProvider
{
    public string NodeKey => "ventes.commandes";                  // the module's componentKey
    public string? RequiredPermission => "ventes.commandes.read"; // the gate

    public async Task<IReadOnlyList<NavigationStat>> GetStatsAsync(CancellationToken ct) =>
    [
        new NavigationStat("pending", "Awaiting validation",
            await context.Orders.CountAsync(o => o.Status == OrderStatus.Pending, ct),
            Icon: "ClipboardCheck", Route: "/ventes/commandes/validation"),
    ];
}
```

Register it inside the `<<< NAVIGATION-STATS-DI >>>` markers of
`{Project}.Infrastructure/DependencyInjection.cs`. Mirror of `ISearchProvider` / `ITaskProvider`: the
handler resolves `IEnumerable<INavigationStatsProvider>`, so **several providers may target the same
node** and their cards concatenate — that is how an application banner adapts to what the caller may
see, and how you append KPIs to a CORE module.

Three rules: **gate every provider** (`RequiredPermission`; `null` only for `IsPersonal` nodes, and
then the data MUST be caller-scoped), **never `IgnoreQueryFilters()`** (the tenant / data-scope
filters are what make a KPI honest), and **check the node actually renders a generic home page** — a
module whose root you scaffolded as a hub page keeps that page (a registered component always wins),
so the provider would have no rendering surface unless the hub renders
`<NavigationStatsBanner nodeKey="…" />` itself. A module with a single section never shows a home page
at all.

Labels are looked up as `navigation:stats.{nodeKey}.{key}` with the node key **flattened** (`.` → `_`)
and fall back to the provider's English `Label`; add the keys to the four locales.

→ Full contract, conventions and worked example: **`references/navigation-home-kpis.md`**.

## Row-level data scopes (own / assigned lists)

When the BA RBAC matrix gives an actor a `read` whose **Portée ≠ toutes** (`les siennes` / `attribuées`),
the permission side alone is NOT enough: `scaffold-core-seed` seeds `{path}.read` + the `.read.all` bypass,
but nothing filters the rows — the lists would return every record to every actor. The filtering is a
TWO-halves cascade, each half owned by a deterministic CLI:

1. **Column half — `scaffold-entity`** (`dataScope: { mode: "own" | "assigned" | "own-assigned" }`):
   synthesizes the ownership column(s) (`Guid OwnerUserId` required/indexed, set at `Create()` and never
   updatable; `Guid? AssignedToUserId` nullable/indexed, updatable) and marks the entity
   `IOwnedEntity` / `IAssignedEntity` (`SmartStack.Domain.Common`).
2. **Policy half — `cli/scaffold-data-scope`**: generates one `{Entity}ScopePolicy.cs`
   (`DataScopePolicy<T>`, `ScopeAllPermission = "{path}.read.all"`, `Visibility` = the own/assigned lambda),
   patches the DbContext `OnExtensionModelCreating` (`<<< DATA-SCOPE-FILTERS >>>` markers →
   `ApplyDataScopeFilter(…)` mounts the named "DataScope" EF filter) and the DI
   (`<<< DATA-SCOPE-POLICIES-DI >>>` markers → `AddSingleton<IDataScopePolicy>(…)`).

```bash
npx --prefer-offline tsx skills/development/backend/data-layer/cli/scaffold-data-scope/index.ts \
  --spec-file /tmp/data-scope-spec.json --outdir /path/to/target-app
```

**Run BOTH halves** for every scoped entity — audit **DEV-API-020** fails the build when a planned
own/assigned scope has no policy/filter/DI wiring.

Two hard rules (full rationale in the reference):
- **NEVER `[RequireDataScope(typeof(T), …)]` on an extension controller** — the platform guard is bound to
  `ICoreDbContext` and throws at runtime for extension entities. The extension `GET {id}` contract is the
  named filter's 404.
- **The DbContext ctor must forward `ICurrentUserAccessor`** to the `SmartStackExtensionDbContext` base or
  the filter stays inert (the current project template ships the forwarding; `scaffold-data-scope` warns on
  the pre-seam ctor shape).

A scoped entity registered in **global search** must mirror the rule with `.RestrictTo(…)` (see the
global-search section) — search must never reveal a row the list hides.

→ Full contract, generated shapes and traps: **`references/data-scopes.md`**.

## Coded entities (system-allocated business codes)

When the BA data model gives an entity a **system-allocated `Code`** (an invoice number, a mandate code —
`entité.md` numbering conventions, audit rule DM-017), the platform's code-generation engine allocates it
**atomically and gaplessly** at insert — the same engine as Departments/Tickets. Two halves, both deterministic:

1. **Entity half — `scaffold-entity`** (`codedEntity: { codeKey: "orders.invoice" }`): emits the `Code`
   column (engine-assigned — NEVER a Create/Update input), the `ICodedEntity` implementation (explicit
   members) and the EF constraint + a **unique** index — the socle pattern, chosen from `tenantMode`:
   composite `(TenantId, Code)` (strict), the filtered pair `[TenantId] IS NOT NULL` / `IS NULL` +
   `IX_{table}_Code_Global` (optional), simple unique (none). The DB constraint is the safety net under
   the allocation engine; `unique: false` opts out (validate warns — document why). The key MUST be
   namespaced (`app.entity`) — un-namespaced keys are platform-reserved. Audit rule **DEV-API-022**
   verifies the whole seam (entity + descriptor DI + ctor forwarding + unique index) at the Phase 2 gate.
2. **Descriptor half — `cli/scaffold-coded-entity`**: generates `{Entity}CodeKeyDescriptor.cs`
   (`ICodeKeyDescriptor`: default format mask, scope Tenant/Global, reset, gapless) and patches the DI
   (`<<< CODED-ENTITY-KEYS-DI >>>` markers → `services.AddSmartStackCodeKey<…>()`). **No seed, no
   migration**: a `CodePattern` DB row only ever OVERRIDES the descriptor, and the key surfaces in the
   admin "Code patterns" screen automatically.

```bash
npx --prefer-offline tsx skills/development/backend/data-layer/cli/scaffold-coded-entity/index.ts \
  --spec-file /tmp/coded-entity-spec.json --outdir /path/to/target-app
```

Activation prerequisite (same as data scopes): the client DbContext ctor must forward **`IServiceProvider`**
to the `SmartStackExtensionDbContext` base — otherwise allocation is **silently skipped** (the current
project template ships the forwarding). `SaveChanges()` synchronous throws on an Added coded entity — the
platform pipeline is async-first.

Two traps the CLI now fails closed on: the **format grammar is CLOSED** (`{YYYY} {YY} {MM} {DD} {TENANT}
{SEQ:n}` + the derived `{FIELD|UPPER|LOWER|SLUG|INITIALS:Champ}` / `{ABBR:Champ:n}`; `{SEQ}` required unless
a derived token is present — an invented `{NNNN}` makes the engine throw at EVERY insert), and there is **no
per-parent scope** (`CodeScopeKind` = Tenant | Global — put the parent in the format instead). And never
model a counter entity: the socle allocates, DM-017 + the `code-generation` capability flag the duplicate.

→ Full contract, grammar, spec shape and forbidden list: **`references/coded-entities.md`**.

## File / document storage (attachments)

When the BA plans "documents / pièces jointes" on an entity, the platform **already ships the storage
primitive**: `IFileStorageService` (`SmartStack.Application.Common.Interfaces` — Scoped via `AddSmartStack`,
injectable from any extension handler/controller; `StorageType.Normal | Legal`; Local disk or Azure Blob per
`AzureStorage:UseAzure`, config blocks present in every generated `appsettings.json`). **Never rebuild
storage, never store file content in the database** (`binary`/`varbinary` — `scaffold-entity` fail-closes,
DM-019/PRD-053 flag upstream). What the socle does NOT ship: a generic upload endpoint (`api/files` is
download-only and its `normal` route is ANONYMOUS — never route business documents through it), a generic
attachment entity, or an exported React upload component.

The extension therefore owns the FEATURE around the primitive, as a **conversational pattern — no
scaffolder yet**: (1) a client METADATA entity (`FileName`, `StoredFileName` opaque + unique,
`ContentType`, `FileSizeBytes`, parent FK) scaffolded normally via `cli/scaffold-entity`; (2) hand-written
dedicated endpoints — `IFormFile` upload with `[RequestSizeLimit]` + extension/size whitelist validated
**in the controller** (the Local implementation validates nothing) + `[RequirePermission]`, and an
AUTHENTICATED streamed download resolved through the tenant-filtered metadata query; (3) client uploads via
`api.post(url, formData)` (the package's multipart interceptor sets the boundary); (4) a local dropzone
component (the package exports none). Never a custom action `payloadParameters[].type: 'file'` (not wired
for multipart — PRD-107).

→ Full contract, worked controller/client examples and forbidden list: **`references/file-storage.md`**.

## Optimistic concurrency — offline-write (`versioned: true`)

When a page is planned **`offline: 'write'`** (the mobile PWA queues mutations in an outbox and replays
them later), a replay can hit a row that changed server-side since the client last read it. The platform's
answer is the socle **`IVersionedEntity`** seam (SQL Server `rowversion`): EF Core checks the write against
the token the client last read, and a stale write surfaces as **HTTP 409** carrying the current server
state — instead of silently overwriting. Opt-in **per entity** (deliberately NOT on the base entity) so
only entities taking part in offline write pay for the extra column.

`scaffold-entity` `versioned: true` (default `false`) emits the **ENTITY half**:

- `, IVersionedEntity` appended to the class's interface list (`SmartStack.Domain.Common`);
- `public byte[] RowVersion { get; private set; } = Array.Empty<byte>();` — EF/DB-managed, **never** a
  `Create()`/`Update()` input (`validate.ts` rejects a hand-declared `RowVersion` field);
- `builder.Property(e => e.RowVersion).IsRowVersion();` in the EF configuration.

**Migration signal — never auto-run.** The new `rowversion` column is a schema change: an EF extension
migration is REQUIRED. The CLI only SIGNALS it (envelope `nextSteps` + validate warning) — **`/efcore` is
the sanctioned path** (governed; this skill NEVER runs `dotnet ef`).

Pass the SAME flag to `scaffold-business` for the entity — it emits the DTO/handler half (`RowVersion`
echo on Update + `DbUpdateConcurrencyException` → 409 `ConflictException` with the current state).
`offline: 'write'` pages REQUIRE the seam **end-to-end** — audit **DEV-PWA-009** fails otherwise.

## CLIs

This skill contains **six colocalized CLIs**:

- **`cli/scaffold-entity`**: emits `Entity.cs` + `EntityEvents.cs` + `Configuration.cs` (incl. FK constraints + navigation properties from `relations[]`, plus the opt-in `dataScope` ownership columns, `codedEntity` Code seam and `versioned` rowversion concurrency token) **and patches `ExtensionsDbContext`** (`<<< TENANT-FILTERS >>>` markers): one `ApplyNamed{Strict|Optional}TenantFilter<{FQEntity}>` line per tenant-scoped entity — the named "Tenant" filter that keeps every read tenant-isolated (per-entity line upsert, idempotent, never touches a hand-mounted filter outside the markers; gate DEV-API-032)
- **`cli/scaffold-extension-search`**: patches `DependencyInjection.cs` (between `<<< EXTENSION-SEARCH-DI >>>` markers) with the global-search registration of each list entity — deterministic + idempotent, mirrors `scaffold-core-seed`. `build-spec.ts` assembles the spec from menu sections + permissions + list screens.
- **`cli/scaffold-time-entry-refs`**: patches `DependencyInjection.cs` (between `<<< TIME-ENTRY-REFS-DI >>>` markers) with the HR time-entry imputation registration of each opt-in dimension — deterministic + idempotent. **Spec-file + discover only** (no BA-derived mode: imputation is a business decision, not derivable from pagespecs). `--discover` NEVER auto-registers — it back-fills the commented seam + returns candidate entities for a human to promote.
- **`cli/scaffold-data-scope`**: emits one `{Entity}ScopePolicy.cs` per own/assigned entity and patches the DbContext (`<<< DATA-SCOPE-FILTERS >>>`) + `DependencyInjection.cs` (`<<< DATA-SCOPE-POLICIES-DI >>>`) — the POLICY half of the row-level data-scope cascade (`scaffold-entity`'s `dataScope` input is the COLUMN half). Deterministic + idempotent, full-spec marker replacement.
- **`cli/scaffold-coded-entity`**: emits one `{Entity}CodeKeyDescriptor.cs` per coded entity and patches `DependencyInjection.cs` (`<<< CODED-ENTITY-KEYS-DI >>>` markers → `AddSmartStackCodeKey<…>()`) — the DESCRIPTOR half of the coded-entities seam (`scaffold-entity`'s `codedEntity` input is the ENTITY half: Code column + ICodedEntity). No seed, no migration.
- **`cli/scaffold-migration`**: runs `dotnet ef migrations add`, then **freezes any new/changed SQL objects** (functions/views/procs under `…/Persistence/SqlObjects/**`) into the generated migration as `migrationBuilder.Sql(@"…")` literals — EF never emits raw SQL, so without this a `database update` that doesn't boot the app won't deploy them. Project-name-agnostic (glob), reuses the shared `lib/sql-objects.ts` helper (same as `/efcore create` + `squash`). Reports them in `report.sqlObjectsInlined`.

## Invocation

```bash
npx --prefer-offline tsx skills/development/backend/data-layer/cli/scaffold-entity/index.ts \
  --spec '{"name":"Employee","module":"hrm","appCode":"MyApp","domainPrefix":"hrm","fields":[{"name":"FirstName","type":"string","required":true,"maxLength":100,"indexed":true}],"relations":[{"type":"many-to-one","targetEntity":"Department","foreignKey":"DepartmentId","nullable":false,"onDelete":"restrict"}],"tenantMode":"strict","schemaTarget":"extensions","projectPath":"/path"}'
```

## Key rules

1. **Factory + private ctor**: `new Employee()` outside the class MUST fail.
2. **Required string validation**: `ArgumentException.ThrowIfNullOrWhiteSpace(...)` in `Create` and `Update`.
3. **Domain events on every state change**: Create/Update/SoftDelete/Restore each emit one.
4. **Soft-delete, not hard-delete**: `SoftDelete()` sets `DeletedAt`. Physical deletion is an ops concern.
5. **Query filters**: `HasQueryFilter(e => e.DeletedAt == null)` (Configuration) hides
   soft-deleted rows; the named "Tenant" filter (ExtensionsDbContext, TENANT-FILTERS
   markers — mounted by this CLI) hides other tenants' rows. BOTH must be in place:
   the Configuration alone leaves reads cross-tenant (DEV-API-032).
6. **Indexes default on**: CreatedAt and TenantId always indexed; other fields require `indexed: true`.
7. **domainPrefix convention**: lowercase alphanumeric, no hyphen — the entity's
   business-domain prefix from `entité.md` (`aff_` → `aff`), **never `ext`** (that
   is the `extensions` migration prefix, not a table prefix). Enforced:
   `validate.ts` rejects `ext`/`extensions`/`core` with an actionable error.
8. **Computed fields are skipped**: any `fields[]` entry with a non-empty
   `formula` is **omitted** from the Domain entity and the EF Configuration —
   the value lives only in the read DTOs (`scaffold-business` injects the
   formula into the LINQ projection, single SQL query, no extra column).
   Don't add a property by hand in the Domain entity for a computed field
   ; the `[NotMapped]` round-trip would just confuse EF tracking.
9. **Every relationship becomes a real FK (any scope)**: each `relations[]` entry
   of type `many-to-one` / `one-to-one` emits the FK column +
   `HasOne(...).HasForeignKey(...).OnDelete(DeleteBehavior.X)` with the declared
   cascade — plus a navigation property for `same-module`. `cross-module` / `core`
   refs emit a no-navigation FK (`core` via a `*Reference` stub +
   `ExcludeFromMigrations`, a cross-schema constraint). The `TenantId` FK is emitted
   automatically from `tenantMode`. Only the identity/audit allowlist stays a bare
   `Guid` (DEV-DAT-008 enforces all of this — blocking).
10. **Columns are PascalCase**: EF Core default mapping, NO `HasColumnName`. The
   property name IS the column name (`FirstName`, `CreatedAt`, `ClientId`),
   matching SmartStack.app. snake_case columns are forbidden.
