Skip to content

Latest commit

 

History

History
853 lines (732 loc) · 51.2 KB

04-api-design.md

File metadata and controls

853 lines (732 loc) · 51.2 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. The accepted tenant-authentication contract derives from ADR-0064; P02b-5 Step 4 activates the closed HTTP boundary; its delivery record owns proof and review status. Subject-registry capacity, cleanup and refusal semantics derive from ADR-0066, Accepted — 2026-10-20. Implementation has local runtime/exported proofs; the implementation record owns independent reviews and final-source CI qualification. Existing-binding admission additionally derives from Accepted ADR-0072. Live policy and sensitive-directory contracts derive from Accepted ADR-0076 and ADR-0077. The API composition activates live policy and the administrative directory; the phase Status block owns qualification. The Accepted P03-3 extension derives from ADR-0078 and ADR-0079; acceptance defines its contract without claiming runtime activation.

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 Invalid present tenant bearer credentials; missing authentication only where the surface permits a challenge
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; bounded Retry-After where the surface defines a truthful hint

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.

P02b-5 HTTP activation — 2026-10-19. ADR-0064 defines the strict tenant bearer profile and ordered HTTP admission. On classified known hosts, present invalid or undecidable credentials return 401 with a bare Bearer challenge, including on public endpoints. Unknown hosts retain their masked 404 before JWT validation; credential-free public reads retain anonymous behavior. Production authentication and authorization services/middleware activate together with the complete Step 4 boundary.

Existing-binding handoff. The Security contract separates verified provider subject from normalized application identity. An optional wire actor is consistency evidence; quota precedes the uncached existing- binding read, which alone supplies a stored actor. Unknown/conflicting/Closed or unavailable bindings return uniform 401 without allocation. Ordinary resolution remains route-independent. Credential-free public reads and AccountSite's existing absent-bearer 404/admitted-wrong-method 405 remain. That P03-1 handoff admits no POST or SDK operation. ADR-0076 separately accepts the administrative directory below. The Accepted P03-3 extension below defines new-user transport and native BFF admission separately.

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 untrusted request input selects a tenant. A body field, query parameter, browser cookie or assertion header supplies no scope authority. X-Tenant-Id and X-Organization-Id can only reject. ADR-0078's separately authenticated server-owned native candidate is the bounded additional source; its API confirmation and exact host ceiling follow Security's native admission contract.

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 records host-only tenant authority, including a validated actor whose tenant claim is absent under ADR-0064. Authentication cannot lift that ceiling. 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. This generic unresolved MediatR result remains tenant_mismatch. ADR-0064's exact AccountSite endpoint instead refuses at outer HTTP admission: absent credentials on tenant, organization and platform hosts return the masked not_found 404, as does a validated principal without usable host-backed tenant authority. Its scoped authorization result handler preserves that result before dispatch; it does not change the generic pipeline gate.

  • 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.

Closed authenticated read

Accepted by ADR-0064 — 2026-10-19; HTTP activation in P02b-5 Step 4. The additional operation is exact GET /api/v1/account/site, GetAccountSite, dispatching non-public GetAccountSiteQuery. It returns the validated actor, resolved tenant/organization and existing live PublicSite projection, accepts no query parameters or client-selected identifiers, and is audit Off. Before any reader, the query requires HostAndClaim, a real non-system actor and matching real resolved/host scope; ClaimAndMembership remains ineligible even after live membership arrives. The exact query receives physical ReadOnly through the existing owner; it gains no public marker or general authenticated-read exemption. ADR-0078's Accepted extension additionally permits the confirmed HostAndNativeSessionScope origin with the same real-host/context agreement. It does not admit a platform-host AccountSite read.

Step 4 adds this GET to the eight public GET/HEAD actions in the closed production census, with explicit infrastructure exclusions, bearer OpenAPI security and no-store on success and early errors. Generated GETs have exact disjoint public and authenticated ownership with an exhaustive union. The public transport stays credential-free; P02b-7 owns authenticated session transport.

Administrative membership directory

