Route Publishing
Publish routes for the Plat5 gateway to discover and proxy traffic.
Pipeline
Section titled “Pipeline”Service (routes.yml) → route-registry (Postgres desired state + revision) → etcd (edge/gateway/routes/{name}) → GatewayPostgres is the source of truth. etcd is the live projection the gateway watches. Apply while gateways are down; they pick up the map on watch/reconnect.
Write path details: route-registry.md.
etcd Schema
Section titled “etcd Schema”| Key | Value | Written By |
|---|---|---|
edge/gateway/routes/{service_name} |
JSON ServiceConfig blob |
route-registry (projection) |
Gateway watches the prefix. Create/modify/delete triggers a full route reload.
Route registry
Section titled “Route registry”Long-running service. Validates config, expands route_prefix and nested methods maps, records a revision in Postgres, projects current JSON to etcd.
- Local admin URL:
http://localhost:5002 - Auth:
Authorization: Bearer <ADMIN_TOKEN> - Apply:
POST /applywith aroutes.ymlbody (JSON or YAML) — upsert of the services in the file. Services not in the file are left alone. - Identity: public identity routes are operator-owned. Catalog:
services/identity/routes.yml. Apply it (or a subset). Dev compose may seed missing services from that file on first boot; it does not overwrite. Prod does not seed.
curl -sS -X POST http://localhost:5002/apply \ -H "Authorization: Bearer dev-admin-token" \ -H "Content-Type: application/yaml" \ --data-binary @routes.ymlValidation is at apply time, and the same checks run on PUT /services/{name} and on revision restore (a restored revision must pass today’s rules). Malformed config → 422 VALIDATION_ERROR; nothing written.
- Service
urlis required and must be exactlyhttp://host:port(explicit port, no path or query), e.g.http://my-service:3000.https://→422saying TLS (https) upstreams aren’t supported yet. Barehost:port, any other scheme, a missing port, or a path/query →422 VALIDATION_ERRORnaming that form. Routes already stored with a barehost:portkeep routing (the gateway strips the scheme) until re-applied.http://user@host:port→422saying credentials aren’t allowed; a port outside 1–65535 →422saying it is out of range. A missingurl→422telling you to seturl(the CLI catches this first and points atupstreams:inplat5.yml). - A service name is 1–128 characters of letters, digits,
.,_, and-. It is an etcd key, a rate-limit bucket prefix, and theserviceon every audit event (audit.md).billing apiora/b→422. - Paths are canonical and are rejected, not rewritten: no trailing
/(except/itself) and no uppercase letters outside{params}(param names keep their case)./user/profile/and/USER/profile→422naming the canonical form (/user/profile). This applies to the full path afterroute_prefixis joined. - A path is what an audit event records as
route, and what a request can match: visible ASCII (no spaces; percent-encode anything else), at most 1024 bytes. Param names are letters, digits, and_, not starting with a digit, at most 64 characters, at most 32 per path.{project-id}→422; use{project_id}. - A route may belong to only one service, compared by shape: each
{param}matches any other{param}, a literal segment matches only the same literal (a literal never matches a param). If an incoming method + shape is already owned by a different service, the whole apply is rejected with409 ROUTE_CONFLICT; the message lists each conflicting route and its current owner (and the owner’s path when it differs, e.g.GET /a/{x} (owned by 'a2' as /a/{y})). Nothing is written.- Only the same method conflicts:
GET /a/{x}in one service andPOST /a/{y}in another are fine. - Literal vs param never conflicts:
/a/specialand/a/{x}can live in different services. - Inside one service, shapes don’t conflict:
POST /subscribers/{id}/subscriptions/changeandGET /subscribers/{id}/subscriptions/{subscription_id}apply cleanly. Re-applying a service’s own routes replaces them and is allowed.
- Only the same method conflicts:
After validation, all services in the batch commit in one Postgres transaction (each service gets a new revision). etcd projection follows; a reconciler retries if a put fails. 200 means desired state is recorded.
JSON in etcd (not YAML): registry validates and canonicalizes at write time; gateway deserializes into route types. Nested methods maps are expanded at apply so etcd methods is always a string array.
Environment Variables (route-registry)
Section titled “Environment Variables (route-registry)”| Variable | Description | Default |
|---|---|---|
ETCD_URL |
etcd client endpoint | http://localhost:2379 |
DATABASE_URL |
Postgres (schema routes) |
Required |
ADMIN_TOKEN |
Bearer token for admin API | Required |
SEED_ROUTES_DIR |
Optional; upsert missing services from YAML (dev) | empty |
PORT |
Admin API port | 5002 |
INTERNAL_PORT |
Health port | 5003 |
Config Format (routes.yml)
Section titled “Config Format (routes.yml)”Auth is scopes (public, user, organization, member).
services: my-service: url: http://my-service:3000 public: routes: - path: /public/health methods: [GET]
user: routes: - path: /widgets methods: [GET, POST] required_labels: [widgets:read]
- path: /widgets/{id} methods: [GET, DELETE] rate_limit: requests: 30 window_seconds: 60Same path, different per-verb required_labels / rate_limit — nested methods map (expanded at apply into one etcd row per verb):
- path: /features methods: GET: required_labels: [org:read] POST: required_labels: [org:write] rate_limit: requests: 100 window_seconds: 1url is exactly http://host:port, where host is anything the gateway can reach (cluster DNS name, host.docker.internal, an IP). No https:// (TLS upstreams aren’t supported yet), no path, no query; anything else is 422. How you run the process is out of scope for Plat5.
Fields
Section titled “Fields”| Field | Type | Description |
|---|---|---|
services |
map<string, ServiceConfig> |
Top-level wrapper. Keys are service names. |
url |
string |
Upstream, exactly http://host:port (e.g. http://my-service:3000). |
rate_limits |
map<string, RateLimitPolicy>? |
Optional on the service. Named policies this service’s routes may reference. |
public |
ScopeConfig? |
No authentication. |
user |
ScopeConfig? |
A person’s credential: user JWT or user API key. Subject field: user_id. |
organization |
ScopeConfig? |
A member’s credential: member API key or session. Subject field: organization_id. |
member |
ScopeConfig? |
Same credential as organization. Subject fields: organization_id, member_id. |
route_prefix |
string? |
Optional on any scope. Registry expands into each path before etcd. Not applied to upstream. |
routes |
array<RouteConfig> |
HTTP routes for this scope. |
path |
string |
Match path (/ or starts with /). Params are resource ids, not the subject. |
upstream |
string? |
Absolute path template. Omitted means proxy path unchanged. Placeholders stay in etcd; the gateway substitutes at request time. Route-level only — not per-method. |
methods |
array<string> | map<string, MethodConfig> |
List form: allowed HTTP methods. Map form: per-verb config (see below). Do not mix list and map on the same route (422). |
required_labels |
string[]? |
Optional. Omitted = any admitted principal. If set, a member credential must hold * or share at least one label: any one of the list, not all. User credentials skip. Cannot include *. Validated at apply. Route-level value applies only to the flat methods list. |
rate_limit |
false | {requests, window_seconds} | string | omitted |
Omitted inherits the gateway fallback. false opts out (unlimited). Object = this route+method only. String = named policy on this service. Limiter subject follows route scope (public→ip, user→user_id, organization→organization_id, member→member_id). Route-level value applies only to the flat methods list. |
audit |
bool? |
user, organization, and member routes. Omitted = method default: POST / PUT / PATCH / DELETE audited, GET / HEAD not. true / false overrides. Route-level value applies only to the flat methods list. See audit. |
A service must define at least one scope. Multiple scopes may be present.
methods
Section titled “methods”Two forms. Do not mix them on the same route (422).
Flat list. Optional route-level required_labels / rate_limit / audit apply to every method in the list.
- path: /features methods: [GET, POST] required_labels: [org:read] rate_limit: { requests: 60, window_seconds: 60 }Nested map. Each key is an uppercase HTTP verb (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). The body may set required_labels, rate_limit, and/or audit for that verb only. An empty body (GET: or GET: {}) means that method with no extra constraints. An empty methods map is rejected.
- path: /features methods: GET: required_labels: [org:read] POST: required_labels: [org:write] rate_limit: requests: 100 window_seconds: 1Nested maps are an apply-time YAML convenience. Registry prepare_for_registry / prefix expand turns each verb into its own RouteConfig row (same path, methods: [THAT_VERB], required_labels / rate_limit / audit taken from that method entry). upstream stays on the path. After expand, etcd methods is always a string array. Duplicate path+method after expand → 422.
Labels are opaque. org:write does not imply org:read.
required_labels
Section titled “required_labels”Labels follow the same hygiene as roles: [a-z0-9:._-]+, max 64 chars, max 32, unique, non-empty list if present.
After admission and the rate limit: if the route has required_labels, a member credential’s labels must hold * or share a label with the route, or the gateway returns 403 FORBIDDEN. [a, b] means a or b. labels: ["*"] (a ["*"] role, or roles off) passes. labels: [] cannot intersect — 403 on these routes, still admitted on unlabeled routes.
A credential carries its principal’s labels. For a member key or session, validate’s list is the member’s role labels. Keys and sessions cannot narrow it. A role is how a deployment gives members labels: roles.md. User credentials have no role and skip, so a label on a user route constrains nothing. Put labels on organization and member routes. The 403 details.required_labels echoes the route’s list (gateway-contract.md).
rate_limit
Section titled “rate_limit”Applies to all admitted routes (JWT, API key, and session). Counters live in Valkey — replicas share one budget. VALKEY_URL is required to boot. Valkey error or timeout on a limited request → 503 SERVICE_UNAVAILABLE. The gateway opens a new connection when Valkey answers again.
Fixed window: the first increment opens the window; key TTL is window_seconds. Restarting Valkey resets open windows.
Route field (rate_limit on a route or nested method):
| YAML | Effect |
|---|---|
| omitted | Inherit RATE_LIMIT_REQUESTS / RATE_LIMIT_WINDOW_SECONDS. 0 requests = unlimited fallback. |
false |
Unlimited for this route / verb |
{requests, window_seconds} |
Unique bucket for this route+method only. Both must be > 0. |
| policy name (string) | Named policy defined on this service. true is invalid. |
A name on a flat methods list puts every verb in that list on the named bucket. An inline object on a flat list still uses per-method buckets (GET /features ≠ POST /features) with the same numbers.
Named policies stay names in etcd (rate_limit: writes plus the service rate_limits map). Apply does not substitute {requests, window_seconds}. The gateway resolves the bucket at request time.
Named policies (rate_limits)
Section titled “Named policies (rate_limits)”Optional map on the service. Routes may only reference a name defined on that service. Unknown name → 422.
services: projects: url: http://projects:3000 rate_limits: writes: requests: 30 window_seconds: 60 org-writes: requests: 30 window_seconds: 60 shared: true organization: routes: - path: /projects methods: [POST] rate_limit: writes - path: /projects/{project_id} methods: [DELETE] rate_limit: org-writes| Field | Rule |
|---|---|
| map key | Policy name. [a-z0-9:._-]+, max 64. Unique in the service. |
requests / window_seconds |
Required. Both > 0. |
shared |
Optional bool. Omit / false: this service only. true: same bucket across services that declare the name. |
Buckets (subject appended):
| Policy | Bucket |
|---|---|
| omitted inherit / inline object | {METHOD} {path} {subject} |
| name, not shared | {service}:{name}:{subject} |
name, shared: true |
{name}:{subject} |
Limiter subject follows route scope and is not configurable: public→ip, user→user_id, organization→organization_id, member→member_id. Every organization route shares one bucket per org, so a runaway service account can throttle the people in its org. Give a heavy caller’s routes their own named policy.
shared: true — opt-in cross-service join. Every service that uses the name declares the same table entry (requests, window_seconds, shared: true). Apply and PUT /services/{name} validate against all current services in Postgres, not only the payload.
- Same shared name, different numbers → 422
- Same name, one
shared: trueand one local → 422 - Local
writeson two services → independent buckets; numbers may differ - Inline object is its own bucket (
{METHOD} {path} {subject}) - One service claiming a shared name with no other consumer is fine
Exceed → 429 RATE_LIMITED, Retry-After, details.retry_after_seconds. Admitted limited routes also set X-RateLimit-Limit / Remaining / Reset (success and 429). See api-errors.md and gateway-contract.md.
A separate failed-auth IP limiter (RATE_LIMIT_AUTH_FAILURE_*) covers unadmitted 401s and unmatched 404s. It is not per-route.
The limiter runs before required_labels. A label-denied 403 counts against the route’s limit.
Whether the gateway records this route+method in the audit log of the realm that owns what it changes: the org’s, or on a user route the person’s. Model, delivery, and the event: audit.md.
| YAML | Effect |
|---|---|
| omitted | Method default: POST, PUT, PATCH, DELETE audited. GET, HEAD not |
true |
Audited. Use it on a read worth recording, e.g. one that returns secrets |
false |
Not audited. Use it on a high-volume write nobody needs in the log |
user, organization, and member routes. On public → 422: that scope has no realm to log into. OPTIONS is answered as a preflight and never audited.
Omitted stays omitted in etcd. The gateway applies the method default. With audit off for the deployment, the gateway ignores the field.
upstream
Section titled “upstream”path is the match. Its params are resource ids. upstream is an absolute template. Omitted means proxy path unchanged: the upstream has no subject id. A route that needs the subject sets upstream.
user: routes: - path: /user/foos/{foo_id} upstream: /users/{subject.user_id}/foos/{path.foo_id} methods: [GET]
organization: routes: - path: /org/projects/{project_id} upstream: /organizations/{subject.organization_id}/projects/{path.project_id} methods: [GET, POST]
member: routes: - path: /member/api-keys upstream: /members/{subject.member_id}/api-keys methods: [GET, POST]route_prefix expands path only, at apply, before etcd. upstream is not prefixed. etcd stores the full path and the upstream template with placeholders still in it. The gateway substitutes at request time. The registry does not.
Apply-time 422:
subject.*is onlyuser_id,organization_id,member_id, and only a field that scope has.path.*names a param of the expandedpath.- A bare
{foo_id}inupstreamis rejected. The namespace is the point. pathmust not contain a param whose name is a subject field of that scope.userforbids{user_id}.organizationforbids{organization_id}.memberforbids{organization_id}and{member_id}.upstreammust be omitted or an absolute path (starts with/, no?or#).
{organization_id} on a user route is a resource id. It is not admission input.
Substituted values are one path segment. Reject /, ?, #. A bad path param is 400. A bad subject id is 500. Query string is preserved.
Unknown fields on the route schema are 422.
Scopes
Section titled “Scopes”| Scope | Auth | Subject fields |
|---|---|---|
public |
No | none |
user |
User JWT or user API key | user_id |
organization |
Member API key or session | organization_id |
member |
Same credential as organization |
organization_id, member_id |
Subject fill and service rules: gateway-contract.md. Layer boundary and admission errors: identity-boundary.md.
Gateway Behavior
Section titled “Gateway Behavior”Startup
Section titled “Startup”- Connect to etcd (
ETCD_URL, defaulthttp://localhost:2379). - Load all keys under
edge/gateway/routes/. - Parse JSON, validate, build
RouteMap. - Fail fast if etcd unreachable — no start without a route registry store.
Runtime Updates
Section titled “Runtime Updates”- Watch
edge/gateway/routes/. - On any event: full reload of all routes (small set; simpler than incremental).
- Reload failure → keep current routes, log warning.
Service Health vs Route Existence
Section titled “Service Health vs Route Existence”Decoupled. Service down → gateway still knows the route → 502 SERVICE_UNAVAILABLE. Missing route (nothing registered that path) → 404.
organization and member
Section titled “organization and member”Business APIs that should not own membership storage. The credential is a member of the org. The client does not name the subject.
organization fills organization_id. member fills that and member_id. Admission steps and errors: identity-boundary.md.
User-subject identity routes stay on user scope. Identity routes whose subject is the org or the member are published on those scopes. The catalog is services/identity/routes.yml.
route_prefix (optional, any scope)
Section titled “route_prefix (optional, any scope)”Joined with each route path so configs stay short.
Join rule: path must be / (exactly the prefix) or start with /. Empty path invalid. When path is /, full path is the prefix with trailing slashes stripped.
Expand site (locked): Registry expands route_prefix + path and nested methods maps before writing etcd. Registry stores full paths only and list-form methods only — one expand site, no gateway/registry drift.
upstream is an absolute path template (not relative to route_prefix).
Examples
Section titled “Examples”identity catalog
Section titled “identity catalog”Apply services/identity/routes.yml or a subset. Edge paths are /user, /org, and /member. upstream fills the identity URL. GET /organizations, GET /users, PATCH /users/{user_id}/status, and GET /members/{member_id}/user are not in the catalog: they are for staff.
Business service
Section titled “Business service”services: projects: url: http://projects:3000 organization: route_prefix: /api routes: - path: /projects upstream: /organizations/{subject.organization_id}/projects methods: [GET, POST] - path: /projects/{project_id} upstream: /organizations/{subject.organization_id}/projects/{path.project_id} methods: [GET, PATCH, DELETE] required_labels: [projects:write] rate_limit: requests: 20 window_seconds: 60The handler reads organization_id from the rewritten path. The client path has no subject id.
Validation Rules
Section titled “Validation Rules”Registry validates before etcd. Gateway validates again at load (expanded list form):
- Service name: 1–128 of
[A-Za-z0-9._-](registry) pathnot empty; must be/or start with/- Expanded
path: visible ASCII, at most 1024 bytes; at most 32 params, each[A-Za-z_][A-Za-z0-9_]*, at most 64 characters (registry) methodsnot empty (list or non-empty map)- Nested
methodsmap keys:GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS - Do not mix methods list and map on the same route (
422) - Nested maps expand at apply; duplicate
path+method after expand →422 - At least one scope (
public,user,organization, and/ormember) - Unknown fields 422
upstreamif present: absolute path;subject.*only a field that scope has;path.*names a param of the expanded path; bare{foo}rejected- Expanded
pathmust not contain a subject-field param of that scope route_prefixjoin rules at registry; etcd stores full paths only;upstreamis not prefixedrequired_labelsif present: non-empty,[a-z0-9:._-]+, max 64 chars, max 32, uniquerate_limitif object:requests> 0,window_seconds> 0.trueis invalidrate_limitif string: names a policy on this service; policy name[a-z0-9:._-]+, max 64rate_limitskeys: same hygiene, unique; valuesrequests> 0,window_seconds> 0auditif present: a boolean, on auser,organization, ormemberroute or method only- Shared policy names: same name across services must agree on
requests,window_seconds, andshared(full desired state, apply and PUT) - Duplicate service names in merged registry: first wins, warning
- Malformed JSON values skipped with warning; other routes continue
The two copies of route_config.rs (gateway and route-registry) stay aligned.
Platform Integrity
Section titled “Platform Integrity”Auth delegation: gateway-contract.md. Layer split: identity-boundary.md. Admin write path: route-registry.md.