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.
- REST first. GraphQL only after a clear product driver.
- Resources are plural nouns (
/courses,/users). - HTTP verbs used idiomatically:
GETread,POSTcreate,PUTreplace,PATCHmodify,DELETEremove. - JSON,
application/json, UTF-8. - URL versioned:
/api/v1/...(canonical prefix per ADR-0024). - Bodies use
camelCase.
/api/{version}/{resource}/{id?}/{sub-resource?}
Examples:
GET /api/v1/courses?cursor=...&limit=20POST /api/v1/coursesGET /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.
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.
/healthzand/readyzare 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.
| 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 |
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:
titlecarries thelockey_*key, not English prose, and there is nodetail. 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 adetailfield; both contradicted Standards 09 and the shippedProblemDetailsFactory.- 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.
- 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
Integrationswith 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.
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-IdandX-Organization-Idcan 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).
[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.
-
TenantContextOriginis the ceiling. A context resolved from the host alone carriesHostOnlyand reaches only[PublicSurface]request types;TenantContextBehaviorat pipeline step 4 (02-backend-coding.md § Pipeline Behaviors) rejects anything else — failing withlockey_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. Notlockey_tenant_mismatch: aHostOnlycontext 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 thatUseStatusCodePagesfills in throughProblemDetailsFactory.ForStatus(404), while a step-4 failure carries its own Problem Details body throughProblemDetailsActionResult; sinceHttpStatusMap.CanonicalCodeFor(404)isnot_foundandError.Codestrips thelockey_prefix, both carry the sametype,title,status,codeandmessageKey.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
405on a host that resolved and the shared404on 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 underis_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-host404here too, so the bit disclosed is exactly the one that is public by definition — and once the table above has a row, a plainGETdiscloses it more directly by returning200.A_Disallowed_Method_Discloses_Only_That_The_Host_Is_Publicly_Livepins 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
coderather than status: an unmarked request there resolves no tenant and fails gate 1 withtenant_mismatch, where a tenant host under the ceiling and an unknown host both carrynot_found. That discloses membership ofTenancy:PlatformHosts— a short static list an operator publishes as its own entry point — and nothing about any tenant. This generic unresolved MediatR result remainstenant_mismatch. ADR-0064's exact AccountSite endpoint instead refuses at outer HTTP admission: absent credentials on tenant, organization and platform hosts return the maskednot_found404, 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-classread-sensitive(18-audit-coverage.md) — an anonymousGETwould 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.
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.
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.listReadSensitive row commits on the ordinary ReadWrite owner. This GET is not added to the physical READ ONLY catalogue. Audit failure returns503 audit_unavailablewithout 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.
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.
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.
Cursor pagination by default:
GET /api/v1/courses?cursor=eyJpZCI6Li4ufQ&limit=20
Response:
{
"items": [...],
"pageInfo": {
"nextCursor": "eyJpZCI6Li4ufQ",
"previousCursor": null,
"hasNext": true,
"hasPrevious": false
}
}Rules:
limitdefault 20, max 100. A largerlimitis clamped to 100, not rejected — one behaviour, decided once inCursorPaginationand not re-decided at the edge. Alimitof zero or less is rejected, witherrors.limitnaming 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_failednaming 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).
- 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=fieldorsort=-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=PublishedAtis accepted; handing a handler back a spelling the endpoint never declared is how a field name that reaches aswitchor anOrderBybreaks on a casing nobody tested. - An empty segment (
title,ora,,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=abcand?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_allowedwith the field inparams.
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
LocationandETag. 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 notconcurrency_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_unavailablereleases 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_unavailablerather 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.
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.
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-Matchfails 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.
- 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.jsontoday. Each document holds only its own major's paths; without that filter an added/api/v2operation reads as a breaking change tov1underoasdiff. - Reference console at
/docs(redirects to/docs/), rendered by Scalar.Microsoft.AspNetCore.OpenApiships 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/garbagewith 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 underSelfHostedAirGapped. - 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.jsonanswered 404 intext/plainwith 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.
serversis omitted. Its default value is derived from the request'sHost, which is client-chosen — so the published contract would vary with the caller, and theoasdiffgate would diff the document against itself.- Every operation carries
x-version-introduced; ADR-0024 § OpenAPI marking owns the full extension set.deprecatedis derived from[Obsolete]and is absent when false — OpenAPI defines an absentdeprecatedasfalse, and the serializer omits defaults. - TypeScript SDK
@learnstack/sdkis generated from this spec; Standards 07 § SDK owns how and when. - Breaking OpenAPI changes fail the always-running
openapi diffjob 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.
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.
- 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.
- 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.
GET /healthz— liveness.GET /readyz— readiness (all critical deps reachable).GET /version— build metadata: git sha, build time, version tag.
Public but rate-limited.
POST /resourcesreturns the created resource with201+Location.PATCHaccepts partial JSON; unknown fields →400.DELETEis idempotent; deleting a missing resource →204.GETis safe and idempotent; never has side effects.- Sort parameters never affect filtering.
- Tenant id read from a request body or query param.
- Tenant id selected from a request header.
X-Tenant-Id/X-Organization-Idare 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.