---
name: audit-dev-api
description: Audit code generated by the API phase against the PRD slice — controllers, routes, HTTP actions, RBAC attributes, DTOs, integration tests
group: D
phase: devApi
kind: audit
audit_only: true
section_label: 'AUDIT-DEV-API (rules to apply against generated controllers vs the PRD API slice)'
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# audit-dev-api — API Phase Code-vs-PRD Audit

## Context

You are auditing the output of the API phase of `ba-develop`.
Compare the PRD slice (spec) against the generated controllers, DTOs,
validators and integration tests (reality), and emit findings for every
drift.

You receive in the system prompt:

- `--- PRD SLICE: API ---` — Markdown listing controllers, routes, HTTP
  actions and permissions the user wants for this application.
- `--- PROJECT INVENTORY (api) ---` — deterministic scan of:
  - `controllers[]` (file, className, route, httpActions),
  - the entities visible from the Domain layer.
- `--- FILES TOUCHED THIS PHASE ---` — files modified during the current run.
- `--- APPLICATION CODE / MODULE CODE ---` — active scope.

You may use `Read` / `Glob` / `Grep` to verify rules. **No `Edit` /
`Write` / `Bash`** — you are read-only.

Apply every rule across the module scope. Emit `ok` findings for passing
rules. Use `err` only when the gap is unambiguous (entity missing its
controller; HTTP action declared in spec but absent from the controller);
use `warn` for conventions and missing tests.

## Rules

### DEV-API-001 — Every PRD entity has a `<Entity>Controller.cs`
- **Severity**: err (if any entity uncovered), ok (if all covered)
- Check: for each entity from the Domain inventory whose PRD declares an
  HTTP API surface, find an `inventory.api.controllers[*].className`
  matching `^<Entity>Controller$` (case-insensitive). Skip entities the
  PRD slice marks `internal: true` or `api: skip`.
- **ok**: label=`DEV_API_001_ok`, params=`{ count: <number> }`
- **err**: label=`DEV_API_001_err`, params=`{ entities: "<comma-separated entity names without controller>" }`
- **fixSkill**: `backend-controller`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Re-run the API phase. The subagent
  must call `scaffold-controller` for each missing entity. The generator
  emits the controller + DTOs + FluentValidation in one shot."

### DEV-API-002 — Each controller declares the HTTP actions listed in the PRD
- **Severity**: err (if any required action missing), ok (if all present)
- Check: for each controller in `inventory.api.controllers[*]`, the
  PRD slice declares the expected `httpActions` (typically `GET list`,
  `GET detail`, `POST create`, `PUT update`, `DELETE remove`). Cross
  the spec list against `controllers[*].httpActions`. Missing actions
  block the frontend from completing CRUD.
- **ok**: label=`DEV_API_002_ok`
- **err**: label=`DEV_API_002_err`, params=`{ actions: "<comma-separated controller.action pairs>" }`
- **solution** (mandatory on err): "Re-run the API phase. The subagent
  must add the missing `[HttpGet]` / `[HttpPost]` / `[HttpPut]` /
  `[HttpDelete]` methods. The DTO + validator scaffolders cover Create
  / Update — Delete only needs a route."

### DEV-API-003 — Controller route follows `/<applicationCode>/<moduleCode>/<entity-kebab-plural>`
- **Severity**: warn (if any violation), ok (if all comply)
- Check: each `inventory.api.controllers[*].route` must match the pattern
  `/<applicationCode>/<moduleCode>/<entity-kebab-plural>` where the
  application + module codes come from the PRD scope and the entity
  segment is the kebab-cased plural form of the controller's entity.
- **ok**: label=`DEV_API_003_ok`
- **warn**: label=`DEV_API_003_warn`, params=`{ controllers: "<comma-separated controller:expected:got>" }`
- **solution** (mandatory on warn): "Update the controller's `[Route(...)]`
  attribute. The DB-driven frontend router builds page links from this
  exact pattern; deviations break navigation silently at runtime."

### DEV-API-004 — Write actions carry `[RequirePermission(...)]`
> **SUPERSEDED by DEV-API-033** (deterministic, err, ALL verbs incl. GET,
> attribute-block-scoped, `[AllowAnonymous]` as the explicit public opt-out).
> Kept for legacy reports only — do not apply it conversationally when the
> CLI runs: DEV-API-033's verdict wins.
- **Severity**: warn (if any unguarded write action), ok (if all guarded)
- Check: `Read` each controller and `Grep` for `[HttpPost]`, `[HttpPut]`,
  `[HttpDelete]` declarations. Each must have a `[RequirePermission(...)]`
  attribute on the SAME method (within ~3 lines above the method
  signature). Read actions (`[HttpGet]`) are warnings only when the PRD
  marks the entity sensitive (e.g. `audit: required`).
- The `/lookup` endpoint is the ONLY one expected to carry TWO permissions —
  `[RequirePermission(X.Lookup, X.Read)]` (ANY semantics, SmartStack ≥ 3.62).
  The two-arg form counts as guarded; its gate completeness is DEV-API-016's
  `lookup_gate` warn, not this rule.
- **ok**: label=`DEV_API_004_ok`
- **warn**: label=`DEV_API_004_warn`, params=`{ actions: "<comma-separated controller.action pairs missing perm>" }`
- **solution** (mandatory on warn): "Add `[RequirePermission(\"<app>.<module>.<section>.<action>\")]`
  on each listed method. The format must match the seed provider's
  `Permission.CreateForModule` calls — otherwise the gate denies
  everyone."

### DEV-API-005 — Each controller has matching DTOs (Response / Create / Update)
- **Severity**: warn (if any missing), ok (if all triplets present)
- Check: for each controller, `Glob` the project for
  `<Entity>ResponseDto.cs`, `<Entity>CreateDto.cs`, `<Entity>UpdateDto.cs`
  (the latter two only required when the controller exposes POST/PUT).
  Use `Grep` to confirm the file declares the type, not just imports it.
- **ok**: label=`DEV_API_005_ok`
- **warn**: label=`DEV_API_005_warn`, params=`{ entities: "<comma-separated entity:missing-DTO list>" }`
- **solution** (mandatory on warn): "Re-run the DTO scaffolder for the
  listed entities (`scaffold-dto --entity <Entity>`). The generator emits
  the missing files alongside FluentValidation validators."

### DEV-API-006 — Each entity with Create/Update has a `<Entity>Validator.cs`
- **Severity**: warn (if any missing), ok (if all comply)
- Check: for each controller exposing POST or PUT, `Glob` for
  `<Entity>Validator.cs` (FluentValidation validator). Skip controllers
  that only expose `[HttpGet]` or `[HttpDelete]` — they don't accept a
  body.
- **ok**: label=`DEV_API_006_ok`
- **warn**: label=`DEV_API_006_warn`, params=`{ entities: "<comma-separated entity names>" }`
- **solution** (mandatory on warn): "Run `scaffold-business --entity <Entity>`
  which emits the validator alongside the service / MediatR handler.
  Without the validator, MVC binds the DTO without enforcing the PRD's
  business rules at the API boundary."

### DEV-API-007 — Each controller has at least one `<Entity>ControllerTests.cs`
- **Severity**: warn (if any controller untested), ok (if all covered)
- Check: for each controller in `inventory.api.controllers[*]`, `Glob`
  for `<Entity>ControllerTests.cs` under `tests/` or any `*.Tests/`
  project. The xUnit class must reference the controller (`Grep` for
  `using <namespace>` or `<Entity>Controller`).
- **ok**: label=`DEV_API_007_ok`
- **warn**: label=`DEV_API_007_warn`, params=`{ controllers: "<comma-separated controllers without tests>" }`
- **solution** (mandatory on warn): "Run `scaffold-tests --layer api
  --entity <Entity>` to emit the integration test class. Untested
  controllers regress silently when the next phase touches the
  service layer."

### DEV-API-008 — Business rules are enforced, not stubbed
- **Severity**: err (any rule left as a TODO marker), ok (if none)
- Check: `Grep` the Application-layer validators
  (`*Application/**/Validators/*.cs`) and the Infrastructure services
  (`*Infrastructure/**/Services/**/*.cs`) for the literal `// TODO[BR-`.
  Any occurrence means a business rule from `règles-métier.md` was emitted
  by `scaffold-business` as a fallback comment and never translated.
  Additionally, for each `BR-…` id referenced in the API PRD slice, confirm
  a `RuleFor(...)` or a `// BR-…` trace exists in a validator or service.
