The contract every /api/v1/* endpoint and every ornn-api module must follow. All future endpoints and modules MUST conform. Changes that violate a convention are blocked at review.
This document is normative. It is the authoritative source for decisions that would otherwise be re-litigated per PR. When in doubt, this file wins.
- Response & error format
- URL structure
- HTTP semantics
- Query parameters
- Authentication & authorization
- SSE streaming
- Deprecation
- Caching
- Observability headers
- OpenAPI
- Architecture conventions
- Every new
/v1/endpoint checklist
Return the resource directly. No envelope.
GET /v1/skills/abc
200 OK
Content-Type: application/json
{
"id": "abc",
"name": "pdf-extract",
"createdOn": "2026-04-22T10:00:00Z",
...
}Wrap in { items, meta }:
GET /v1/skills?q=pdf&limit=20
200 OK
Content-Type: application/json
{
"items": [ { "id": "abc", ... }, { "id": "def", ... } ],
"meta": { "nextCursor": "eyJpZCI6...", "hasMore": true, "limit": 20 }
}meta MUST contain limit and hasMore. When hasMore === true, nextCursor MUST be a non-empty opaque string. When hasMore === false, nextCursor MAY be omitted. Endpoint-specific metadata (e.g. searchMode) lives alongside pagination fields in meta.
POST /v1/skills/abc/permissions
400 Bad Request
Content-Type: application/problem+json
X-Request-ID: req_01HXYZ...
{
"type": "https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md#validation_error",
"title": "Validation failed",
"status": 400,
"detail": "Request body failed validation",
"instance": "/v1/skills/abc/permissions",
"requestId": "req_01HXYZ...",
"errors": [
{ "path": "sharedWithUsers[3]", "code": "invalid_user_id", "message": "..." }
]
}Required fields: type, title, status, instance, requestId.
Optional: detail, errors[].
| Code | HTTP | Meaning |
|---|---|---|
validation_error |
400 | Body / query / path param validation failed — details in errors[] |
invalid_zip |
400 | Uploaded payload is not a parseable ZIP (malformed / unreadable) |
unsupported_media_type |
415 | Request Content-Type not accepted |
payload_too_large |
413 | Upload exceeds max size |
uncompressed_too_large |
413 | Uncompressed size or compression ratio of skill ZIP exceeds caps (zip-bomb guard) |
too_many_files |
413 | Skill ZIP entry count exceeds MAX_PACKAGE_FILE_COUNT |
authentication_required |
401 | No valid identity |
permission_denied |
403 | Authenticated but lacks required permission |
resource_not_found |
404 | Target resource does not exist or not visible to caller |
resource_conflict |
409 | State conflict (duplicate, concurrent modification, etc.) |
rate_limited |
429 | Caller exceeded rate limit |
upstream_unavailable |
502 / 503 | Dependency (NyxID, LLM, sandbox, ...) failed |
org_membership_unavailable |
503 | NyxID org-membership lookup unresolved — forwarded token absent or lookup failed. Retryable |
internal_error |
500 | Unhandled server error |
New codes require convention-doc update. Handlers MUST NOT invent ad-hoc codes.
- Generated server-side on every request (or echoed if the client provided one).
- Returned as
X-Request-IDheader on every response (2xx, 4xx, 5xx). - Also embedded as
requestIdin every error body. - Logged with every request/response pair on the server.
Point to GitHub markdown anchors in this repository:
https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md#<code>
The catalog lives in docs/ERRORS.md with ## headings per code (GitHub auto-generates anchors). Zero infra cost; resolves day one. Future migration to a docs domain (docs.ornn.xyz) is a one-time redirect configuration; no client changes required.
All endpoints live under /api/v1/. Breaking changes ship under /api/v2/. Additive changes ship under v1.
- Plural resource nouns:
/skills,/categories,/tags,/users,/activities. - Canonical URL uses the stable ID (GUID). No polymorphic
:idOrNameon write operations. - Name→ID resolution via
GET /v1/{resource}/lookup?name=<name>(returns{ id }). - Caller-scoped resources under
/v1/me/*.
Custom actions as sub-resource paths:
POST /v1/skills/generate
POST /v1/skills/generate/from-openapi
POST /v1/skills/validate
POST /v1/skills/search
POST /v1/playground/chat
Router config MUST declare static action segments with priority over :id params (Hono / Express / Rails default behavior). Skill / category names that collide with reserved action verbs are rejected at create time.
Reserved action verbs per resource documented in ornn-api/src/shared/reservedVerbs.ts.
GET /v1/{resource}?q=...&<filters>— simple keyword filter over URL params (cacheable, bookmarkable).POST /v1/{resource}/search— complex queries with structured body (semantic mode, long queries, compound filters).
Both return the same collection shape ({ items, meta }).
GET /v1/skills/{idOrName}/closure[?version=<major.minor>|<dist-tag>]
Resolves the full transitive dependency closure of a skill version. A skill declares its direct dependencies in SKILL.md frontmatter via metadata.depends-on — an array of <name-or-guid>@<major.minor> or <name>@<dist-tag> refs (no semver ranges). The endpoint walks that graph and returns every transitive dependency.
- Auth: optional. Anonymous callers resolve against public skills only; a public skill that transitively depends on a private skill the caller can't read surfaces that node as
skill_dependency_not_found(existence not leaked). - Order: items are returned in deps-first topological order — every dependency precedes the dependents that pin it, so installing in array order is always safe. Shared nodes (diamonds) appear exactly once.
- Response: standard collection envelope.
{
"data": {
"items": [
{ "guid": "…", "name": "pdf-tools", "version": "1.0", "skillHash": "…", "depth": 1 },
{ "guid": "…", "name": "report-gen", "version": "2.3", "skillHash": "…", "depth": 0 }
]
},
"error": null
}- Errors:
dependency_cycle(409) when the graph loops;dependency_conflict(409) when one skill is pinned to two versions in the same closure;skill_dependency_not_found(404) when a ref doesn't resolve or isn't visible. Seedocs/ERRORS.md.
The same closure is validated at publish time: declaring a depends-on ref that can't be resolved, forms a cycle, or conflicts fails the create/update before the version is committed.
SDK helpers: client.resolveClosure(idOrName, { version }) / client.pullClosure(...) (TypeScript), client.resolve_closure(...) / client.pull_closure(...) (Python).
A skillset is a named, versioned, owned, visibility-scoped meta-package that references N member skills and carries a kind. One call resolves + delivers the whole set — including each member's dependency closure (§2.5). The ownership / visibility / immutable-versioning model mirrors skills verbatim; permission scopes reuse the existing ornn:skill:{create,read,update,delete} (see §5.2 — a dedicated ornn:skillset:* scope split is a tracked follow-up).
POST /v1/skillsets — create (ornn:skill:create; private by default)
GET /v1/skillsets/{idOrName} — read (optional auth; anon sees public only)
GET /v1/skillsets/{idOrName}/versions — list versions (optional auth)
GET /v1/skillsets/{idOrName}/closure — one-call resolve (optional auth)
PUT /v1/skillsets/{id} — publish a new immutable version (ornn:skill:update)
PUT /v1/skillsets/{id}/permissions — visibility / sharing (ornn:skill:update)
POST /v1/skillsets/{id}/transfer-ownership — hand to another user (ornn:skill:update; ADMIN tier — §5.4)
DELETE /v1/skillsets/{id} — delete + cascade versions (ornn:skill:delete)
GET /v1/skillset-search — discovery by kind / tags / scope (optional auth)
kind: enum, v1{ "generic", "consensus-supported" }(extensible). Defaultgeneric.consensus-supportedis an author claim that the members are an independent, comparable set suitable for agent-side consensus — not a guarantee (stated honestly; Ornn packages + delivers the set, the agent runs any consensus in its own runtime).members: 2..N skill refs, each<name-or-guid>@<major.minor>or<name>@<dist-tag>(the same grammar asdepends-on, §2.5). No nested skillsets in v1 — a skillset references skills only. Validated on publish: every member must resolve to a readable skill version, and the union dependency closure must be conflict-free.instructions(master prompt, #978): a REQUIRED, versioned markdown body telling an agent HOW to use the set (orchestration, ordering, which member to pick when). 1..8000 chars (trimmed server-side; a whitespace-only body is rejected). Distinct fromdescription(a short ≤1024-char human summary). Required on BOTH create and publish, with NO carry-forward — unlikedescription/kind/tags(which a publish may omit to inherit the prior version's value), every published version must explicitly state its own master prompt. Stored opaque — Ornn does not render, sanitize, template, lint, or search-index it. Surfaced verbatim onGET /v1/skillsets/{idOrName}and as a root field on/closure.- Create / publish bodies (JSON):
POST /v1/skillsets
{ "name": "review-set", "description": "…",
"instructions": "Run pdf-tools first, then feed its output to csv-tools…",
"kind": "consensus-supported",
"tags": ["review"], "members": ["pdf-tools@1.0", "csv-tools@2.1"], "version": "1.0" }GET /v1/skillsets/{idOrName} returns the detail object including the version's instructions.
- Closure:
GET /v1/skillsets/{idOrName}/closureresolvesroots = membersthrough the same §2.5 resolver — the union of all members plus each member's transitive dependency closure, deduplicated and topo-sorted (deps-first). The success body carries the version's master prompt as a root sibling ofitems:{ "data": { "instructions": "…", "items": [ … ] }, "error": null }(the skill/skills/:id/closureenvelope stays{ items }, unchanged). Same error codes as §2.5:dependency_cycle(409),dependency_conflict(409),skill_dependency_not_found(404). Anonymous callers resolving a public skillset whose member transitively pins a private skill getskill_dependency_not_foundfor that node — existence is not leaked. - Search:
GET /v1/skillset-search?kind=…&tags=a,b&scope=…— plain keyword/filter discovery (no semantic / LLM ranking, no facets, no popularity ranking). Cursor pagination per §4.3.
SDK helpers: client.createSkillset(...) / getSkillset(...) / publishSkillset(...) / setSkillsetPermissions(...) / deleteSkillset(...) / getSkillsetClosure(...) / searchSkillsets(...) (TypeScript); client.create_skillset(...) / get_skillset(...) / publish_skillset(...) / set_skillset_permissions(...) / delete_skillset(...) / resolve_skillset_closure(...) / search_skillsets(...) (Python).
Scope follow-up: skillset endpoints intentionally reuse the
ornn:skill:*permission scopes in v1 (a skillset is a skill-lifecycle resource). Splitting them into dedicatedornn:skillset:{create,read,update,delete}scopes is a tracked follow-up; callers should not assume the reuse is permanent.
| Method | Semantics |
|---|---|
GET |
Safe, idempotent read |
POST |
Create, or custom action |
PUT |
Full replace of a resource (idempotent) |
PATCH |
Partial update |
DELETE |
Remove (idempotent) |
Partial updates MUST use PATCH. PUT MUST accept a complete representation.
| Code | Use |
|---|---|
200 OK |
Successful read / update returning a body |
201 Created |
Successful create. MUST include Location: /v1/{resource}/{id} header |
202 Accepted |
Async job accepted (not currently used) |
204 No Content |
Successful delete, or update with no body to return |
400 |
validation_error |
401 |
authentication_required |
403 |
permission_denied |
404 |
resource_not_found |
409 |
resource_conflict |
413 |
payload_too_large |
415 |
unsupported_media_type |
429 |
rate_limited |
500 |
internal_error |
502 / 503 |
upstream_unavailable |
When a resource has multiple representations, select via Accept:
GET /v1/skills/abc
Accept: application/json → JSON metadata + file contents
Accept: application/zip → raw ZIP package
Do not encode representation in the URL path (no /skills/:id/json).
POST creates accept optional Idempotency-Key: <uuid> header. Server persists the key + response for 24h and returns the cached response on retry. Implementation: middleware layer in ornn-api/src/middleware/idempotency.ts.
Bulk-capable endpoints are symmetric:
POST /v1/{parent}/{id}/{child} { <child>Ids: [...] } # add
DELETE /v1/{parent}/{id}/{child} { <child>Ids: [...] } # remove (body)
Single-item convenience endpoints MAY exist alongside.
camelCaseeverywhere (matches JSON body convention).- Search query param is
q(neverquery). - Booleans are
true/false— omit for "any".
?sharedWithOrgs=a&sharedWithOrgs=b&sharedWithOrgs=c
Never CSV. Never bracket notation. Handler: c.req.queries('sharedWithOrgs') returns string[].
?cursor=<opaque>&limit=<1-100>
cursoris opaque (base64-encoded server-chosen payload). Clients MUST NOT parse.limitdefaults per-endpoint (typically 20), max 100.- Absence of
cursor= first page. - Response
meta.nextCursorfeeds the next request. - Total counts are NOT part of pagination. Endpoints needing a count expose a sibling (e.g.
GET /v1/skills/counts) or fold the count into listmeta.
Endpoint-specific. Rules:
- Orthogonal filters are separate params. Do NOT overload (avoid
scope=shared-with-me|mine|...). - Booleans instead of tri-state enums when possible.
- For
/v1/skills:visibility—public | private(omit for "any" within caller's reach)owner—me | others(omit for "any")sharedWith—me(filters to skills shared with caller)isSystem— boolean (omit for "any")
Authorization: Bearer <jwt>between client and the NyxID proxy.X-NyxID-Identity-TokenandX-NyxID-*headers between proxy andornn-api(internal).- OpenAPI declares one
bearerAuthscheme;X-NyxID-*is not part of the public contract.
Format: ornn:<resource>:<action>. These are the request scopes NyxID
mints onto an access token — requirePermission(...) middleware checks them
per route. They gate who may call an endpoint at all; they are distinct
from the per-object READ / WRITE / ADMIN tier (§5.4), which decides
what the caller may do to a specific skill/skillset. A caller needs both:
the route scope to reach the handler, and the object tier to act on the
target.
| Permission | Grants |
|---|---|
ornn:skill:read |
Read skills (respects visibility) |
ornn:skill:create |
Create skills (upload, pull from GitHub) |
ornn:skill:update |
Update / publish / refresh / change permissions / transfer ownership / toggle deprecation / bind NyxID service (+ object ADMIN/WRITE per §5.4) |
ornn:skill:delete |
Delete a skill or a single version (+ object ADMIN per §5.4) |
ornn:skill:build |
Invoke skill generation endpoints (high LLM cost) |
ornn:playground:use |
Invoke playground chat (runs user code) |
ornn:admin:skill |
Platform-admin bypass — manage any skill/skillset (override ownership), plus all /admin/*, force-audit, and platform settings |
ornn:category:read |
List categories |
ornn:category:admin |
Manage categories |
ornn:tag:read |
List tags |
ornn:tag:admin |
Manage tags |
ornn:user:admin |
User dashboard (list users, aggregate stats per user) |
ornn:activity:read |
Platform activity log read access |
ornn:stats:read |
Platform-wide dashboard aggregates |
Skillset endpoints reuse the ornn:skill:{create,read,update,delete}
scopes verbatim (§2.6) — there is no ornn:skillset:* scope split in v1.
NyxID composes a "Platform Admin" role around ornn:admin:skill (plus the
*:admin + *:read scopes above); a token carrying ornn:admin:skill
bypasses object ownership on every skill/skillset operation. Current platform
admins inherit this role with zero UX change. Sub-admin roles (content
moderator, tag curator, support) can be composed from subsets when needed.
Adding a new permission requires convention-doc update. NyxID role → permission mapping is owned by NyxID config; this doc is the permission catalog.
Every route in OpenAPI tagged with its required scopes. Public endpoints explicitly declare security: [].
Independent of the request scopes above, every skill AND skillset carries
a three-tier object-permission model. All three gates derive from one source
of truth — ornn-api/src/domains/skills/crud/authorize.ts (canReadSkill /
canWriteSkill / canManageSkill) — and skillsets reuse the same gates. A
request scope decides whether the caller may call the endpoint; the object
tier decides whether they may act on this specific skill/skillset.
| Tier | Gate | Who qualifies | What it grants |
|---|---|---|---|
| READ | canReadSkill |
Public skill → anyone. Private → author, platform admin, or any grantee (read or write), directly or via a granted org. |
View / pull / execute / list versions. |
| WRITE | canWriteSkill |
Author OR platform admin OR a write grantee (direct or via a granted org). |
READ, plus update the skill's content + metadata only (publish a new version). |
| ADMIN | canManageSkill |
Author OR platform admin only. | Change permissions, transfer ownership, delete skill/version, toggle deprecation, manage dist-tags, bind a NyxID service. |
A write grantee is never an admin — the danger-zone operations stay
with the author (createdBy) and platform admins (ornn:admin:skill). Org
grants resolve uniformly: every admin/member of a granted org inherits the
grant's level. The org-membership gates fail soft on an unresolved NyxID
lookup (deny) — they never grant on a "couldn't ask" result.
The canonical access-control list is a typed grants array, exposed on
skill/skillset detail responses and accepted by the permissions endpoints
(PUT /v1/skills/:id/permissions, PUT /v1/skillsets/:id/permissions):
{
"grants": [
{ "type": "user", "id": "<nyxid-person-user-id>", "level": "read" },
{ "type": "org", "id": "<nyxid-org-user-id>", "level": "write" }
]
}type—"user"(a NyxID person user_id) or"org"(a NyxID org user_id).id— the principal's NyxID id (1..128 chars).level—"read"or"write"(awritegrant implies read). An invalid value is rejected withinvalid_permission_level(avalidation_errorsubcode — seedocs/ERRORS.md).
The author (createdBy) is never represented in grants — they hold implicit
ADMIN. The legacy read-only sharedWithUsers / sharedWithOrgs arrays are
still accepted on the permissions endpoints and still returned for
back-compat; any principal supplied through them is treated as a read-level
grant. A skill predating typed grants authorizes exactly as before.
POST /v1/skills/:id/transfer-ownership { "newOwnerUserId": "<id>" }
POST /v1/skillsets/:id/transfer-ownership { "newOwnerUserId": "<id>" }
- Auth: ADMIN tier (
canManageSkill) — author or platform admin only. Awritegrantee can never transfer. Rides on the existingornn:skill:updaterequest scope; no new scope was added. - Behavior: immediate, synchronous transfer. The target becomes the new
owner (
createdBy); the prior owner is kept as a READ grantee (retains visibility, loses edit/admin rights). - Target validation:
newOwnerUserIdmust resolve to a known Ornn user — someone who has signed in to Ornn at least once. An unresolvable target is rejected withinvalid_transfer_target(400). - No-op guard: transferring to the current owner returns
ownership_conflict(409). - Returns the updated resource (
{ data: { skill | skillset }, error: null }).
Format: <resource>_<event>, snake_case.
Shared event vocabulary across endpoints:
| Suffix | Meaning |
|---|---|
_start |
Stream opened |
_text_delta |
Incremental text content |
_tool_call |
Model requests tool invocation |
_tool_result |
Tool output |
_file_output |
File produced during run |
_validation_error |
Recoverable validation failure |
_error |
Terminal error |
_complete / _finish |
Stream ended normally |
Endpoints pick a subset and MAY add endpoint-specific events with the same prefix.
| Endpoint | Events |
|---|---|
POST /v1/skills/generate |
generation_start, generation_delta, generation_validation_error, generation_error, generation_complete |
POST /v1/playground/chat |
chat_start, chat_text_delta, chat_tool_call, chat_tool_result, chat_file_output, chat_error, chat_finish |
POST /v1/assistant/chat |
chat_start, chat_text_delta, chat_error, chat_finish |
Content-Type: text/event-stream- Each event has a
typefield in the JSON payload plus SSE-nativeevent:line set to the same value - Keep-alive events every
config.sseKeepAliveIntervalMsmilliseconds (JSON{ type: "keepalive" }) - Clients abort via
AbortSignal/ closing the connection Last-Event-IDreconnection: not supported in v1; clients start over on reconnect
See docs/API_STABILITY.md for the public stability commitment, per-route tiers, and the full deprecation lead-time policy. This section codifies the header shape only.
Per RFC 8594 on deprecated endpoints and representations:
Deprecation: true
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Link: <https://github.com/ChronoAIProject/Ornn/blob/main/docs/DEPRECATIONS.md#skill-version-v1>; rel="deprecation"
Free-form notes go in response body, not custom headers. No X-Skill-Deprecated style custom headers.
GETendpoints returning immutable or slowly-changing data setETag+Cache-Control.- Docs endpoints,
GET /v1/openapi.json,GET /v1/skills/formatare public and cacheable. - Authenticated reads use
Cache-Control: private, max-age=<short>where appropriate.
Every response carries:
X-Request-ID(§ 1.5)- Future:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
GET /v1/openapi.jsonis the source of truth.- Every route declares security, request content types, all documented error responses, and at least one example.
- CI contract test asserts every handler in code appears in the spec with complete metadata.
- Error
typeURLs point to live documentation per § 1.6.
The canonical JSON Schema for SKILL.md YAML frontmatter is published at:
GET /api/v1/skill-manifest-schema.json
- Generated from the Zod source of truth (
ornn-api/src/shared/schemas/skillFrontmatter.ts) at server boot — the published schema cannot drift from the runtime validator. - Output is JSON Schema draft-2020-12 (
$schema) — what current IDEs (VS Code, Cursor, JetBrains) andschemastore.orgconsume. - Served with
Content-Type: application/schema+json(IANA registration). No{ data, error }envelope — the body root is the schema document itself, because that's what schema-store tools expect. - Public, no auth required.
Cache-Control: public, max-age=3600. SKILL_MANIFEST_SCHEMA_VERSION(currently"1") is bumped manually when the frontmatter contract changes in a way external tooling cares about. Out of an abundance of caution while we're pre-1.0, this version is not yet baked into the URL; consumers should re-fetch on a finite TTL.
Skill authors and tooling SHOULD point their YAML language servers at this URL via # yaml-language-server: $schema=... so SKILL.md gets autocomplete + inline validation.
Single AppError class in ornn-api/src/shared/errors/. No inlined or per-module copies. Global error handler is a single mapping from AppError → application/problem+json.
Zod schema, not imperative requiredEnv() / Number() casts. A malformed env var (MAX_PACKAGE_SIZE_BYTES=abc) must fail-fast with a typed error, not silently produce NaN.
Library code MUST NOT call process.exit(). Throw a typed error; let the entry point decide.
All global middleware declared in this order:
- CORS (with env-driven
ALLOWED_ORIGINS, not(origin) => origin) requestId— generate/echoX-Request-ID, attach to pino child logger- logging — structured with requestId
proxyAuthSetup()— parse NyxID proxy headersnyxidOrgLookupMiddleware()— lazy, per-request org membership lookup- route handler (with per-route
validateBody/validateQuery/validateParamsmiddleware) onError— single handler,AppError→ problem+json
ornn-api/src/domains/<resource>/
├── routes.ts HTTP layer — routing + validation middleware only
├── service.ts business logic — the only caller of repository
├── repository.ts data access — only domains/<resource>/ imports this
├── schemas.ts Zod schemas — request, response, domain types
└── [optional] <sub>/ sub-module for large domain (e.g. skills/search/)
Routes MUST NOT import repositories directly. Only services may call repositories. A lint rule (in eslint.config.js) enforces this.
All NyxID-related clients grouped under ornn-api/src/clients/nyxid/:
ornn-api/src/clients/
├── nyxid/
│ ├── base.ts shared HTTP client, SA-token provider
│ ├── llm.ts LLM gateway client
│ ├── orgs.ts org lookup
│ └── userServices.ts user-services client
├── storage.ts chrono-storage
└── sandbox.ts chrono-sandbox
NyxidSaTokenProvider is a first-class class, not an inlined closure.
- Unit tests colocated with source (
foo.ts+foo.test.ts). Pure functions, no DB. - Integration tests in
ornn-api/tests/integration/— real Mongo (via testcontainers or a spun-up instance), real HTTP. Not mocks. - Contract tests in
ornn-api/tests/contract/— assert OpenAPI spec matches handler behavior. Runs in CI. - Frontend unit tests — Vitest + Testing Library, colocated with source.
Per-test teardown is the test's responsibility; shared fixtures live in tests/fixtures/.
- Path follows
/v1/{resource}or/v1/{resource}/{action}(sub-resource for actions) - Method matches semantics (
PATCHfor partial update,DELETEreturns204,POSTcreate returns201+Location) - Collection response shape is
{ items, meta }with cursor pagination - Error response uses
application/problem+jsonwith a code from the catalog -
X-Request-IDon every response;requestIdin every error body - Query params camelCase; arrays as repeated keys;
qfor search - Required request scopes from the catalog declared in OpenAPI
security(§5.2) - Object-level authz, where the target is an owned resource, gates on the READ / WRITE / ADMIN tier (§5.4) — distinct from the route scope
- Content negotiation for multi-representation resources
- SSE events named
<resource>_<event>snake_case - Deprecation uses RFC 8594 headers