ADR-0076 extends the AccountSite-only authenticated API with GET /api/v1/identity/memberships. It is a declared tenant-wide administrative read requiring the existing Tenant identity.membership.admin capability, including an eligible NULL-org Tenant Admin Membership in a branch session. An Organization grant never authorizes it. HostAndClaim and confirmed ClaimAndMembership authority admit it, including a platform host with a confirmed claim; HostOnly and host/claim disagreement do not. PublicSurface and AccountSite keep their separate real-HostScope ceilings.

  • Return only MembershipId, UserId, OrganizationId, status, contextual DisplayName and PreferredLocale for undeleted Active/Suspended Memberships. Revoked/retired rows, provider subject, email, grant graph and global profile are excluded. Module-local IDs cross contracts as Guid; SharedKernel identity/scope types stay.
  • Use fixed MembershipId ordering and an opaque versioned cursor bound to actor, tenant/org and declared operation. Limit defaults to 20 and clamps at 100. Bound the cursor to 1,024 encoded/768 decoded bytes and strictly validate canonical version/fields. Every page rechecks live policy and actual scope; the anchor supplies no authentication or snapshot authority. No search, arbitrary filter/sort or mutation is admitted.
  • Responses are no-store. Success discloses the DTO only after the MUST identity.membership.list ReadSensitive row commits on the ordinary ReadWrite owner. This GET is not added to the physical READ ONLY catalogue. Audit failure returns 503 audit_unavailable without a result payload.
  • Served OpenAPI and the generated SDK carry the exact directory contract. The delivered P03-2 native transport still permits only AccountSite; ADR-0078/0079 define its separately qualified extension below.

Security's live policy boundary owns early-gate precedence, runtime default deny, independent application checks and 403/404/503 outcomes. Database read authority owns the invocation witness shared by EF and SQL. Runtime and source-bound controls separately verify routing, schema, grants and pre-disclosure audit.

Accepted P03-3 self entry and administration

ADR-0078 adds three exact global-self descriptors on known Tenant/Organization hosts: GET /api/v1/identity/self, POST /api/v1/identity/self/bootstrap and GET /api/v1/identity/self/contexts. They authenticate the verified provider subject and separate native source before any ordinary contextual binding path; they do not create an unresolved-context exemption for other endpoints. Self and context discovery are Off, own-person, no-store navigation reads. Bootstrap creates a binding only, with opt-in allocation and central MUST audit; it grants neither Membership nor a tenant session. Nonadmitting bindings never look missing. The Security contract owns exact ordering, source consistency, terminal outcomes and creation budgets.

Initial selection and explicit switching use the separately audited POST /api/v1/identity/session-scope/admissions. Ordinary refresh instead uses the unchanged-scope Off self/AccountSite validation defined by ADR-0078; it does not emit another admission event. A successful API event proves its own committed admission, not delivery of a browser cookie.

ADR-0079 defines the finite member/profile/Role and Organization administration surface, its explicit v2 grant adoption and exact native operation allowlist. Protected resource reads, own-capability previews and mutation receipts retain their separately declared audit classes and no-store behavior. Advisory capability flags are not permission grants. The exact descriptor/policy, OpenAPI and SDK censuses must activate together; no catch-all BFF proxy follows. The phase Status block records implementation and qualification, separately from these Accepted contracts.

Protected HTTP authorization performs a nonmutating completed-receipt lookup before choosing the current mutation or original-target disclosure policy. A private request-bound admission carries that choice into the resource filter; missing, changed or reused admission refuses. Read admission never becomes a new claim after expiry or recycling. The filter still dispatches the independent MUST receipt read and joins actual owner cleanup before returning retained bytes. The lookup acquires no capacity fence, creates no key and extends no retention. The storage/replay contract owns durable key and outcome semantics.

Every protected write, including a completed-key replay, satisfies the same UTF-8 application/json media predicate before lookup, claim or disclosure. Own-context pagination selects its byte-bounded prefix before constructing the continuation cursor. The count limit remains an upper bound: escaped labels and verified origin expansion may shorten a page to preserve ADR-0079's 256 KiB API/SDK response budget, while subsequent pages retain every remaining item.

Verified invitation transport