- **ok**: label=`DEV_API_008_ok`, params=`{ count: <number of rules traced> }`
- **err**: label=`DEV_API_008_err`, params=`{ rules: "<comma-separated BR ids left as TODO or untraced>" }`
- **fixSkill**: `backend-business-layer`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Complete the post-scaffold
  business-logic pass (see `backend-business-layer` SKILL.md § Post-scaffold
  pass). Translate every `// TODO[BR-…]` into a `RuleFor(...)` in BOTH the
  Create and Update validators, or — for multi-entity / stateful / temporal
  rules — into a guard in the service method. No `// TODO[BR-` may survive
  the API gate."

### DEV-API-009 — Use-case actions are implemented, not `NotImplementedException`
- **Severity**: err (any stubbed action), ok (if none)
- Check: `Grep` the Infrastructure services
  (`*Infrastructure/**/Services/**/*.cs`) for `NotImplementedException`
  or `// TODO[UC-`. Either is a non-canonical custom action (anything beyond
  archive / restore / activate / deactivate / duplicate) that
  `scaffold-business` stubbed and the agent never implemented.
- **ok**: label=`DEV_API_009_ok`
- **err**: label=`DEV_API_009_err`, params=`{ actions: "<comma-separated service method / UC id stubbed>" }`
- **fixSkill**: `backend-business-layer`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Implement each stubbed service method
  from the linked use case's main flow (`backend-business-layer` SKILL.md
  § Post-scaffold pass): load the aggregate, apply the state change +
  invariants, persist, return the response DTO. Keep the `// UC-…` trace
  comment. No `NotImplementedException` may survive the API gate."

### DEV-API-010 — Pagespec `kind:api` actions land on the controller
- **Severity**: err (any pagespec action missing on the controller), ok (if all matched)
- Check: load every `<MODULE>/pagespecs/*.md` of the audited module and
  parse the fenced ```json blocks. For each `pagespec.actions[]` entry
  with `kind: "api"` AND `code ∉ STANDARD_CRUD_CODES` (the set declared
  in `lib/page-spec-actions.ts`), compute the expected controller route
  per `expectedControllerRoute(scope, endpoint)`:
  - `scope: "row"`    → `[Http<VERB>("{id:guid}/<endpoint>")]`
  - `scope: "bulk"`   → `[Http<VERB>("bulk/<endpoint>")]`
  - `scope: "header"` → `[Http<VERB>("<endpoint>")]`
  Then `Read` the entity's `<Entity>sController.cs` and `Grep` for
  the exact attribute (verb + route). De-duplicate pagespec entries by
  `(scope, endpoint, httpMethod)` before counting — the same action MAY
  appear on multiple views and is expected to land ONCE on the controller.
- **ok**: label=`DEV_API_010_ok`, params=`{ count: <matched> }`
- **err**: label=`DEV_API_010_err`, params=`{ actions: "<comma-separated entity:VERB:expectedRoute pairs missing>" }`
- **fixSkill**: `backend-controller`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Re-run Phase 2 of `/ba-develop` after
  refreshing the pagespecs. The orchestrator derives `customActions[]` from
  `pagespec.actions[]` and forwards them to `scaffold-controller`. If the
  pagespec endpoint differs from the legacy controller route, either:
  (a) update the pagespec `endpoint` to match the controller, or (b) let
  the regenerator rewrite the controller route to match the pagespec.
  Pick the source of truth via `/audit-dev-actions-alignment`."

### DEV-API-011 — No phantom controller endpoints outside the pagespec contract
- **Severity**: warn (any non-CRUD endpoint not in any pagespec), ok (if none)
- Check: reciprocal of DEV-API-010. For each `[HttpVerb("…")]` route
  attribute on `<Entity>sController.cs` whose path is NOT one of the
  canonical CRUD shapes (`""`, `"{id:guid}"`), confirm a matching
  `pagespec.actions[]` entry exists somewhere in this module's pagespecs.
  Catches legacy hand-coded endpoints that pre-date the per-page contract
  and silently drift from what the BA actually declared.
- **ok**: label=`DEV_API_011_ok`
- **warn**: label=`DEV_API_011_warn`, params=`{ endpoints: "<comma-separated entity:VERB:route pairs without pagespec backing>" }`
- **solution** (mandatory on warn): "Either declare the missing custom
  action in the corresponding `screen.md` (then re-run `/ba-create-prd`)
  or delete the orphan endpoint. Phantom endpoints are unreachable from
  the UI under the per-page contract — leaving them in place misleads
  future audits."

### DEV-API-012 — Every pagespec view has its endpoint on the screen controller
- **Severity**: err (any pagespec list/detail/form view missing an endpoint on its `{EntityPlural}ScreenController.cs`), ok otherwise
- **Transition behaviour**: when the project has NO screen controllers at all
  (i.e. Phase 2b of `/ba-develop` has not yet been executed for any entity),
  the rule emits `ok` with `params.note = "no screen-driven controllers
  detected — Phase 2b not yet executed"` instead of flooding the report. The
  per-entity gate only checks pagespecs whose entity already has at least
  one screen controller — partial migration is supported.
- Check: load every `<MODULE>/pagespecs/*.md` of the audited module. For
  each pagespec with `view ∈ {list, detail, form}` whose entity has a
  `{EntityPlural}ScreenController.cs` on disk, verify the expected
  endpoint(s) exist:
  - `list`   → `[HttpGet("list")]`
  - `detail` → `[HttpGet("detail/{id:guid}")]`
  - `form`   → BOTH `[HttpPost("form")]` AND `[HttpPut("form/{id:guid}")]`
  The class-level mount route is irrelevant — only the method-level path
  matters because the scaffolder always emits `[Route("api/screens/{plural}")]`.
- **ok**: label=`DEV_API_012_ok`, params=`{ count: <matched pagespecs> }`
- **err**: label=`DEV_API_012_err`, params=`{ screens: "<comma-separated SCR-…:VERB route>" }`
- **fixSkill**: `backend-screen-controller`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Re-run Phase 2b of `/ba-develop` for
  each (section, entity) missing an endpoint. The `scaffold-screen-controller`
  CLI must emit list/detail/form endpoints on the screen controller per
  `pagespec.view`."

### DEV-API-013 — No `// TODO[SCREEN-…]` markers in screen controllers
- **Severity**: err (any marker remains), ok (if none)
- Check: `Grep` every `.cs` file under any `Screens/` folder of the
  backend for `// TODO[SCREEN-<screenCode>]:`. The
  `scaffold-screen-controller` CLI emits these markers whenever it cannot
  prove the matching Business method exists or the DTO mapping is
  incomplete. The Phase 2b subagent is expected to clear every marker
  before staging its commit.
