Skip to content

Latest commit

 

History

History
578 lines (486 loc) · 31.7 KB

04-api-design.md

File metadata and controls

578 lines (486 loc) · 31.7 KB

04 — API Design Standards

Status: Active Derives from: ADR 0002 — Initial Architecture, ADR 0003 — Tenant Isolation Defense in Depth, ADR 0024 — API Versioning Policy, ADR 0036 — Trusted Inputs for Tenant and Organization Resolution, ADR 0037 — What an Idempotency Key Identifies, Owns, and Replays, ADR 0039 — The Optimistic Concurrency Token. Public-read additions derive from ADR-0052; anonymous accounting derives from ADR-0054.

REST conventions for LearnStack public and admin APIs.

Style

  • REST first. GraphQL only after a clear product driver.
  • Resources are plural nouns (/courses, /users).
  • HTTP verbs used idiomatically: GET read, POST create, PUT replace, PATCH modify, DELETE remove.
  • JSON, application/json, UTF-8.
  • URL versioned: /api/v1/... (canonical prefix per ADR-0024).
  • Bodies use camelCase.

URL Structure

/api/{version}/{resource}/{id?}/{sub-resource?}

Examples:

  • GET /api/v1/courses?cursor=...&limit=20
  • POST /api/v1/courses
  • GET /api/v1/courses/{id}
  • PATCH /api/v1/courses/{id}
  • POST /api/v1/courses/{id}/versions

Platform-admin endpoints live under /api/v1/platform/... and require platform-admin scope.

P02d-4 Accepted contract — 2026-10-03. Steps 2–3 deliver all four GET/HEAD read pairs; Step 4 delivers the contract/SDK controls. Institution reads use /api/v1/public, separate from future authoring ID routes: /site, /courses, /courses/{slug} and /courses/{slug}/lessons/{lessonSlug}. Each has explicit GET and HEAD; Education routes require query locale. Response/continuation contracts are the accepted packet under ADR-0052.

Versioning