Derives from: ADR-0093. This is the Accepted P03-4 contract; the phase Status block owns runtime delivery and qualification. The following finite extension adds no general nonmember API or native proxy. All paths below carry /api/v1.

Method and path Exact consumer / response boundary
GET /identity/invitations, /identity/invitations/{id} ListInvitationsQuery / GetInvitationQuery; applicable membership-admin in exact current scope, MUST read; email, Role, lifecycle, expiry, version and bounded delivery status only
GET /identity/invitable-roles ListInvitableRolesQuery; same capability/scope, MUST read; currently delegable ID/key/scope/version summaries, no Role grant graph
POST /identity/invitations CreateInvitationCommand; email and one pinned compatible Role/version; atomically creates generation 1, private encrypted mail custody and typed job; protected keyed 201 version receipt and Location
POST /identity/invitations/{id}/revocations RevokeInvitationCommand; current exact-scope membership-admin withdrawal authority, Pending target and canonical strong If-Match and protected keyed version receipt
POST /identity/invitations/{id}/reissues ReissueInvitationCommand; current delegation and unchanged Role pin, canonical strong If-Match and protected keyed receipt; new verifier/generation/expiry invalidates the old link
GET /tenancy/settings/invitation-policy GetTenantInvitationPolicyQuery; own-current-tenant policy only, orgless context, Off; absent/default or stored version precondition
PUT /tenancy/settings/invitation-policy SetTenantInvitationLifetimeCommand; Tenant tenancy.setting.write, orgless scope, fixed key identity.invitation.ttl_days, 1–30 days; conditional creation or canonical If-Match, protected keyed receipt and MUST update
POST /identity/invitation-entry/inspections InspectInvitationQuery; body ID/code, exact host/source/subject/offer entry, fresh current verified recipient; Off, physically ReadOnly and no-store
POST /identity/invitation-entry/acceptances AcceptInvitationCommand; body ID/code only, canonical strong If-Match for Pending, fresh recipient proof and locked authority; natural offer/actor convergence; Membership/result/version receipt
POST /identity/invitation-entry/results GetOwnInvitationResultQuery; body ID/code, consumed digest, same Active actor and original current Active Membership; Off, physically ReadOnly; no new grant or email/inviter/Role/expiry revalidation

Administrative writes never return raw codes or links. Create/revoke/reissue use the existing bounded protected receipt rules; replays disclose the original Invitation through ReadInvitationReceiptQuery with fresh membership-admin and MUST read audit. Policy replay uses ReadInvitationPolicyReceiptQuery, the exact original TenantSetting ID/key in the same orgless tenant, own-current-Membership coverage and MUST read audit; it requires no old write capability and exposes no generic settings directory. No secret enters durable replay bytes. Policy absence is a conditional create precondition, never an unconditional upsert. The policy getter returns days, present, nullable decimal-string version and advisory canEdit. Absent means 14 days, present=false, version=null and no ETag. A stored version-zero row has present=true, version="0" and ETag "0". PUT accepts only {days} and Idempotency-Key, with exactly one of If-None-Match: * for absence or canonical If-Match: "N" for presence. Missing, combined, weak or list preconditions are 400; a changed presence/version is 409. Both setter branches return the 200 version receipt without Location; protected fingerprints bind the presence distinction. The retained receipt contains the actual original TenantSetting ID, including first creation.

Revocation withdraws a Pending offer under current exact-scope membership-admin and strong root If-Match. It assigns nothing and does not require the obsolete offered Role or its pin/grant ceiling to remain usable. Create and reissue retain the live compatible Role/version and complete delegation checks; changing that pin requires withdrawal and a new offer. Reissue preserves the original inviter.

Entry bodies are strict UTF-8 JSON, at most 8 KiB, with no unknown/duplicate fields or actor/tenant/org authority selectors. Inspection and result use POST to keep credentials out of URL paths/queries; their consumers remain ReadOnly. All three entry bodies carry only Invitation ID and code. Observed acceptance version has one carrier: canonical strong If-Match, not a duplicate body field. Inspection/result reject conditional headers. Acceptance rejects Idempotency-Key and introduces no tenantless replay namespace. A preflight Accepted offer dispatches the exact result read before mutation intents or Pending-only If-Match validation; an obsolete original precondition does not block committed-result recovery. A Pending command losing the race returns recoverable 409 with truthful failed-attempt evidence; a fresh result read reconciles the committed outcome. Unknown COMMIT remains indeterminate until authoritative reconciliation.