- **ok**: label=`DEV_API_013_ok`
- **err**: label=`DEV_API_013_err`, params=`{ count: <number>, files: "<comma-separated files>" }`
- **fixSkill**: `backend-screen-controller`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Each `// TODO[SCREEN-<screenCode>]:`
  marker means the screen-controller scaffolder left a placeholder.
  The Phase 2b subagent must add the missing method to `I{Entity}Service`
  (reusing Phase 2a's query primitives — never duplicate rules) and
  replace the marker with the real LINQ projection."

### DEV-API-014 — Same `I{Entity}Service` injected in both strata
- **Severity**: err (any entity injects different service interfaces across strata), ok otherwise
- Check: build the index `entity → { integration: injectedService, screens: injectedService }`
  from every `*Controller.cs` (integration) and `*ScreenController.cs`
  (screens). For every entity present in BOTH strata, the two
  `injectedService` values MUST be identical. A divergence
  (e.g. `ITypeAffaireService` vs `ITypeAffaireScreenService`) means a
  parallel service was created — Business rules can drift between strata.
  Entities present in only one stratum are skipped (legitimate during the
  transition).
- **ok**: label=`DEV_API_014_ok`, params=`{ entities: <count compared> }`
- **err**: label=`DEV_API_014_err`, params=`{ entities: "<E:integration=I…Service,screens=I…Service ...>" }`
- **fixSkill**: `backend-screen-controller`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Both strata must inject the SAME
  `I{Entity}Service`. If the screen controller needs methods not on the
  integration interface, extend the EXISTING interface — do not create a
  parallel service. Business rules live in ONE Business layer."

### DEV-API-015 — Core-projected columns reach the service projection
- **Severity**: warn (a declared `source` column is never projected), ok otherwise
- A pagespec column carrying a `source` block
  (`{"nav":"Customer","target":"TenantOrganisation","property":"Name",…}`) contracts the
  service to read the value THROUGH the Core navigation — never from an
  invented local column (the whole point of the Core-reuse design: zero
  duplicated data). Check: for each such column, the entity's
  `{Entity}Service.cs` (glob `**/Services/**/{Entity}Service.cs`) must contain
  the token `.{nav}.{property}` (or the `!`-guarded variant
  `.{nav}!.{property}` emitted for nullable FKs).
- Warn (not err): token presence is the 80% signal — an agent-aliased
  expression can false-positive. A warn routes the item back to the Phase 2b
  subagent; it does not block the gate.
- **ok**: label=`DEV_API_015_ok`, params=`{ count }` (or `count: 0` + note when
  no pagespec declares a `source` column).
- **warn**: label=`DEV_API_015_warn`, params=`{ columns: "<Entity.key⇐x.Nav.Prop ...>", details }`
- **fixSkill**: `backend-screen-controller`, **fixPhaseKey**: `api`
- **solution** (mandatory on warn): "Project the value through the Core
  navigation inside the LINQ projection (e.g. `FirstName = x.User.FirstName`,
  or the `!`-guarded conditional for a nullable FK) — never invent a local
  column and never add an EF Include()."

### DEV-API-016 — Integration controller honors the frontend contract (provenance + completeness)
- **Severity**: err (any integration controller breaks the contract), warn
  (`DEV_API_016_lookup_gate_warn` — a `/lookup` endpoint without the v3.62
  dual gate `[RequirePermission(X.Lookup, X.Read)]`: read-holders keep
  working, but lookup-only actors get 403 on their FK dropdowns), ok otherwise
- **Why**: the deterministic frontend (`scaffold-api-client` / `scaffold-component`)
  is generated against a fixed backend contract — it ALWAYS calls
  `GET /api/{module}/{section}/lookup` (NavRoute-resolved) for an `<EntityLookup>` combobox and
  reads the list response as `{ items, totalCount, page, pageSize }`. When the
  backend is **hand-written off `scaffold-controller`** it drops the `/lookup`
  route (every FK dropdown 404s) and/or returns a bare array (every list page
  renders empty because the frontend reads `response.items`). This rule is the
  BACKEND half of the wire contract; `DEV-WIRE-001` is the frontend half — both
  must hold or the app is broken end-to-end. It is the gate that makes "the
  backend MUST be 100% scaffolder-generated" enforceable.
- Check: for each integration `<Entity>Controller.cs` (NOT under `Screens/`),
  verify all of:
  - (a) a `[HttpGet("lookup")]` endpoint is present,
  - (b) the list does NOT return a bare collection (`ActionResult<IReadOnlyList<…>>`
    / `IEnumerable` / `ICollection` / `List`, or a `ProducesResponseType(typeof(IReadOnlyList<…>))`)
    — `scaffold-controller` always wraps it in `PaginatedResult<…ListDto>`,
  - (c) no unexpanded `[controller]` token in the class `[Route(...)]`,
  - (d) the `// @generated-by scaffold-controller` provenance marker is present.
- **ok**: label=`DEV_API_016_ok`, params=`{ count: <controllers checked> }`
- **err**: label=`DEV_API_016_err`, params=`{ controllers: "<entity: problem; problem | …>" }`
- **fixSkill**: `backend-controller`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): "Re-run `scaffold-controller` for each listed
  entity — it emits the `[HttpGet(\"lookup\")]` endpoint, the paginated `GetAll`
  (`PaginatedResult<…ListDto>`) and the `@generated-by` marker by construction.
  The backend MUST be 100% scaffolder-generated: never hand-write or restructure
  the controller (`ba-develop` SKILL.md § Backend is scaffolder-owned)."

### DEV-API-017 — List queries paginate SERVER-side
- **Severity**: err (any list-query method materialises the whole set), ok otherwise
- **Why**: the deterministic frontend list page sends `page`/`pageSize`/`search`/`sortBy`
  and reads `PaginatedResult.totalCount`. If a service query does `.ToListAsync()` with
  no `Skip`/`Take`/`CountAsync`, the client receives EVERY row and paginates/searches in
  the browser — everything past `pageSize` is invisible and search only matches the loaded
  page. `GetAllAsync` is scaffolder-owned (always compliant); the screen stratum's
  `GetForListScreenAsync` is written by the Phase 2b subagent against the pagespec columns
  — this rule is what stops it drifting to a client-side list.
