Standards
Plat5 makes a set of decisions once, so your team doesn’t make them again for every service. The gateway and Plat5’s own services follow them, and so do the starters. A service that begins from a starter matches the rest from its first commit.
Errors
Section titled “Errors”Every error is one envelope:
{ "error": { "type": "invalid_request_error", "code": "VALIDATION_ERROR", "message": "Name is required.", "request_id": "7d6f1c2e-4b0a-4e8e-9a51-3f2b8c1d9e04", "details": { "fields": [{ "path": "name", "message": "Name is required." }] } }}codecomes from a closed set (VALIDATION_ERROR,FORBIDDEN,NOT_FOUND,RATE_LIMITED, …). Clients branch on it and the HTTP status.messageis one sentence, safe to show in a UI. Clients show it as it is.detailsadds context, like the fields of a 422 or the labels a 403 needed. It is never the only place the sentence lives.
The gateway’s own errors (401, 403, 429, 503) use it. Return the same from your service. Reference: API errors, error messages.
Request ids
Section titled “Request ids”The gateway gives every request an X-Request-ID and returns it on every response. The same id is in:
- the error body, as
request_id - the gateway’s log line for the request
- your service’s log lines, when it logs the header it receives
- the trace, as
request_idon the request span - the audit event, which you can look up with
GET /org/audit-events?request_id=…
Don’t make your own and don’t set it on responses. Pass traceparent on to anything you call, so the trace follows.
limit |
Default 50, max 100. Over the max is clamped |
starting_after |
The id of the last item on the previous page |
| Body | The collection under its plural name, and has_more |
| Order | By id. Ids are ULIDs, so that is oldest first. The audit log runs newest first |
{ "projects": [ … ], "has_more": true }Plat5’s lists work this way, and so do the starters’. Reference: Lists and pagination.
Rate limits
Section titled “Rate limits”Limited routes return X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Over the limit is a 429 RATE_LIMITED with Retry-After and details.retry_after_seconds. Your service does nothing: the gateway sets them. Reference: Routes.
Audit details
Section titled “Audit details”On a write, your service says what changed in X-Plat5-Audit-Details: one JSON object, with each change as from and to. On a create, it sends the new id in X-Plat5-Audit-Created.
X-Plat5-Audit-Details: {"name":{"from":"Launch","to":"Launch v2"}}X-Plat5-Audit-Created: 01JD8Y2N5R7T9V1X3Z5B7D9F1HAt most 4096 bytes, and never a secret. Plat5 stores them with the event and removes them before the response reaches the client. See Audit logs.
Health, metrics and logs
Section titled “Health, metrics and logs”/health/liveand/health/readyon a separate internal port (INTERNAL_PORT), answering{"status":"healthy"}.unhealthyis a 503.- Prometheus metrics at
/metricson the same port, named likehttp_requests_totalandhttp_request_duration_seconds. - JSON logs on stdout, with
snake_casefields:request_id,route,method,status,duration_ms,organization_id. - OpenTelemetry traces over OTLP, with W3C trace context.
Reference: Health checks, Telemetry.
- Paths are
kebab-caseplural nouns, with no/apiprefix and no version segment. Paths clients call start with their scope:/public/…,/user/…,/org/…,/member/…. The route registry refuses a path with uppercase letters or a trailing slash. - Fields, log fields and metrics are
snake_case. Error codes areUPPER_SNAKE_CASE. - Labels (permissions) are lowercase, like
projects:write.
Reference: Naming conventions.