The full versioning policy lives in ADR-0024 — API Versioning Policy. Summary so reviewers don't have to chase a link to know the shape:

  • URL-based under /api/v{N}/ (the only canonical public route shape; matches APISIX gateway routes per ADR-0015). Header-based versioning is not used.
  • Two adjacent majors coexist; deprecation window is 6 months.
  • /healthz and /readyz are unversioned infrastructure endpoints.
  • The internal /api/internal/* Hub contract has its own versioning per ADR-0019 and is not governed by ADR-0024.

For the breaking/non-breaking matrix, the exact header set (Deprecation per RFC 9745, Sunset per RFC 8594, Link: rel="successor-version"), OpenAPI extensions (x-sunset / x-successor / x-migration-guide / x-extensible-enum), the 410 Gone Problem Details shape, and per-deployment-mode behaviour — see ADR-0024 directly.

Status Codes

Code Use
200 Success with body
201 Created; Location header set
202 Accepted for async work
204 Success, no body
400 Client validation failure
401 Missing/invalid authentication
403 Authenticated but not authorized
404 Not found (also used to hide cross-tenant existence)
405 Method not allowed; the Allow header lists the methods that are permitted
409 Concurrency or business-rule conflict
413 Request body over the configured limit
410 Permanently gone
415 Unsupported media type
422 Semantic validation failure (rarely needed; prefer 400)
429 Rate limited; Retry-After set
500 Server bug; correlation id in body
503 Dependency unavailable; Retry-After set

Error Responses

All errors use Problem Details (RFC 7807), in exactly one shape, served as application/problem+json; charset=utf-8. One spelling, deliberately: two — one with the charset and one without — made a routing 404 tellable from an MVC 404 without reading the body, which ADR-0036 requires not to be possible. 09-error-handling.md § API Surface is the authority for its fields; the example there is canonical and is not duplicated here.

The rule is one shape for every error the application can see. It does not reach the two the server rejects before any middleware runs: an over-long request line (414) and an over-large header block (431) are refused by Kestrel, with no body at all — and over HTTP/2 the connection is reset with no status. That is a property of where the rejection happens, not an exemption anyone chose; § Request and Response Limits says which bounds those are and why the edge is where such a limit can be given a body.

Two things a reader of this document needs, because both are easy to get wrong:

  • title carries the lockey_* key, not English prose, and there is no detail. The backend never returns raw English — the frontend resolves the key against its i18n catalogue. An earlier version of this section showed "title": "Validation failed" and a detail field; both contradicted Standards 09 and the shipped ProblemDetailsFactory.
  • Every error carries it, including the ones no handler produces. An unmatched route (404), a wrong method (405) and an unsupported media type (415) are framework-level and arrive with no body by default. Packet 4 gives them the same shape, so a client parses one thing.

code is a stable machine-readable string — validation_failed, not_found, method_not_allowed, unsupported_media_type, concurrency_conflict, tenant_mismatch, recording_consent_required, … — and always equals title with the lockey_ prefix stripped, by construction.

Authentication

  • Bearer JWTs issued by Keycloak in Authorization: Bearer <jwt>.
  • Cookie sessions for Next.js apps; cookies are HttpOnly, Secure, SameSite=Lax.
  • Service-to-service uses client-credentials grants with limited scopes.
  • API keys are forbidden in the MVP; if added later, they live in Integrations with rotation + audit.

Tenant Context

ADR-0036 is the single authority for how tenant and organization are resolved. It is not restated here, because the ordered list this section used to carry was one of four incompatible statements in the corpus and reading it as a priority chain produced the wrong answer.

The two rules an API author needs at the point of writing an endpoint:

  • Resolution is by agreement, not priority. Every authoritative signal present on a request — the host lookup result, the validated JWT claims, live membership — is resolved independently, and the request proceeds only on their intersection. Two authoritative signals that disagree do not produce a winner; they produce a 404.
  • No request input selects a tenant. Not a body field, not a query param, not a cookie, and not a header. X-Tenant-Id and X-Organization-Id are assertions: they are compared against the resolved value and can only cause the request to be rejected.

A request that cannot resolve a tenant returns 404 (not 403, to avoid disclosure).

Public surface

[PublicSurface] marks a request type as reachable by a caller LearnStack has not authenticated — the Portal Public role, made mechanical. The marker is the whole of the claim under a host-only context: a request type without it is unreachable from HostOnly, whatever its route looks like. Rows 13 and 15 of ADR-0036's reconciliation matrix are the separate case — no tenant context resolves at all, and [AllowsUnresolvedTenantContext] governs them.

  • TenantContextOrigin is the ceiling. A context resolved from the host alone carries HostOnly and reaches only [PublicSurface] request types; TenantContextBehavior at pipeline step 4 (02-backend-coding.md § Pipeline Behaviors) rejects anything else — failing with lockey_not_found, so the body it renders is byte-identical to the one an unresolvable host gets, because anything a client can tell apart confirms to an anonymous caller that the tenant exists. Not lockey_tenant_mismatch: a HostOnly context is anonymous by construction and tenant_mismatch is the authenticated code. The mechanism differs and the wire result must not — host classification writes a bodyless 404 that UseStatusCodePages fills in through ProblemDetailsFactory.ForStatus(404), while a step-4 failure carries its own Problem Details body through ProblemDetailsActionResult; since HttpStatusMap.CanonicalCodeFor(404) is not_found and Error.Code strips the lockey_ prefix, both carry the same type, title, status, code and messageKey.

    What indistinguishability covers, precisely, and what it does not. It covers the paths the ceiling controls: on the same path, with the same method, a live tenant host and an unknown one produce byte-identical responses, and nothing anywhere names which tenant. It does not extend to responses routing produces before the pipeline runs — a method no action accepts is a 405 on a host that resolved and the shared 404 on one that did not. That is measured and deliberate rather than an oversight: the only hosts reaching routing are ones the resolver admitted, and it admits a row only under is_active AND is_publicly_live, which ADR-0036 defines as DNS pointing at LearnStack and the tenant's public site being served. A host that is not publicly live resolves to nothing and answers the unknown-host 404 here too, so the bit disclosed is exactly the one that is public by definition — and once the table above has a row, a plain GET discloses it more directly by returning 200. A_Disallowed_Method_Discloses_Only_That_The_Host_Is_Publicly_Live pins the boundary so a later reader does not have to re-derive it; two independent reviews of this mechanism reached opposite conclusions about it, which is why it is written down.

    A platform host is separable from both, by code rather than status: an unmarked request there resolves no tenant and fails gate 1 with tenant_mismatch, where a tenant host under the ceiling and an unknown host both carry not_found. That discloses membership of Tenancy:PlatformHosts — a short static list an operator publishes as its own entry point — and nothing about any tenant.

  • Permitted methods default to GET / HEAD. An entry declaring a mutating method states why, in the table.

  • No [PublicSurface] type performs a tenant-owned write.

  • No [PublicSurface] type is classified MUST-class read-sensitive (18-audit-coverage.md) — an anonymous GET would otherwise become a durable standalone audit write.

ADR-0036 § The reconciliation matrix is the authority for why the ceiling holds and what a forged host reaches under it. The matrix is not restated here.

P02d-4 public reads delivered — 2026-10-04. Steps 1–3 implement host admission, read-only execution and all institution read endpoints/guards. All institution public reads are marked requests dispatched through ISender, GET/HEAD only and audit Off. A matching factory HostScope independently narrows normal context/filter/RLS reads; credentials never widen it. Physical ambient reads are READ ONLY under ADR-0052 and ADR-0040 Amendment 8. Controller/helper direct persistence is forbidden. Structural bypass detection complements real HTTP/app-role database proofs; the runtime barrier protects only the enlisted transaction. Rejected-assertion audit retains its independent sanctioned path.

The catalogue records implemented Off, host-provenance, dispatch, controller and read-only and no-schema-evaluation guards with planted controls. Education eligibility has HTTP/database proof; Step 4 adds complete OpenAPI/SDK controls.

The set is this table and nothing else:

Request type Permitted methods Why Owning phase
GetPublicSiteQuery GET, HEAD Anonymous host-resolved institution bootstrap, audit Off and READ ONLY P02d-4 Step 2
GetPublicCoursesQuery GET, HEAD Exact-locale host-scoped marketing catalog, audit Off and READ ONLY P02d-4 Step 3
GetPublicCourseQuery GET, HEAD Eligible marketing and public-only outline, audit Off and READ ONLY P02d-4 Step 3
GetPublicLessonQuery GET, HEAD Eligible public-parent content, audit Off and READ ONLY P02d-4 Step 3

All four rows are production requests with explicit GET/HEAD endpoints. PublicSurface_Marker_Set_Is_Enumerated and PublicSurface_Requests_Are_Never_ReadSensitive (21-architecture-tests-catalogue.md) now guard a non-empty set. The separate PublicSurface_Requests_Are_Registered_Off refuses every non-Off or unclassified public request.

Adding a row here is half of an edit. The rule reads this table in both directions, so a row naming a request type that does not carry [PublicSurface] fails the build: an entry here reads as a reviewed decision, and one with no attribute behind it is a decision the pipeline never enforces. PublicSurface_Requests_Are_Never_ReadSensitive cross-checks the merged IAuditCatalog and fails any marked request registered as MUST-class read-sensitive. Its companion proves the forbidden classification is detected even when all shipped requests are correctly Off.

Pagination

Cursor pagination by default:

GET /api/v1/courses?cursor=eyJpZCI6Li4ufQ&limit=20

Response:

{
  "items": [...],
  "pageInfo": {
    "nextCursor": "eyJpZCI6Li4ufQ",
    "previousCursor": null,
    "hasNext": true,
    "hasPrevious": false
  }
}

Rules:

  • limit default 20, max 100. A larger limit is clamped to 100, not rejected — one behaviour, decided once in CursorPagination and not re-decided at the edge. A limit of zero or less is rejected, with errors.limit naming the parameter the client sent.
  • Cursor is opaque to clients. P02d-4 accepts a bounded resource-local v1 keyset codec for catalog and outline, context-bound to endpoint, host digest, tenant, host organization, canonical locale and order, plus outline parent. It is keyless and makes no confidentiality, MAC or snapshot promise. Invalid encoding/shape/ scope is 400 validation_failed naming the sent cursor parameter. Forward-only PageInfo keeps previousCursor null and hasPrevious false. The accepted pagination contract owns exact payloads, bounds and seek behavior; P02d-4 Step 3 implements the codec.
  • Offset pagination is allowed only for admin-bounded lists (≤ 10k total rows).

Filtering and Sorting

  • Filter via query params: ?status=published&level=B1. Filters are resource-specific and each endpoint declares its own — the names and the value sets belong to the resource, so there is no generic filter type. Declaring them as parameters is what documents them in OpenAPI.
  • Sort via sort=field or sort=-field (prefix - for descending).
  • Multiple sort keys: sort=-publishedAt,title. The order is priority order and is preserved.
  • Search via q=... for free text. No separate length cap: the URL bound in § Request and Response Limits is the host's request-line limit, and a third bound agreeing with neither it nor the gateway's would be worse than inheriting whichever actually rejects an over-long URL.

The sort grammar is enforced by SortSpecification, and its edges are decided rather than left to each endpoint:

  • A field is one or more dot-separated segments of ASCII letters and digits, each starting with a letter — the camelCase shape § Style fixes, with a dot for a nested path — and at most 64 characters. Anything else is 400.
  • A permitted field comes back in the allow-list's spelling. The match is case-insensitive, so ?sort=PublishedAt is accepted; handing a handler back a spelling the endpoint never declared is how a field name that reaches a switch or an OrderBy breaks on a casing nobody tested.
  • An empty segment (title, or a,,b), a bare -, or the same field twice is 400. Each is a typo, and accepting one silently drops or reorders a key the client believes it asked for.
  • At most four keys. A sort is a query plan and each key is an index decision; unbounded, a client composes an arbitrarily expensive ordering.
  • A well-formed field the endpoint does not permit is also 400, naming the field. Parsing and authorising are separate steps: the endpoint owns the allow-list, and silently ignoring an unpermitted key returns a page in an order the client did not ask for with no way to notice.

Both report under errors.sort — the name the client sent — and the two are deliberately different shapes, because they fail at different times:

  • A grammar failure is a binding failure and answers exactly as one: lockey_invalid_value, no parameters. ?limit=abc and ?sort=title, produce the same body under different keys, which is the point.
  • An unpermitted field is refused by the endpoint after binding succeeded, so it carries lockey_sort_field_not_allowed with the field in params.

Idempotency

Derives from: ADR-0037.

Unsafe operations with external side effects accept an Idempotency-Key header:

POST /api/v1/orders
Idempotency-Key: 01HX7F...

A key is a nonce, not an identity. The stored record is addressed by (tenant, key), and it carries a fingerprint of the organization, the acting principal, the method, the path, the query string and the body. A key presented with a different fingerprint is refused — the client reused it for a different request, and both alternatives (replaying the old answer, or running the new one) fail silently.

Rules:

  • The server stores the outcome for 24 hours and replays it, marked Idempotency-Replayed: true — a client retrying after a timeout otherwise cannot tell whether its second call did the work or collected the first one's answer.
  • A replay is the whole response, not its status line: the body, the content type and every header describing the outcome, including Location and ETag. Headers describing the exchange — framing, Date, Server, Set-Cookie, X-Correlation-Id — are not replayed.
  • The key space is scoped to the tenant. A request with no resolved tenant is refused rather than served from an unscoped space.
  • A key is 8–128 printable ASCII characters with no space. Malformed, absent, or repeated is 400 under errors.idempotencyKey.
  • A concurrent request holding the same key gets 409 request_in_progress — retry with the same key. This is deliberately not concurrency_conflict, which tells a client to re-read and re-submit; one code cannot carry both instructions.
  • A key presented for a different request gets 409 idempotency_key_reuse. Use a new key.
  • A 5xx, a thrown attempt, a 408/425/429, or a response carrying concurrency_conflict / rate_limited / dependency_unavailable releases its key. Each describes a condition rather than an outcome, and pinning one would answer it for the whole window.
  • A response too large to retain (256 KiB, headers included) records a tombstone: the retry gets 409 idempotency_outcome_unavailable rather than re-running an operation that already happened. An endpoint that trips this is a design mistake and is logged as one.
  • When the store is at capacity a new key is refused with 503 dependency_unavailable; an existing key is always served. Nothing unexpired is ever displaced to make room, because displacing a record lets the operation it describes run again.
  • Required for: payment operations, notification sending, recording start/stop.
  • Encouraged for: enrollment creation, invitation sending.

Inbound webhooks do not use this mechanism. A provider cannot be made to send an Idempotency-Key; they deduplicate on (provider, event_id) from the verified payload — see § Webhooks (Inbound).

The guarantee is at-most-once while a claim is live, and at-least-once across process death. The response is recorded before it is delivered, so a client disconnect is not a loss; a process that dies between the operation committing and the record being written releases the key and the retry re-runs. An operation whose duplicate execution is genuinely intolerable needs its own guard inside the business transaction.

Marked per endpoint with [Idempotent], because which operations have external side effects is knowledge the endpoint has and the pipeline does not. The attribute publishes its own OpenAPI contract — the required header and the statuses it can answer before the action runs.

Optimistic Concurrency

Mutable resources expose ETag (or version field).

P02d-4 public exception delivered — Step 2. The public prefix uses no-store on successes and errors; it emits no ETag or Last-Modified and does not honor If-None-Match/304. Mutable authoring resources retain ADR-0039's concurrency contract below. Internal definition caching is separate.

GET /api/v1/courses/{id}        → ETag: "7"
PATCH /api/v1/courses/{id}
  If-Match: "7"

Version mismatch returns 409 with concurrency_conflict — not the 412 RFC 9110 describes for If-Match. That is this corpus's call: HttpStatusMap maps concurrency_conflict to 409 and § Status Codes lists 409 and not 412, so a 412 would put a status on the wire that no error code maps to and the generated SDK has no branch for.

  • Strong comparison, always (RFC 9110 § 13.1.1). A weak tag says "semantically equivalent", and two versions of a row that are semantically equivalent are still two versions — one of which the client did not see.
  • If-Match: * means "whatever version, as long as it exists".
  • A malformed If-Match fails the precondition; it is never treated as absent. Reading "I could not parse your precondition" as "you did not send one" turns a conditional write into an unconditional one — exactly the overwrite the client was preventing.

OpenAPI

  • Generated from code by Microsoft.AspNetCore.OpenApi, not handwritten. Swashbuckle is not used: it ships no document generator for .NET 10, and ADR-0024 § Implementation Notes fixes the built-in generator.
  • One document per live major, at /openapi/v{N}.json — so /openapi/v1.json today. Each document holds only its own major's paths; without that filter an added /api/v2 operation reads as a breaking change to v1 under oasdiff.
  • Reference console at /docs (redirects to /docs/), rendered by Scalar. Microsoft.AspNetCore.OpenApi ships no UI of its own. The console is deliberately not under /openapi: Scalar mounts a {documentName?} catch-all inside whatever prefix it is given, which shadowed the document namespace and answered /openapi/garbage with 200 and an HTML page. Scalar's request proxy is disabled, so its "Test Request" button never routes a LearnStack bearer token through a third party — which is also what makes the console usable under SelfHostedAirGapped.
  • An unknown document name is a 404 in the one error shape. The document route is constrained to the documents actually registered; without that, /openapi/v9.json answered 404 in text/plain with English framework prose.
  • The document is served in every environment, not only Development: it is the contract the SDK generates from and CI diffs, so an artefact no deployed instance serves is not the contract. Restricting who may reach it is an edge concern (APISIX), not an application one.
  • servers is omitted. Its default value is derived from the request's Host, which is client-chosen — so the published contract would vary with the caller, and the oasdiff gate would diff the document against itself.
  • Every operation carries x-version-introduced; ADR-0024 § OpenAPI marking owns the full extension set. deprecated is derived from [Obsolete] and is absent when false — OpenAPI defines an absent deprecated as false, and the serializer omits defaults.
  • TypeScript SDK @learnstack/sdk is generated from this spec; Standards 07 § SDK owns how and when.
  • Breaking OpenAPI changes fail the always-running openapi diff job under ADR-0024. Required-check registration is recorded only after a real run and API read-back, in the packet delivery record.

G31 delivered by Step 4. backend/openapi/v1.json is the sole v1 snapshot, compared against the production served document. Non-empty eight-operation and recursive public DTO controls accompany it. Required frontend CI regenerates the SDK and refuses scoped drift. Pinned CLI fixtures and explicit type/enum policy companions implement representable ADR-0024 rules; runtime tests own validator and status meaning absent from the document. Bootstrap builds and serves the verified base composition; only a base without a snapshot and without v1 operations qualifies. Verification/read failures and deleted head snapshots fail closed. The accepted contract/CI plan owns pins, overrides and the six-check rollout; the delivery record owns execution.

Request and Response Limits

Each row says what enforces it. A published limit that nothing enforces is not a limit, and the first version of this table was four of those.

Limit Value Enforced by
Request body (JSON) 1 MiB RequestBodyLimit middleware, and KestrelServerLimits.MaxRequestBodySize behind it
Request headers, total 32 KiB Kestrel (MaxRequestHeadersTotalSize), server default
Request header count 100 Kestrel (MaxRequestHeaderCount), server default
URL length 8 KiB Kestrel (MaxRequestLineSize), server default
Multipart upload (excluding files) — No endpoint yet; Phase 04
File upload, per content type see architecture/16 § Validation No endpoint yet; Phase 04
Rate limit (anonymous) 60 req/min per canonical IP + 600 req/min per physical peer ADR-0053 / P02d-5 G34 delivers both budgets. Fixed one-minute windows, no queue; no host/cookie partition. ADR-0054 coordinated accounting is implemented by remediation Step 1; both independent review rounds passed. See below
Rate limit (authenticated) 600 req/min per token No token to key on yet; Phase 02b
Rate limit (write endpoints) 60 req/min per token No token to key on yet; Phase 02b

429 responses include Retry-After.

ADR-0054 accounting implemented — 2026-10-09; both review rounds passed. Remediation Step 1 probes an owned known visitor limiter with zero permits and returns its refusal before any peer debit. Successful peer acquisition gates unknown visitor allocation; the visitor is debited only after peer admission. The first refusal supplies unchanged Retry-After, including visitor precedence when both budgets are exhausted. The 600/min budget bounds successful peer acquisitions and new visitor allocations, not all attempts or refusal-response work. Authenticated-hop visitor metadata supplies the canonical IP; direct/untrusted traffic uses its socket peer. Invalid trusted metadata retains peer-IP fallback quota and masked refusal before host/database lookup. Shared direct/hop quotas, request-outcome replay and limiter lifecycle obligations are defined in Security Standards § Rate Limiting.

The body bound is middleware, not only a Kestrel option. TestServer — what the integration suite runs on — implements neither IHttpMaxRequestBodySizeFeature nor IHttpRequestBodySizeFeature, so a Kestrel limit or a [RequestSizeLimit] attribute is silently inert there and no test can tell whether it is wired. The middleware is the authoritative bound because it is the one that can be asserted; the Kestrel option is set to the same number behind it, so an oversized body is refused before it is buffered. A declared Content-Length over the limit is refused without reading anything; a request that declares no length is counted as it is read.

The header and URL rows are server bounds, not application bounds, and they cannot carry the standard error shape. Kestrel rejects an over-long request line or header block before any middleware runs, so a 414 or 431 arrives without the Problem Details body every other error on this surface carries — and over HTTP/2 the connection is reset with no status at all. That is a property of where the rejection happens, not something the application can wrap. The numbers are Kestrel's defaults, written down here so the table describes the running binary rather than an intention. Tightening them is an ADR-0015 / APISIX concern: the edge is where a limit can be enforced and given a body.

File-size limits are owned by architecture/16 § Validation, which carries the per-category numbers and the tenant-override rule. This section and Standards 11 § File Upload link there rather than restating them — the three used to disagree three ways about the same image.

Webhooks (Inbound)

  • Endpoint: /api/v1/webhooks/{provider}.
  • Verifies provider signature with a tenant-scoped or platform-scoped secret.
  • Idempotent: (provider, event_id) stored; duplicates ignored.
  • Returns 200 quickly; heavy work deferred to a job.
  • Never trusts payload tenant id without cross-checking the stored provider account.

Webhooks (Outbound)

  • Signed with HMAC-SHA256; secret rotation supported.
  • Retry with exponential backoff (max 5 attempts) and dead-lettering.
  • Headers: X-LearnStack-Event-Id, X-LearnStack-Event-Type, X-LearnStack-Signature, X-LearnStack-Timestamp.
  • Outbound events documented as a separate OpenAPI schema set.

Health Endpoints

  • GET /healthz — liveness.
  • GET /readyz — readiness (all critical deps reachable).
  • GET /version — build metadata: git sha, build time, version tag.

Public but rate-limited.

Etiquette

  • POST /resources returns the created resource with 201 + Location.
  • PATCH accepts partial JSON; unknown fields → 400.
  • DELETE is idempotent; deleting a missing resource → 204.
  • GET is safe and idempotent; never has side effects.
  • Sort parameters never affect filtering.

Forbidden

  • Tenant id read from a request body or query param.
  • Tenant id selected from a request header. X-Tenant-Id / X-Organization-Id are assertions compared against the resolved value, never a resolution source (ADR-0036).
  • Domain logic in controllers.
  • Returning EF entities directly.
  • Mixing API versions in a single endpoint.
  • Inconsistent casing.
  • Echoing internal error messages to clients.