- Check: (a) for each `{Entity}Service.cs`, every present `GetAllAsync` / `GetForListScreenAsync`
  method window contains BOTH `.Skip(`…`).Take(` AND `CountAsync(`; (b) **filter-param leg** —
  for each list pagespec with `filters[]`, BOTH `Get{Entity}ListScreenQuery` AND the integration
  `Get{Plural}Query` declare one member per param `lib/page-spec-filters.screenFilterParams()`
  derives (select/text → `string?`, boolean → `bool?`, date-range → `DateTime?` From/To; each
  record's existing `Guid?` members are its FK skip-set). A missing member means the filter UI
  posts a param the server silently drops — the list LOOKS filtered and is not. The integration
  leg is the lockstep guard: a regenerated frontend posts the filter params in BOTH modes, so a
  stale backend (pre-server-filters scaffold) fails here instead of lying silently.
- **ok**: label=`DEV_API_017_ok`, params=`{ count: <methods checked> }`
- **err**: label=`DEV_API_017_err`, params=`{ methods: "<Entity.Method (missing …) | …>" }`
  (the filter-param leg reports `missing query params <Members> (pagespec <file> filters[])`)
- **fixSkill**: `backend-business-layer`, **fixPhaseKey**: `api`
- **solution** (mandatory on err): reuse the server-side primitive of `GetAllAsync`
  (IQueryable + multi-field `Where(EF.Functions.Like)` + `CountAsync` + whitelisted sort +
  `Skip/Take`) — see `business-layer` SKILL.md § Server-side list; for missing query members,
  re-run `scaffold-business` with `screenFilters` (the list pagespec `filters[]`) and apply one
  predicate per member in the hand-written body BEFORE `CountAsync`.

### DEV-API-023 — `payloadParameters` must reach a transport-binding endpoint

Reciprocal of DEV-API-018. An action declaring collectible
`payloadParameters[]` makes the generated UI render a `<CustomActionDialog>`
and send the values. The matched controller method MUST give them a
transport — `[FromBody]` on non-GET verbs (the dialog's values ride the
body), `[FromQuery]` on GET (a GET carries no body; the chain projects the
parameters onto nullable query scalars). Without the binding the user's
input is silently discarded server-side. The generation chain synthesizes
the payload DTO name (`{Pascal(code)}{Entity}Dto`) and the GET
`queryParameters` in `lib/page-spec-actions.ts` so all three projections
agree — an offender means the backend was generated by a pre-synthesis
chain or spliced by hand: re-run Phase 2a and re-audit. Err.

### DEV-API-018 — Custom action `payloadDto` without `payloadParameters`
- **Severity**: warn (a body-bearing action the UI cannot populate), ok otherwise
- **Why**: `scaffold-component` renders a `<CustomActionDialog>` — and the frontend POSTs a
  body — ONLY when the action declares collectible `payloadParameters[]`. A `kind:api`
  non-GET action that declares a `payloadDto` but NO `payloadParameters` therefore sends
  **no body**. This is now safe (the controller binds the body with
  `[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)]` and the service receives a
  default DTO), but it is silent: if the endpoint genuinely needs a user-entered body, the
  BA forgot to model the fields. The warn surfaces exactly that fork.
- Check: for each pagespec action with `kind:api`, `httpMethod ≠ GET`, `payloadDto` set and
  no `payloadParameters[]` → warn (deduped by entity/scope/endpoint).
- **ok**: label=`DEV_API_018_ok`, params=`{ count: 0 }`
- **warn**: label=`DEV_API_018_warn`, params=`{ entity, action, scope, verb, endpoint, payloadDto }`
- **fixSkill**: `ba-create-screen`, **fixPhaseKey**: `api`
- **solution**: add `payloadParameters[]` to the pagespec action (the UI then collects +
  sends the body), OR confirm the body is optional / server-derived (e.g. year defaults to
  the current year) and leave it — the optional-body safety net keeps it from 415ing.

### DEV-API-019 — FK relation filters span the triplet (controller ⊕ query ⊕ handler)
- **Severity**: err (a Guid FK filter is missing / broken on any leg), ok otherwise
- **Why**: this is the BACKEND half of the 360 view (DEV-UI-031 is the frontend half).
  The invariant of `lib/page-spec-related-tabs.ts` is that every Guid FK of an entity
  becomes an optional whitelisted list filter generated in lock-step by
  `scaffold-controller` ⊕ `scaffold-business` across THREE legs:
  - controller `GetAll(… [FromQuery] Guid? {fkCamel} = null …)` forwarding `{fkCamel}`
    positionally into `new Get{Plural}Query(page, pageSize, search, sortBy, sortDir, {fk}…)`;
  - `Get{Plural}Query` record declaring `Guid? {FkPascal} = null`;
  - `GetAllAsync` applying `if (query.{FkPascal} is not null) { q = q.Where(x => x.{FkPascal} == query.{FkPascal}); }`
    **BEFORE** `CountAsync` (otherwise `totalCount` ignores the filter).
  A dropped leg silently unfilters the 360 related tabs: the frontend sends
  `?{fk}=<guid>` and the backend ignores it — the tab lists EVERY record.
- **FK source (documented decision)**: the expected FK set per entity is the UNION of
  (1) the Domain entity class's Guid(?) `*Id` auto-properties, filtered through the
  SSOT `fkFilterFields()` of `lib/page-spec-related-tabs.ts` (Id-suffixed, system
  columns `id`/`tenantId`/`createdBy`/`updatedBy`/`deletedBy` excluded) — this is what
  catches a **stale pre-360 scaffold** where NO leg carries the filter — and
  (2) every FK filter present on ANY of the three legs (cross-leg symmetry — catches a
  hand-edit that dropped one leg). Leg-derived candidates pass through the same
  `fkFilterFields` shape check, so a status/enum `Where` or a non-FK Guid param never
  widens the set. **Limitation**: when the Domain entity class cannot be located under
  the backend root (`**/*.Domain/**`, `**/Domain/**`, `**/Entities/**`), variant (2)
  alone applies — a fully-absent FK filter is then caught at generation level instead
  (both scaffolders derive the filter set from the same `fkFilterFields` SSOT by
  construction).
- Check: for every integration `<Entity>Controller.cs` (NOT under `Screens/`) exposing a
  `GetAll` method, mirror the three legs by regex (same file-location plumbing as
  DEV-API-016/017: the query record is resolved by the `new Get…Query(…)` name in the
  GetAll body against `**/Queries/**/*.cs`; the handler is the entity's
  `{Entity}Service.cs` `GetAllAsync` method window). One **err per missing leg per FK**:
  - (a) controller — `[FromQuery] Guid? {fkCamel} = null` exposed AND `{fkCamel}`
    forwarded into `new Get{Plural}Query(…)`;
  - (b) query record — `Guid? {FkPascal} = null` declared (skipped when the record
    cannot be resolved: a missing record does not compile);
  - (c) handler — the is-not-null-guarded `q = q.Where(x => x.{FkPascal} == query.{FkPascal})`
    present AND positioned before `CountAsync` (index comparison; skipped when
    `GetAllAsync` is absent — DEV-API-016/017's domain).
- **ok**: label=`DEV_API_019_ok`, params=`{ count: <FKs checked> }` (or a `note` when no
  GetAll controller / no FK candidates exist)
- **err**: label=`DEV_API_019_err`, params=`{ entity, fk, leg, reason, file }` (per FK per leg)
- **fixSkill**: `backend-controller` (controller leg) / `backend-business-layer`
  (record + handler legs), **fixPhaseKey**: `api`, autoFixable: false
- **solution** (mandatory on err): "Re-run `scaffold-controller` AND `scaffold-business`
  for the entity — both derive the FK filter set from the same SSOT
  (`lib/page-spec-related-tabs.ts` `fkFilterFields`) by construction. Never hand-edit a
  single leg."

### DEV-API-020 — Planned own/assigned scope is actually enforced (policy + filter + DI)
- **Severity**: err (a scoped read with no row filtering), ok otherwise
- **Why**: the RBAC matrix planning a Portée `les siennes`/`attribuées` makes
  `scaffold-core-seed` emit the scoped `{path}.read` + the `.read.all` bypass — but
  **permissions don't filter rows**. Without the policy half (`scaffold-data-scope`),
  every list/detail endpoint returns EVERY row to every actor: the app LOOKS scoped
  (the matrix says so) while actually leaking. This is the silent-failure gap of the
  data-scope cascade (see `ba-develop` phases-detail § "Data scopes — end-to-end cascade").
- Check: for each module entity whose `rbac.md` matrix gives ANY actor a `read` with
  Portée ∈ {`les siennes`, `own`, `attribuées`, `assigned`}, verify the FOUR legs:
  - (a) `{Entity}ScopePolicy.cs` exists under `src/*.Application/**/Authorization/`
    with `ScopeAllPermission` ending in `.read.all` for the entity's section path;
  - (b) the client DbContext's `OnExtensionModelCreating` carries
    `ApplyDataScopeFilter(…, {Entity}ScopePolicy.Instance)` (inside the
    `<<< DATA-SCOPE-FILTERS >>>` markers or hand-mounted);
  - (c) the Infrastructure DI registers `AddSingleton<IDataScopePolicy>({Entity}ScopePolicy.Instance)`;
  - (d) the DbContext constructor forwards `ICurrentUserAccessor` to the
    `SmartStackExtensionDbContext` base (without it the filter is INERT — "system"
    context, never scoped).
  Also **err** (reversed) when any controller of the module carries
  `[RequireDataScope(typeof(<extension entity>), …)]` — the platform guard is bound to
  `ICoreDbContext` and throws `InvalidOperationException` at runtime for extension
  entities (the extension `GET {id}` contract is the named filter's 404).
- **ok**: no actor has a scoped read (nothing to enforce), or all four legs present.
- **err**: name the entity, the missing leg(s), and the actor/Portée rows that planned
  the scope.
- **fixSkill**: `backend-data-layer` (run `scaffold-entity` with `dataScope` +
  `cli/scaffold-data-scope`), **fixPhaseKey**: `entities`
- **solution**: "Run the two halves of the data-scope cascade for the entity —
  `scaffold-entity` (`dataScope`: ownership columns + markers) then
  `scaffold-data-scope` (policy + `ApplyDataScopeFilter` + DI). Upgrade the DbContext
  ctor if it predates the seam. See data-layer `references/data-scopes.md`."
- **Deterministic**: implemented in the CLI sibling below — the RBAC → section →
  entity mapping and the four-leg scan (policy / filter / DI / ctor) run without an LLM.

### DEV-API-021 — Every permission constant is seeded (constants ⊆ grants)
- **Severity**: err (a constant no seeded grant can match), warn (legacy layout, app
  not deducible), ok otherwise
- **Why**: `scaffold-controller` compiles the permission paths into `const string`
  values in `{Mod}Permissions.{Section}.cs`; `scaffold-core-seed` writes the grants
  as `permissionsToSeed` tuples in `{AppPascal}CorePermissionsSeedDataProvider.cs`.
  Two different CLIs, one contract: the platform's `PermissionMatcher` does an
  EXACT match against the seeded 4-segment `{app}.{module}.{section}.{action}`
  paths. A constant absent from the seed — most notoriously the historical
  3-segment (app-less) `permissionPrefix` fallback bug — can NEVER be granted:
  every role-based user 403s on the endpoints it gates, while the `*` super-admin
  used by dev smoke tests sails through. Runtime-only, invisible to unit tests;
  this rule is the build-time net.
- Check: collect every `const string` value from `**/Permissions/**/*Permissions.*.cs`
  (app read from the `Permissions/<App>/<Module>/` path segment) and every `path`
  tuple from `**/*CorePermissionsSeedDataProvider.cs` (app from the file name),
  then verify each constant exists VERBATIM in the SAME app's seeded paths.
  BOTH provider generations parse: the pre-floor 3-field tuple
  `(Path, Action, SectionCode)` and the multi-grain 4-field
  `(Path, Action, Level, NodeCode)` the permission floor emits. Since the floor
  (access/lookup/read/create/update/delete/execute per section+resource) is
  derived from the nav tree by scaffold-core-seed, a regenerated project passes
  this rule BY CONSTRUCTION — one emitted constant = one seeded row.
  ONE direction only (constants ⊆ seed) — the reverse is legitimate: derived
  lookup grants, `.read.all` scope tiers and nav grants have no controller constant.
  Scans the whole backend (all apps), like DEV-API-016.
- **ok**: label=`DEV_API_021_ok`, params=`{ count: <constants matched> }`
- **err**: label=`DEV_API_021_err`, params=`{ reason, constants }` with `reason` ∈
  - `missing-app-segment` — the constant is 3-segment and the seed carries exactly
    the app-prefixed value: THE permissionPrefix-fallback bug signature →
    fixSkill `backend-controller` / fixPhaseKey `api` (re-run scaffold-controller);
  - `absent-from-seed` — the path exists nowhere in the app's seed →
    fixSkill `backend-core-seed` / fixPhaseKey `core` (re-run scaffold-core-seed);
  - `no-seed-provider` — the app has constants but no `*CorePermissionsSeedDataProvider`
    at all (the whole app 403s) → fixSkill `backend-core-seed` / fixPhaseKey `core`.
- **warn**: label=`DEV_API_021_warn`, `reason` ∈
  - `legacy-unresolved-app` — legacy `Permissions/{Module}/` layout where the
    owning app cannot be deduced (0 or ≥2 candidate providers); never guesses
    across apps. Fix = re-run scaffold-controller (relocates to
    `Permissions/{App}/{Module}/` + regenerates 4-segment values);
  - `legacy-pre-floor-seed` — a STRUCTURAL constant (Access/Lookup, emitted
    unconditionally by scaffold-controller) missing from a pre-floor 3-field
    provider: the project predates the permission floor, not a real defect —
    the historical false positive on non-FK-producer sections. Bounded to
    Access/Lookup on 3-field providers ONLY (a 4-field provider gets no
    exemption; a missing data action stays err). Fix = re-run
    scaffold-core-seed (the floor seeds both by construction).
- **Deterministic**: implemented in the CLI sibling below — pure file scans + a
  verbatim set-membership check, no LLM.

### DEV-API-022 — Planned coded entity → seam actually in place
- **Severity**: err (a leg of the coded-entities seam is missing), ok otherwise
- **Why**: an entity declaring `- **Code pattern** : …` in `entité.md` (under its
  `### ENT-… — {Entity}` block — DM-017's data-model half) plans a system-allocated
  business Code. The seam spans four generated legs, and a missing leg is
  runtime-only and SILENT — the shared `CodedEntitySaveHandler` skips allocation
  without a log, so rows ship with an empty `Code` and dev smoke tests pass.
  This rule is the build-time net.
- Check — for every entity with a `**Code pattern**` line, four legs:
  1. `entity` — the Domain class implements `ICodedEntity` (scaffold-entity `codedEntity`);
  2. `descriptor-di` — `AddSmartStackCodeKey<…{Entity}CodeKeyDescriptor>()` is registered
     in the DI (scaffold-coded-entity, between the `CODED-ENTITY-KEYS-DI` markers);
  3. `ctor-IServiceProvider` — the DbContext ctor forwards `IServiceProvider` to the
     `SmartStackExtensionDbContext` base (without it allocation is silently skipped);
  4. `unique-index` — the entity's EF Configuration carries a unique index on `Code`
     (composite `(TenantId, Code)` or simple/filtered — the DB safety net under the
     allocation engine).
- Check — six COHERENCE legs, armed only when `{Entity}CodeKeyDescriptor.cs`
  exists (a missing file is already the `descriptor-di` leg). The BA line is
  parsed via `lib/code-pattern-grammar.parseCodePatternLine`; a facet compares
  ONLY when authored on BOTH sides (unauthored prose = null → skipped, never a
  false err):
  5. `format-mismatch` / `scope-mismatch` / `reset-mismatch` / `gapless-mismatch`
     — descriptor `DefaultFormat`/`DefaultScopeKind`/`DefaultReset`/`Gapless`
     vs the entité.md facets (the silent-degradation class: `Yearly → None`
     to appease a validator ships green with wrong numbering);
  6. `descriptor-key-mismatch` — the entity's `ICodedEntity.CodeKey` literal vs
     the descriptor's `Key` (a mismatch means the key registry never resolves —
     allocation fails at insert);
  7. `code-inputs-empty` — the effective format carries derived tokens but the
     entity still ships the EMPTY `GetCodeInputs()` literal (pre-C5 emission;
     heal = re-run `scaffold-entity` passing `codedEntity.format`);
  8. `suffix-no-probe` — descriptor `CollisionStrategy.Suffix` with a
     MONO-generic `AddSmartStackCodeKey<…>` DI line (the v3.67 engine refuses
     probe-less Suffix allocations at runtime).
- Check — NEAR-MISS declarations (the green-by-vacuity killer): every
  `Code pattern` MENTION of entité.md that does not parse (a table cell — the
  incident shape, a wrong-cased/unbolded bullet, declarative prose —
  `lib/code-pattern-grammar.findCodePatternNearMisses`) is an **err**
  `reason: 'near-miss-declaration'` — emitted whether or not other entities
  parse. The TRUE zero (no mention at all) keeps the mute ok note.
- Check — SUPPLIED legs, armed when the line declares `surchargeable à la
  création` (`parseCodePatternLine(...).supplied === true`), skipped with a
  **warn** `supplied-socle-floor` below socle 3.66.0
  (`MIN_SOCLE_SUPPLIED_CODE_VERSION` — the seam cannot compile there):
  9. `supplied-create-surface` — `Create{Entity}Command` exposes the terminal
     `string? Code = null` (scaffold-business);
  10. `supplied-guard-call` — `{Entity}Service.cs` runs the
     `EnsureAvailableAsync` + `ApplyCode` pair before the save.
  Reciprocal **warn** `supplied-undeclared`: `string? Code` on the Create
  command of a coded entity WITHOUT the facet (the BA line is the SSOT —
  author it or drop the member; warn not err, the pre-facet doctrine allowed
  the hand-authored exception).
- **ok**: label=`DEV_API_022_ok`, params=`{ count }` (or `{ count: 0, note }` when no
  entity declares a Code pattern).
- **err**: label=`DEV_API_022_err`, params=`{ entity, pattern, missingLegs }` (presence
  or `reason: 'supplied'`), `{ entity, pattern, reason: 'coherence', issues }`
  (coherence), or `{ file, line, shape, reason: 'near-miss-declaration' }`.
- **fixSkill**: `backend-data-layer` (run `scaffold-entity` with `codedEntity` +
  `cli/scaffold-coded-entity`), **fixPhaseKey**: `entities`
- **solution**: "Run the two halves of the coded-entities seam, ensure the DbContext
  ctor forwards `IServiceProvider`, then regenerate the migration. If the table
  already holds data, deduplicate existing Code values BEFORE applying the
  unique-index migration."
- **Deterministic**: implemented in the CLI sibling below — entité.md parse + four
  file scans, no LLM.

### DEV-API-024 — Every entity has an API surface or an explicit opt-out
- **Severity**: err, ok otherwise. Deterministic (`--rules DEV-API-024`).
- Check: every `entité.md` entity resolves `{Plural}Controller.cs` OR
  `{Plural}ScreenController.cs` on disk, OR carries the explicit
  `**API** : none` marker (aliases: aucune/interne/skip), OR has a
  junction/component classification (never their own controller).
  lookup/reference entities are NOT exempt — they serve `/lookup`.
- The hole it closes: PRD-084 guarantees the DOMAIN for every entity — table
  included — while the only surface signal was PRD-044, a warn skipped for
  whole classifications, and the Phase 2a gate said "that the PRD declares an
  API surface". A modelled + migrated entity shipped "stockable et ni lisible
  ni écrivable", silently (HandoverAccessory).
- **fixSkill**: `backend-controller` — scaffold the surface, or write the
  marker: silence is not a decision.

### DEV-API-025 — Routed GET actions declare a response
- **Severity**: err, ok otherwise. Deterministic (`--rules DEV-API-025`).
- Check: every `[HttpGet("<route>")]` custom method (standard `{id:guid}` /
  `lookup` excluded) must NOT `return NoContent()` in its window — a READ whose
  computed value nobody receives (`driver-at`, `mileage-forecast`, `history`:
  the names say a value is expected).
- The generation chain now REFUSES the shape (lib/page-spec-actions superRefine
  + scaffold-controller/business validates; authoring gate = PRD-124) — this
  rule catches the LEGACY controllers already on disk.
- **fixSkill**: `backend-controller` — declare the pagespec `responseDto`, then
  re-run derive-action-specs + scaffold-controller + scaffold-business.

### DEV-API-026 — Controller-injected services resolve in the container

The leg NO other gate exercises. The generated controllers inject
`I{X}Service` directly (no MediatR indirection for the service itself), and
the two halves are only tied together at runtime: the build is green, the
audits are green, the unit tests are green — and every request to the
controller answers 500 (`InvalidOperationException: Unable to resolve service
for type 'I{X}Service'`). Two whole modules shipped that way.

- **Severity**: err, ok otherwise.
- Check (deterministic, `--rules DEV-API-026`):
  - (a) every `private readonly I{X}Service` a `**/Controllers/**/*Controller.cs`
    declares has a matching `AddScoped<…I{X}Service…>` in some DI host
    (`DependencyInjection.cs` / `ServiceCollectionExtensions.cs` /
    `InfrastructureModule.cs` / `Program.cs` — `global::`-qualified types
    tolerated);
  - (b) when the client Application assembly carries `Handlers/` or
    `Validators/`, a `RegisterServicesFromAssembly` scan exists — the
    platform's `AddSmartStack()` scans ONLY its own assembly, so unscanned
    client handlers are invisible to MediatR.
- **ok**: label=`DEV_API_026_ok` (params `{ note }` when no injection exists)
- **err**: label=`DEV_API_026_err`, params=`{ iface, controller, hosts }`
- **fixSkill**: `backend-business-layer` — re-run `scaffold-business` for the
  entity: it lands the `AddScoped` in the `BUSINESS-SERVICES-DI` marker block
  and the `CLIENT-APPLICATION-ASSEMBLY-DI` scan idempotently (hand-written
  registrations outside the block are honoured). Runtime twin: the generated
  `DiResolutionTests` (scaffold-tests, Phase 4 gate) resolves every controller
  from the real container.

### DEV-API-027 — Core-lookups bridge matches the socle version

The bridge lifecycle gate of `scaffold-core-lookups`, enforced BOTH ways —
the constant `MIN_SOCLE_CORE_LOOKUPS_VERSION` (`lib/socle-version.ts`) is the
single flip when the socle ships its own `/api/core/*/lookup`.

- **Severity**: err, ok otherwise.
- Check (deterministic, `--rules DEV-API-027`):
  - socle >= floor AND a `CoreLookupsController.cs` bridge on disk → err
    (`direction: retire` — the socle's own routes AmbiguousMatch the bridge's
    at startup);
  - socle < floor, NO bridge, and the module's pagespecs reference core
    catalogues (`"module": "core"` or an `/api/core/` endpoint) → err
    (`direction: scaffold` — every core FK control renders empty, the
    generated hooks swallow the 404).
- **ok**: label=`DEV_API_027_ok` (params `{ socle, bridgePresent, coreFkUsed }`)
- **err**: label=`DEV_API_027_err`, params=`{ socle, direction }`
- **fixSkill**: `backend-core-lookups` — `mode: 'remove'` to retire (marker-
  guarded: a hand-written controller is never touched), default mode to
  scaffold.

### DEV-API-028 — Scheduled use cases land their complete runtime

The triple-presence gate behind `derive-job-specs` (the SAME derivation — one
source, no re-guess). The class it closes: 4 scheduled UCs, emission entities
migrated, and NOTHING ever wrote them — 26 acceptance criteria on a runtime
nobody generated.

- **Severity**: err per incomplete scheduled UC, ok otherwise (incl. modules
  with no scheduled UC).
- Check (deterministic, `--rules DEV-API-028`) — for every scheduled UC of the
  module, all three legs exist:
  - (a) the `Run{X}Async` service method (scaffold-business `scheduledJobs[]`);
  - (b) the `AddSmartStackRecurringJob("{jobId}", …)` line in `Program.cs`
    (RECURRING-JOBS marker — never Hangfire's static API);
  - (c) the manual `POST jobs/{slug}/run` trigger on the integration
    controller (permission `.execute`, `?date=` replay).
- **ok**: label=`DEV_API_028_ok` (params `{ count }`)
- **err**: label=`DEV_API_028_err`, params=`{ ucCode, jobId, missing }`
- **fixSkill**: `backend-business-layer` — run `derive-job-specs`, splice
  `report.jobs` as `scheduledJobs[]` into the owning entity's scaffold-business
  AND scaffold-controller specs, re-run both.

### DEV-API-029 — Global-search seam actually filled

The search engine is **fail-closed by design**: client extension entities are
invisible to `GET /api/search` until registered in `AddExtensionSearch<>` —
there is no auto-scan of the `extensions` schema. So an untouched
`<<< EXTENSION-SEARCH-DI >>>` marker block (the `ss init` template, one
commented example) produces no error, no warning, nothing: a typed plate just
returns 0 results. On the incident project the two seams armed with an `err`
rule (DEV-API-020, 022) were filled and the four prose-only seams all stayed
empty — this rule arms this one.

- **Severity**: err per unregistered list entity, ok otherwise (incl. modules
  with no list pagespec).
- Check (deterministic, `--rules DEV-API-029`): every entity carrying a
  `view: list` pagespec has an ACTIVE (non-commented) `search.Entity<…>`
  registration in the backend's `AddExtensionSearch` block. Commented template
  lines never count.
- **ok**: label=`DEV_API_029_ok` (params `{ count, registered }`)
- **err**: label=`DEV_API_029_err`, params=`{ entity, listEntities, registered, markersFound, file }`
- **fixSkill**: `backend-data-layer` — assemble the spec with
  `scaffold-extension-search/build-spec.ts` (sections + permissions + list
  screens + the entities' `dataScopes`), then run the CLI against the markers.
  A row-scoped entity (own/assigned) must carry its derived `.RestrictTo(...)`
  (`rowScope` in the spec), never be skipped.

### DEV-API-030 — No unpopulatable entity

The 3rd member of the "entities nobody can exercise" family — and the one its
two cousins cannot see: unlike a controller without POST detected structurally
or an entity without pagespec, the incident entity had EVERYTHING (pagespecs,
controller, service, tests) and its table stayed at 0 rows FOREVER: the API
exposes no create (the BA orders it — "les 9 types sont fixés par le métier"),
and nothing seeds the fixed rows. Every UC precondition ("le type d'alerte
existe") is unsatisfiable while every acceptance criterion passes green.

- **Severity**: err per unpopulatable entity, ok otherwise.
- Check (deterministic, `--rules DEV-API-030`): every pagespec entity WITH a
  controller has at least ONE population path:
  - (a) a bare `[HttpPost]` create endpoint on either stratum;
  - (b) a seed provider writing it (`Set<{Entity}>()` in a
    `*SeedDataProvider.cs` — the scaffold-seed `referenceData[]` output from
    the entité.md `**Valeurs initiales**` table);
  - (c) a DECLARED feeding path — pagespec
    `"rowsCreatedBy": ["<ParentEntity>.<actionCode>", …]`, each entry verified
    against the parent's own pagespec actions (DEV-API-010 then guarantees the
    backend half). This keeps the legit satellites (rows created by the
    parent's immobilize/suspend/… actions) out of the findings — the criterion
    is never "no POST", it is "no POST AND no declared feeding path".
  Entities with no controller at all are skipped (DEV-API-024's territory).
- **ok**: label=`DEV_API_030_ok` (params `{ count }`)
- **err**: label=`DEV_API_030_err`, params=`{ entity[, rowsCreatedBy, broken] }`
- **fixSkill**: `backend-seed-data` (fixed rows) / `ba-create-prd` (declare
  `rowsCreatedBy` or the create action).

### DEV-API-031 — Declared unique indexes actually emitted

The §27 class: the BA declared 23 unique indexes; the 16 scalar ones were all
emitted, the 7 FK-bearing ones ALL silently dropped — the FK column was
synthesized without `unique` and composites were not representable. The BA had
written WHY it mattered ("l'index unique est ce qui empêche physiquement la
double notification que BR-009 interdit") — that support did not exist, and
the app-layer AnyAsync that remained is racy and blind to soft-deleted rows.

- **Severity**: err per dropped declared unique, ok otherwise.
- Check (deterministic, `--rules DEV-API-031`): every `**Index** … (…) unique`
  group of `entité.md` has a matching `HasIndex(...).IsUnique()` line in the
  entity's `*Configuration.cs` carrying EVERY declared column (tenant-composite
  variants count). Non-unique declarations are out of scope (EF auto-indexes
  FK columns). Entities without a Configuration on disk are skipped (surface
  rules cover that).
- **ok**: label=`DEV_API_031_ok` (params `{ count }`)
- **err**: label=`DEV_API_031_err`, params=`{ entity, fields, file }`
- **fixSkill**: `backend-data-layer` — re-run scaffold-entity carrying the
  declaration (`relations[].unique: true` for a single FK column, `indexes[]`
  for composites), regenerate the migration; deduplicate existing rows FIRST
  when the table holds data.

### DEV-API-032 — Tenant isolation actually mounted

The cross-tenant-read class: extension entities carry NO automatic tenant
filter — the socle filters only the Core V1 whitelist, and the generated
entity configuration only emits the anonymous soft-delete filter. Every
generated read (`Set<T>().AsQueryable()` in the services, the hand-written
`GetForListScreenAsync` of Phase 2b) therefore returns EVERY tenant's rows
unless the named "Tenant" filter is mounted in `ExtensionsDbContext` — the
socle helpers (`ApplyNamedStrictTenantFilter` / `ApplyNamedOptionalTenantFilter`)
exist precisely for this and used to never be called.

- **Severity**: err per unmounted entity / per FindAsync service, ok otherwise.
- Check (deterministic, `--rules DEV-API-032`), two legs:
  1. every Domain entity implementing `ITenantEntity` / `IOptionalTenantEntity`
     has its matching `ApplyNamed{Strict|Optional}TenantFilter<…{Entity}>` line
     in `ExtensionsDbContext` (scaffold-entity maintains it between the
     `<<< TENANT-FILTERS >>>` markers; the commented template placeholder never
     counts; the flavour must match the interface);
  2. no `{Entity}Service.cs` calls `.FindAsync(` — FindAsync bypasses EVERY EF
     query filter (tenant, DataScope, soft delete), so update/delete would stay
     cross-tenant even with leg 1 mounted (scaffold-business emits
     `FirstOrDefaultAsync(x => x.Id == …)`).
- **ok**: label=`DEV_API_032_ok` (params `{ count, note }`)
- **err**: label=`DEV_API_032_err`, params=`{ entity, flavour, file }` (leg 1)
  / `{ entity, leg: 'findAsync' }` (leg 2)
- **fixSkill**: `backend-data-layer` (re-run scaffold-entity — mounts the
  filter line) / `backend-business-layer` (re-run scaffold-business — replaces
  FindAsync). This rule is the CATCH-UP gate for apps generated before the
  tenant-filter seam existed.

## CLI mode — Phase 2 gate of `/ba-develop`

`audit-dev-api` ships a deterministic CLI sibling that implements the rules
that need cross-source verification — the pagespec ↔ controller ↔ service ↔
rbac.md checks (DEV-API-008, 009, 010..032):

```
npx --prefer-offline tsx skills/development/audit-dev-api/cli/audit-dev-api/index.ts \
  --project-path "<dotnet-root>" \
  --module-path  "<absolute path to .smartstack/ba/<APP>/<MODULE>>" \
  --module-code  "<MODULE>" \
  --app-code     "<APP>" \
  --mode audit
```

The CLI loads every `pagespecs/*.md` of the module + every `*Controller.cs`
of the backend, then cross-references them:

| Rule | Severity | What it checks |
|---|---|---|
| DEV-API-008 | err | No `// TODO[BR-…]` survives in any generated validator/service (the legacy guard stub `// Guard BR-x — TODO:` counts — it used to escape the marker regex while its substring satisfied the trace), AND every `BR-…` id declared in the module's pagespecs (`linkedBusinessRules[]`) has a word-bounded `// BR-…` trace **in THIS module's files** (path-segment scoped — BR codes are doc-scoped, another module's BR-001 never satisfies this one; strict, fail-closed like DEV-API-032), AND — the catch-up leg — a module whose `règles-métier.md` declares enforceable **and non-exempt** rules (err/warn minus `ruleExemption` — numbering/access/`**Enforcement**` opt-out have a REAL other channel; counting them made the err unhealable on all-exempt modules and pressured hand-written implementations) with ZERO `linkedBusinessRules` anywhere is an err `no-rules-declared` (heal = `create-prd/cli/derive-rule-links --mode backfill`), never an ok at count 0. |
| DEV-API-009 | err | No `NotImplementedException` / `// TODO[UC-…]` survives in any generated service — every non-canonical custom action has an implemented body. |
| DEV-API-010 | err | Every `pagespec.actions[]` `kind:api` ∧ `code ∉ STANDARD_CRUD` has a matching `[HttpVerb("<expected-route>")]` on the **integration** `<Entity>Controller.cs` |
| DEV-API-011 | warn | Reciprocal — no non-CRUD integration controller endpoint without a pagespec backing |
| DEV-API-012 | err | Every pagespec view ∈ {list, detail, form} has its expected endpoint on the **screen-driven** `{EntityPlural}ScreenController.cs`. Skipped when no screen controllers exist yet (Phase 2b not run). |
| DEV-API-013 | err | No `// TODO[SCREEN-…]` markers remain in any `Controllers/.../Screens/*.cs` |
| DEV-API-014 | err | When an entity has BOTH an integration and a screen controller, both inject the SAME `I{Entity}Service` (no parallel service forks) |
| DEV-API-015 | warn | Every pagespec column with a Core-projection `source` block is read through the navigation (`.{nav}.{property}` token) in the entity's `{Entity}Service.cs` — never an invented local column |
| DEV-API-016 | err + warn | Every integration `<Entity>Controller.cs` honors the frontend contract: has `[HttpGet("lookup")]`, returns a paginated list (not a bare `IReadOnlyList<…>`), has no unexpanded `[controller]` route token, and carries the `@generated-by scaffold-controller` marker. Catches a hand-written backend that 404s lookups / empties tables. Additionally WARNS (`lookup_gate`) when a `/lookup` endpoint lacks the v3.62 dual gate `[RequirePermission(X.Lookup, X.Read)]`. |
| DEV-API-017 | err | Every list-query service method (`GetAllAsync`, `GetForListScreenAsync`) paginates SERVER-side — `Skip`/`Take` + `CountAsync`. Catches a hand-written screen query that materialises everything and lets the browser paginate. |
| DEV-API-018 | warn | Every `kind:api` non-GET action with a `payloadDto` but no `payloadParameters[]` — the UI sends no body (optional-body safety net applies). Nudges the BA to add the fields when the body is truly required. |
| DEV-API-019 | err | Every Guid FK relation filter spans the triplet: controller `GetAll` `[FromQuery] Guid?` + forward, `Get{Plural}Query` `Guid?` record param, `GetAllAsync` is-not-null-guarded `Where` BEFORE `CountAsync`. FK set = Domain entity Guid FKs (SSOT `fkFilterFields`) ∪ FKs on any leg. Backend half of the 360 view (DEV-UI-031 is the frontend half). |
| DEV-API-020 | err | Every module entity whose `rbac.md` plans a scoped `read` (Portée `les siennes`/`own`/`attribuées`/`assigned`) actually enforces row scoping across the four legs: `{Entity}ScopePolicy.cs`, `ApplyDataScopeFilter(…{Entity}ScopePolicy.Instance)` in the DbContext, `AddSingleton<IDataScopePolicy>(…)` in DI, and the DbContext ctor forwarding `ICurrentUserAccessor`. Also **reversed err** on any `[RequireDataScope(typeof(<extension entity>), …)]` (Core-only guard). |
| DEV-API-021 | err + warn | Every `const string` value in the `{Mod}Permissions.{Section}.cs` files exists VERBATIM as a `path` tuple in the SAME app's `*CorePermissionsSeedDataProvider.cs` (constants ⊆ seed; the reverse is legitimate — derived lookup grants, `.read.all` tiers). Dedicated err when the constant is the 3-segment missing-app bug signature (fix = re-run scaffold-controller); err when the app has no seed provider at all; warn on the legacy layout when the owning app cannot be deduced. |
| DEV-API-022 | err | Every entity declaring `- **Code pattern** : …` in `entité.md` has the coded-entities seam fully in place across the four PRESENCE legs: `ICodedEntity` on the Domain class, `AddSmartStackCodeKey<{Entity}CodeKeyDescriptor>` in the DI, `IServiceProvider` forwarded by the DbContext ctor (without it allocation is SILENTLY skipped — empty Codes, no log), and a unique index on `Code` in the EF Configuration. PLUS six COHERENCE legs when the descriptor file exists (the BA line parsed via `lib/code-pattern-grammar.parseCodePatternLine` — a facet compares ONLY when authored on both sides, free prose never false-errs): `format-mismatch` / `scope-mismatch` / `reset-mismatch` / `gapless-mismatch` (an agent degrading `Yearly → None` to appease validateFormat used to ship green with a functionally wrong numbering), `descriptor-key-mismatch` (entity `CodeKey` ≠ descriptor `Key` — the registry never resolves, allocation fails), `code-inputs-empty` (derived-token format + the empty `GetCodeInputs()` literal — the pre-C5 emission; heal = re-run scaffold-entity with `codedEntity.format`), `suffix-no-probe` (`CollisionStrategy.Suffix` + mono-generic DI — the v3.67 engine refuses probe-less Suffix allocations). |
| DEV-API-023 | err | Reciprocal of DEV-API-018: every action collecting `payloadParameters[]` lands on a controller method that transports them — `[FromBody]` on non-GET verbs, `[FromQuery]` on GET — otherwise the generated `CustomActionDialog` collects values the backend has nowhere to receive (silent input loss; the pre-synthesis chain produced exactly this drift). Missing endpoints stay DEV-API-010 findings. |
| DEV-API-024 | err | Every entité.md entity has a controller on some stratum, an explicit `**API** : none` marker, or an exempt junction/component classification — closes the "stockable et ni lisible ni écrivable" hole (PRD-084 guarantees the domain, nothing guaranteed the surface). |
| DEV-API-025 | err | No routed `[HttpGet("…")]` returning `NoContent()` — a read whose computed value nobody receives; the generation chain refuses the shape, this catches legacy controllers. |
| DEV-API-026 | err | Every controller-injected `I{X}Service` is AddScoped-registered in a DI host, and a client Application assembly carrying Handlers/Validators is scanned (`RegisterServicesFromAssembly`) — the container leg no build/audit/unit test exercises (green gates, every request 500). Heal = re-run `scaffold-business` (marker blocks `BUSINESS-SERVICES-DI` / `CLIENT-APPLICATION-ASSEMBLY-DI`). |
| DEV-API-027 | err | Core-lookups bridge ↔ socle version, both ways: socle >= `MIN_SOCLE_CORE_LOOKUPS_VERSION` with a bridge on disk = retire it (AmbiguousMatch); socle below the floor with core FKs in the pagespecs and no bridge = scaffold it (`scaffold-core-lookups`). |
| DEV-API-028 | err | Every scheduled UC lands its runtime triple: `Run{X}Async` service method + `AddSmartStackRecurringJob("{jobId}")` in Program.cs + `POST jobs/{slug}/run` trigger — otherwise the emission entities exist and nothing ever writes them. |
| DEV-API-029 | err | Every entity with a `view: list` pagespec has an ACTIVE `search.Entity<…>` registration between the `<<< EXTENSION-SEARCH-DI >>>` markers — the engine is fail-closed by design (no auto-scan), so an empty seam is invisible everywhere else and global search silently returns 0 results on every business entity. |
| DEV-API-030 | err | Every pagespec entity with a controller has a population path: a bare `[HttpPost]` create, a seed provider writing it (`Set<{Entity}>()` — the `**Valeurs initiales**` output), or a verified pagespec `rowsCreatedBy` feeding action. An entity with none is UNPOPULATABLE — its table stays at 0 rows forever while every AC passes green. |
| DEV-API-031 | err | Every `**Index** … unique` declared in entité.md has its `HasIndex(...).IsUnique()` in the entity's EF Configuration (tenant-composite variants count) — the FK-bearing declared uniques used to be dropped silently, shipping the uniqueness rule with no SQL net. |
| DEV-API-032 | err | Every `ITenantEntity`/`IOptionalTenantEntity` Domain entity has its matching `ApplyNamed{Strict\|Optional}TenantFilter<…>` line in ExtensionsDbContext (the named "Tenant" filter — extension entities carry NO automatic tenant filter), and no generated service calls `.FindAsync(` (bypasses every query filter). Without both legs, reads/updates/deletes are CROSS-TENANT. |
| DEV-API-033 | err | Every public HTTP-routed controller action (`[HttpGet/Post/Put/Patch/Delete]`) carries `[RequirePermission(...)]` in its CONTIGUOUS attribute block (or class-level), or an EXPLICIT `[AllowAnonymous]` — `[Authorize]` alone is authentication, not authorisation. Port of roslyn SS004 into the gate: an unguarded endpoint is open to every authenticated user and the UAT plan then EXPECTS 200 for all roles (the oversight gets certified, never caught). Supersedes the conversational DEV-API-004 (warn) with a blocking, block-scoped scan — no ±5-line window a neighbouring guarded action could satisfy. |
| DEV-API-034 | err | No hand-rolled code generator survives in the C# — the socle allocates every business code atomically and gaplessly at insert (`CodedEntitySaveHandler` / `core.seq_Sequences`); a client generator races under concurrency, collides across tenants and bypasses the admin Code patterns screen. Three legs: **method-generator** (a `Generate/GetNext/Next/Allocate/Compute/Build…Code/Number/Numero/Reference/Sequence/Matricule` method — strong alone), **counter-storage** (a `DbSet<T>` matching the `code-generation` capability triggers of `lib/capability-catalog.ts`, or a `*Domain` property named in its attributeTriggers — `NextValue`, `LastNumber`, …), **max-scan** (`Max()`/`OrderByDescending()` over a `Code/Number/Numero/Reference` member AND an increment/format signal — `+ 1`, `PadLeft(`, `int.Parse(`, `ToString("Dn")`, `:0000` — within ±15 lines; a display sort or a Max over an Amount never flags alone). Comment lines stripped; `Migrations/`, `bin/obj`, `*Tests*`, `*CodeKeyDescriptor.cs` and files using the sanctioned `ICodeUniquenessProbe`/`ISuppliedCodeGuard` seams excluded. Heal = declare the `**Code pattern**` (DM-017), scaffold both halves (DEV-API-022 verifies), DELETE the generator — this is the catch-up gate for apps whose agents "improvised" numbering before the chain was closed. |

The report is written to `<backendPath>/_audit/dev-api-<module>.md` and
emitted on stdout as a JSON envelope. **The orchestrator (`/ba-develop` Phase 2 gate)
invokes this CLI automatically.** A non-zero `err` count blocks the phase with a
precise message (`<Entity>:<VERB>:<expectedRoute>` missing on controller).

Other DEV-API rules (001-007) remain conversational — Claude reads / Greps
via the rule definitions above. They do not need a deterministic CLI because
they verify structural conventions (one controller per entity, DTO triplet, …)
that the scaffolders already guarantee by construction. Exception: DEV-API-004
(RBAC attribute on writes) is SUPERSEDED by the deterministic DEV-API-033 —
never apply 004 conversationally when the CLI runs.
DEV-API-008/009 (business-rule / use-case enforcement), DEV-API-020
(data-scope enforcement), DEV-API-021 (permission constants ⊆ seeded grants),
DEV-API-022 (coded-entities seam), DEV-API-029 (global-search seam) and
DEV-API-034 (no hand-rolled code generator) are
**now deterministic** in the CLI
above — a surviving `// TODO[BR-…]`/`// TODO[UC-…]`/`NotImplementedException`,
an untraced business rule, an unenforced scoped read, an unseeded permission
constant, a planned Code pattern whose seam is incomplete, a list entity
absent from the `AddExtensionSearch` registrations, or a hand-rolled code
generator next to the socle's allocator is a hard
`err` that blocks the Phase 2a/2b gate.

## Output

Emit EXACTLY ONE JSON code block matching the standard `auditReport`
envelope. Set `dimension` to `devApi` on every finding. Include one
`ok` finding per passing rule and one finding per failing rule.

```json
{
  "auditReport": {
    "scope": "devApi",
    "applicationCode": "<from PRD>",
    "moduleCode": "<from PRD>",
    "findings": [
      {
        "dimension": "devApi",
        "code": "DEV-API-002",
        "severity": "err",
        "label": "DEV_API_002_err",
        "params": { "actions": "BudgetController.Delete,BudgetController.Update" },
        "solution": "Re-run the API phase. Add the missing [HttpDelete] / [HttpPut] methods on BudgetController.",
        "fixSkill": "backend-controller",
        "fixPhaseKey": "api"
      }
    ]
  }
}
```

Stop immediately after the JSON block. Do not narrate.
