Skip to content

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.

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." }] }
}
}
  • code comes from a closed set (VALIDATION_ERROR, FORBIDDEN, NOT_FOUND, RATE_LIMITED, …). Clients branch on it and the HTTP status.
  • message is one sentence, safe to show in a UI. Clients show it as it is.
  • details adds 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.

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_id on 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.

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.

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: 01JD8Y2N5R7T9V1X3Z5B7D9F1H

At 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/live and /health/ready on a separate internal port (INTERNAL_PORT), answering {"status":"healthy"}. unhealthy is a 503.
  • Prometheus metrics at /metrics on the same port, named like http_requests_total and http_request_duration_seconds.
  • JSON logs on stdout, with snake_case fields: request_id, route, method, status, duration_ms, organization_id.
  • OpenTelemetry traces over OTLP, with W3C trace context.

Reference: Health checks, Telemetry.

  • Paths are kebab-case plural nouns, with no /api prefix 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 are UPPER_SNAKE_CASE.
  • Labels (permissions) are lowercase, like projects:write.

Reference: Naming conventions.