The browser landing GET renders only. Its fragment code reaches the API only through bounded native capture and these POST bodies; no GET consumes an offer, creates Membership or records a send. Admin pages use 20 default/100 maximum, actor/scope/operation-bound opaque cursors, no-store and the inherited bounded response/deadline rules. Provider observation is limited to 16 KiB/five seconds within the existing ten-second native API deadline, with no business locks held. Malformed fields/preconditions are 400; stale versions/changed lock plans and actionable target conflicts are 409. Missing source/admission, invalid link or foreign target is masked 404; an invalid-present bearer remains 401. A matching offer with unverified/mismatched recipient gets bounded 403 recovery without the expected mailbox. Uncertain storage/provider state is 503; exhausted quotas are 429. Submission accepted by SMTP is not proof of inbox delivery.

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.

Protected administration receipts

Derives from: ADR-0079. Its bounded protected set activates the PostgreSQL adapter. Generic endpoints retain the rules above. Protected fingerprints also bind the exact canonical If-Match precondition where applicable. Only successful 2xx outcomes or their unreplayable tombstones are retained; non-2xx attempts release the claim. A success receipt contains only the canonical decimal-string version, ETag and creation Location where applicable, at most 1 KiB including retained headers. The general response-capture ceiling remains 256 KiB including headers.

A completed protected response requires fresh disclosure authorization and its own MUST read audit before bytes are replayed. Its persisted typed target kind/ID identifies the original resource independently of the wire receipt or a later Membership successor. Replay does not repeat the original mutation or its audit, and a failed disclosure/audit check leaves the stored outcome intact. The Database contract owns claim, capacity, expiry/reclamation, target binding and completion fences. A crash after business COMMIT and before outcome recording still permits at-least-once work; no exactly-once or safe automatic mutation retry is claimed.

Global-self bootstrap is a separate tenantless, naturally convergent create on the serialized subject binding. It rejects Idempotency-Key rather than creating an unscoped key space. Acknowledged creation is 201; a matching existing or racing winner is 200. Unknown COMMIT is 503: deliberate retry can find the committed binding or make a newly admitted creation, without assuming rollback.

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.

Protected administration exception — ADR-0079. For this bounded set only, absent, malformed, noncanonical, weak, wildcard or list If-Match is 400 validation_failed. A syntactically valid stale tag remains 409 concurrency_conflict. If-Match: * is rejected, never treated as absent; Organization creation has no version precondition. Versions are canonical decimal strings bounded to Int64, never JavaScript numbers. Other endpoints retain the generic behavior above.

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. Exact nonempty public and authenticated operation censuses 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) Additional 600 req/min per validated issuer + subject ADR-0064; P02b-5 Step 3 implements the component, Step 4 activates it after authentication and before resolution/audit. Existing early visitor/peer limits still apply
Rate limit (protected writes) Same budgets as authenticated requests above Existing authenticated-subject admission plus visitor/physical-peer limits apply. No separate per-token write limit is implemented

429 responses include Retry-After. Accepted ADR-0066 requires the subject-admission boundary to distinguish these outcomes before resolution or audit, using the existing Problem Details/no-store path:

Subject refusal Status / body code Retry-After
Spent quota 429 / rate_limited Stock quota hint, rounded up to seconds, minimum 1
Retained-key capacity 429 / rate_limited 60 seconds
Sticky cleanup fault for a new key 503 / dependency_unavailable Common 503 retry helper: 30 seconds

Capacity is a rate-admission refusal, not an IdP or audit-store failure; the idempotency-store capacity rule remains 503 because an operation outcome cannot be safely retained there. Cleanup fault is an unavailable local dependency. Existing retained keys keep their quota service during that fault. Retry hints promise neither space nor recovery; fault recovery needs qualified instance replacement. Local signed-token HTTP and actual OTLP proofs cover these outcomes; the implementation record owns independent reviews and final-source CI qualification.

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.