@agentsfleet/orly 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +42 -0
- package/.githooks/pre-push +67 -0
- package/AGENTS.md +348 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/SOUL.md +90 -0
- package/audits/agents-md.md +497 -0
- package/audits/agents-md.sh +353 -0
- package/audits/cross-tier-rates.sh +105 -0
- package/audits/data.sh +185 -0
- package/audits/deinit-pairs.sh +224 -0
- package/audits/design-tokens.sh +143 -0
- package/audits/doc-read.sh +183 -0
- package/audits/error-codes.sh +221 -0
- package/audits/fixtures/clean.diff +15 -0
- package/audits/fixtures/dirty.diff +16 -0
- package/audits/lifecycle-anchors.sh +22 -0
- package/audits/logging.sh +217 -0
- package/audits/msid-ui.sh +144 -0
- package/audits/parity-dispatch.sh +51 -0
- package/audits/rule-ledger-lib.sh +264 -0
- package/audits/rule-ledger.sh +225 -0
- package/audits/rule-paths.sh +108 -0
- package/audits/spec-template.sh +258 -0
- package/audits/sql-mod.sh +102 -0
- package/audits/ufs.sh +303 -0
- package/bin/orly +26 -0
- package/core/operating-model.md +256 -0
- package/dispatch/edit_rules.md +69 -0
- package/dispatch/lib.sh +213 -0
- package/dispatch/lifecycle.md +187 -0
- package/dispatch/name_architecture.md +93 -0
- package/dispatch/verify.md +111 -0
- package/dispatch/write_any.md +740 -0
- package/dispatch/write_any.sh +49 -0
- package/dispatch/write_auth.md +36 -0
- package/dispatch/write_changelog.md +64 -0
- package/dispatch/write_documentation.md +45 -0
- package/dispatch/write_go.md +24 -0
- package/dispatch/write_http.md +45 -0
- package/dispatch/write_mdx.md +7 -0
- package/dispatch/write_python.md +8 -0
- package/dispatch/write_rust.md +38 -0
- package/dispatch/write_shell.md +8 -0
- package/dispatch/write_spec.md +105 -0
- package/dispatch/write_sql.md +112 -0
- package/dispatch/write_sql.sh +36 -0
- package/dispatch/write_ts_adhere_bun.md +493 -0
- package/dispatch/write_ts_adhere_bun.sh +37 -0
- package/dispatch/write_zig.md +852 -0
- package/dispatch/write_zig.sh +40 -0
- package/docs/CHANGELOG_VOICE.md +18 -0
- package/docs/DISPATCH_ARCHITECTURE.md +664 -0
- package/docs/DISPATCH_REVIEW_DISPOSITION.md +65 -0
- package/docs/DOCUMENTATION_RULES.md +209 -0
- package/docs/EXECUTE_DOC_READS.md +29 -0
- package/docs/HARNESS_VERIFY_OUTPUT.md +40 -0
- package/docs/LIFECYCLE_PATTERNS.md +316 -0
- package/docs/LOGGING_STANDARD.md +397 -0
- package/docs/ORLY_ARCHITECTURE.md +129 -0
- package/docs/REST_API_DESIGN_GUIDELINES.md +783 -0
- package/docs/RULE_ENFORCEMENT.md +41 -0
- package/docs/SCHEMA_CONVENTIONS.md +93 -0
- package/docs/TEMPLATE.md +411 -0
- package/docs/VERIFY_TIERS.md +57 -0
- package/docs/greptile-learnings/README.md +51 -0
- package/docs/greptile-learnings/RULES.md +1045 -0
- package/fixtures/registry-invalid.json +1 -0
- package/fixtures/registry-valid.json +18 -0
- package/fixtures/unclosed-pack-block.md +8 -0
- package/package.json +63 -0
- package/packs/language/go/rules.md +24 -0
- package/packs/language/mdx/rules.md +7 -0
- package/packs/language/python/rules.md +8 -0
- package/packs/language/rust/rules.md +38 -0
- package/packs/language/shell/rules.md +8 -0
- package/registry.json +405 -0
- package/schemas/evidence.schema.json +43 -0
- package/schemas/profile.schema.json +12 -0
- package/schemas/registry.schema.json +12 -0
- package/skills/orly-babysit-prs/SKILL.md +326 -0
- package/skills/orly-spec-new/SKILL.md +157 -0
- package/skills/orly-write-integration-test/SKILL.md +470 -0
- package/skills/orly-write-unit-test/SKILL.md +520 -0
- package/src/cli.test.ts +70 -0
- package/src/cli.ts +287 -0
- package/src/config.test.ts +226 -0
- package/src/config.ts +288 -0
- package/src/criteria.test.ts +137 -0
- package/src/criteria.ts +166 -0
- package/src/criteria_spec.ts +127 -0
- package/src/criteria_support.ts +53 -0
- package/src/gates.test.ts +183 -0
- package/src/gates.ts +169 -0
- package/src/gates_spec.test.ts +98 -0
- package/src/gates_test_support.ts +194 -0
- package/src/git_env.ts +20 -0
- package/src/install.test.ts +514 -0
- package/src/install.ts +447 -0
- package/src/install_nested.test.ts +32 -0
- package/src/model.test.ts +31 -0
- package/src/model.ts +245 -0
- package/src/references.test.ts +47 -0
- package/src/references.ts +147 -0
- package/src/render.test.ts +60 -0
- package/src/render.ts +94 -0
- package/src/surfaces.test.ts +43 -0
- package/src/surfaces.ts +89 -0
- package/src/validation.test.ts +11 -0
- package/src/validation.ts +87 -0
- package/src/verify.test.ts +13 -0
- package/src/verify.ts +59 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# REST API Design Guidelines — `agentsfleet/agentsfleetd`
|
|
2
|
+
|
|
3
|
+
**Status:** Canonical instruction set. Read this before adding, modifying, or removing any HTTP endpoint.
|
|
4
|
+
**Trigger:** the global instruction `HTTP handler or OpenAPI changes → read docs/REST_API_DESIGN_GUIDELINES.md first` fires when the diff touches `src/agentsfleetd/http/handlers/**`, `public/openapi/**`, or any `route_*` file. If you're an agent reading this — you got here because that trigger fired. Follow this doc as a checklist, not as background reading.
|
|
5
|
+
|
|
6
|
+
This is a goal-oriented instruction set. Each rule states the goal it serves so you can apply judgment at the edge cases instead of memorizing exceptions.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Audience — who this binds
|
|
11
|
+
|
|
12
|
+
This doc binds **whoever ships the change** — Kishore directly, OR an agent acting on Kishore's behalf in auto mode under a start-instruction (per `~/.claude/CLAUDE.md` autonomy rules). Concretely:
|
|
13
|
+
|
|
14
|
+
- **The agent** runs the Quick checklist below as part of `CHORE(close)` and the §10 pre-PR gate, opens the PR via `gh pr create`, and answers `orly-babysit-prs` review feedback. The agent is the primary enforcer.
|
|
15
|
+
- **Kishore** opens the PR directly when working without an agent. Same checklist applies.
|
|
16
|
+
- **A reviewer** (human or `/review`) checks the PR against this doc adversarially. A red box on the checklist that's not justified in the PR description is grounds to block merge.
|
|
17
|
+
|
|
18
|
+
When this doc says "you" it means the agent or the human author — same rules either way. When it says "MUST" it means a missing or wrong implementation blocks merge; "SHOULD" means deviate only with a one-line rationale in the PR description.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Quick checklist — adding an endpoint
|
|
23
|
+
|
|
24
|
+
Run this checklist as part of `CHORE(close)` (per `~/.claude/CLAUDE.md` lifecycle), before `gh pr create`. Every box must be checked, OR the PR description must call out the deviation with a reason. An unchecked box that the description ignores blocks merge.
|
|
25
|
+
|
|
26
|
+
- [ ] **URL design** — plural noun resource, hierarchical path, no verbs (§1); operation-style `:verb` declared as one of the three allowed categories (§1)
|
|
27
|
+
- [ ] **Path params + trailing slash** — `{resource_id}` matches body field name; no trailing slash (§1)
|
|
28
|
+
- [ ] **HTTP method** chosen by semantics; PATCH idempotency guarantee stated; `Idempotency-Key` honored if applicable (§2)
|
|
29
|
+
- [ ] **Long-running ops** use the canonical `202 + /v1/operations/{id}` shape (§2)
|
|
30
|
+
- [ ] **Request body shape** matches the path (no path-param IDs in body) (§3)
|
|
31
|
+
- [ ] **Pagination** uses Stripe-style `?starting_after=&limit=` with `next_cursor` response field; default 50, max 100 (§3)
|
|
32
|
+
- [ ] **List envelope** is exactly `{items, total: int|null, next_cursor: string|null}` — no synonyms (§3)
|
|
33
|
+
- [ ] **Bulk endpoints** use `207` with the canonical per-item shape (§3)
|
|
34
|
+
- [ ] **Null vs omit** — absent optionals omitted, `null` reserved for "explicitly cleared" (§3)
|
|
35
|
+
- [ ] **Datetime fields** are int64 epoch ms with `_at` suffix (NOT ISO 8601) (§4)
|
|
36
|
+
- [ ] **Duration fields** are integers with `_ms` or `_seconds` suffix; no bare `timeout`/`ttl`/`duration` (§4)
|
|
37
|
+
- [ ] **Status codes** — 409 includes `current_state`; 412 includes `etag`; 429 includes `Retry-After` + `X-RateLimit-*` (§4)
|
|
38
|
+
- [ ] **ETag/`If-Match`** wired for any resource with realistic concurrent edits (§4)
|
|
39
|
+
- [ ] **Error responses** use the registry; `detail` follows hygiene rules (no IDs, no SQL, no paths, ≤200 chars) (§5)
|
|
40
|
+
- [ ] **OpenAPI YAML** edited under `public/openapi/paths/<tag>.yaml`; tag is 1:1 with resource; file ≤400 lines (§6)
|
|
41
|
+
- [ ] **Route registered** in all six places (§7)
|
|
42
|
+
- [ ] **Handler signature** matches `inner*(hx: Hx, req: *httpz.Request, ...)` (§8)
|
|
43
|
+
- [ ] **Middleware policy** picked from the table; raw handlers carry first-10-lines comment (§7)
|
|
44
|
+
- [ ] **Versioning** — added/renamed/removed surface listed in PR description; deprecation uses `Deprecation` + `Sunset` headers; new response fields declare `x-stability` (§9)
|
|
45
|
+
- [ ] **Tests** — happy path + one error per `hx.fail` + idempotency double-PATCH + `Idempotency-Key` replay (where applicable) + ETag mismatch (§10)
|
|
46
|
+
- [ ] **Logging** — sensitive ID values are DEBUG-only or carry `// log-id-allowed:` comment; secret-shaped fields are write-only or one-time-read (§11)
|
|
47
|
+
- [ ] **`make check-openapi` clean** — bundle in sync, redocly lint, error-schema + URL-shape checks pass (§6)
|
|
48
|
+
- [ ] **`make test-unit-agentsfleetd` clean** — `route_scopes.zig`'s exhaustive switch + `route_scopes_test.zig` assertions cover the auth gate matrix (§10)
|
|
49
|
+
- [ ] **No file over 350 lines** (§10)
|
|
50
|
+
- [ ] **`gitleaks detect` clean** (§10)
|
|
51
|
+
|
|
52
|
+
If you're skimming, the load-bearing sections for handler authoring are §7 (route registration) and §8 (handler signature). Read those carefully every time.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## §1 — URL design
|
|
57
|
+
|
|
58
|
+
### Use plural nouns for resources
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
GET /products ← collection
|
|
62
|
+
GET /products/{id} ← single resource
|
|
63
|
+
POST /products ← create
|
|
64
|
+
PATCH /products/{id} ← partial update
|
|
65
|
+
DELETE /products/{id} ← remove
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
NOT `/getProducts`, `/createProduct`, `/product`.
|
|
69
|
+
|
|
70
|
+
**Goal:** RESTful conventions; OpenAPI generators (Stainless, Speakeasy, OpenAPI Generator) produce clean SDKs from this shape.
|
|
71
|
+
|
|
72
|
+
### Reflect ownership in the path
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
GET /workspaces/{ws_id}/agents/{agent_id}/events
|
|
76
|
+
POST /workspaces/{ws_id}/agents
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Nested paths express the containment relationship. Don't flatten — `/agents?workspace_id=...` loses the rule that the agent belongs to that workspace.
|
|
80
|
+
|
|
81
|
+
### No verbs in URLs
|
|
82
|
+
|
|
83
|
+
- Bad: `/users/{id}/activate`, `/agents/{id}/start`
|
|
84
|
+
- Good: `PATCH /users/{id}` body `{status: "active"}`, `POST /agents/{id}/events` (event resource creation)
|
|
85
|
+
|
|
86
|
+
**Operation-style endpoints** — the `POST /v1/.../approvals/{gate_id}:approve` colon form is allowed ONLY when the action falls into one of these three categories, named explicitly in the OpenAPI `description`:
|
|
87
|
+
|
|
88
|
+
1. **Idempotent retry** — re-running converges to the same end state (e.g. `:retry`, `:resync`).
|
|
89
|
+
2. **Side-effecting RPC** — the action sends/produces something the caller can't undo by editing a state field (e.g. `:send`, `:rotate`, `:revoke`).
|
|
90
|
+
3. **Multi-resource transaction** — the action atomically touches resources beyond the path's primary resource.
|
|
91
|
+
|
|
92
|
+
Anything else MUST be modeled as `PATCH /resource/{id}` with a state field. "Convenience" is not a category. If you can't pick one of the three in one sentence, you don't have an operation.
|
|
93
|
+
|
|
94
|
+
**Collision check** — before adding `POST /v1/.../{id}:verb`, grep the same resource's schema in `public/openapi/components/schemas.yaml` for an existing lifecycle field (`status`, `state`, `stage`, `lifecycle_state`). If one exists AND the new `:verb` would set it to a value that field can already hold, the operation is forbidden — use `PATCH /resource/{id}` body `{status: "<verb>"}` instead. Adding `:approve` when `status` already has an `approved` value is the canonical anti-pattern. The PR description MUST state the result of this grep ("no `status` field on `Approval`" or "`Approval.status` exists but `approved` is not a settable value via PATCH because <reason>").
|
|
95
|
+
|
|
96
|
+
### Path-param naming consistency
|
|
97
|
+
|
|
98
|
+
`{agent_id}` not `{id}`, not `{agentId}`, not `{aid}`. The path-param name matches the field name in the resource body. One spelling per concept across the whole API.
|
|
99
|
+
|
|
100
|
+
### Trailing slash
|
|
101
|
+
|
|
102
|
+
URLs are canonical without a trailing slash: `/v1/agents`, not `/v1/agents/`. Requests with a trailing slash MUST `308 Permanent Redirect` to the canonical form (preserves method + body). No silent 404, no silent rewrite.
|
|
103
|
+
|
|
104
|
+
### Tag mapping is 1:1 with resource
|
|
105
|
+
|
|
106
|
+
Every top-level resource maps to exactly one OpenAPI tag, and each tag maps to one `paths/<tag>.yaml` file. Don't split a resource across multiple tags ("Agents" + "AgentEvents"); don't merge resources under one tag ("Workspaces and Agents"). Sub-resources live under the parent tag's file unless that file hits the §6 400-line cap, at which point you split by sub-resource and document the split in `root.yaml`.
|
|
107
|
+
|
|
108
|
+
### Resource ID lives in path, not body
|
|
109
|
+
|
|
110
|
+
```http
|
|
111
|
+
PATCH /products/123
|
|
112
|
+
body: { "name": "new name" } ← do NOT include "id": 123 in body
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If the parent ID is in the path, never repeat it in the body.
|
|
116
|
+
|
|
117
|
+
### Field naming (Microsoft-aligned)
|
|
118
|
+
|
|
119
|
+
- **Plural for collections, singular for items**
|
|
120
|
+
- **`_id` suffix** for identifiers (`product_id`, `agent_id`) — never bare `id` except as the resource's own primary key field
|
|
121
|
+
- **`_at` suffix** for datetimes; values are int64 epoch milliseconds (§4)
|
|
122
|
+
- **Unit suffix** for durations: `_ms` or `_seconds` (§4) — never bare `timeout`/`ttl`/`interval`/`duration`/`expiration`
|
|
123
|
+
- **Adjectives before nouns** (`completed_items`, not `items_completed`)
|
|
124
|
+
- **No `is_` prefix** on booleans (`enabled`, not `is_enabled`)
|
|
125
|
+
- **Include units** in any quantitative field (`size_in_bytes`, `wall_ms`, `expiration_days`)
|
|
126
|
+
- **Banned field names** (not "avoid" — banned): `data`, `payload`, `object`, `response`, `result`, `value`, `info`, `metadata` as standalone field names. They convey nothing and break SDK ergonomics. Use a domain-specific noun.
|
|
127
|
+
- **Avoid** brand names and reserved words
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## §2 — HTTP method semantics
|
|
132
|
+
|
|
133
|
+
| Method | Use for | Idempotent |
|
|
134
|
+
|--------|---------|------------|
|
|
135
|
+
| `GET` | Retrieve resource(s) | Yes |
|
|
136
|
+
| `POST` | Create new resource (server assigns ID) OR operation-style endpoint | No |
|
|
137
|
+
| `PUT` | Replace a resource fully (or upsert when client supplies the ID) | Yes |
|
|
138
|
+
| `PATCH` | Partially update a resource | Default: yes. State the guarantee in the spec. |
|
|
139
|
+
| `DELETE` | Remove a resource | Yes (idempotent: 204 on already-deleted) |
|
|
140
|
+
|
|
141
|
+
**PATCH idempotency guarantee** — PATCH MUST be idempotent unless the spec's "Failure Modes" section explicitly declares otherwise with a reason. Idempotent PATCH means: issuing the same body twice in succession produces identical row state and identical 200 responses. A negative test that issues the same PATCH twice and asserts equality is required (§10).
|
|
142
|
+
|
|
143
|
+
**Idempotency keys for non-idempotent POSTs.** Any POST that creates a billable, externally-visible, or side-effecting resource MUST accept an `Idempotency-Key` request header (UUIDv7 from the client). The server stores `(workspace_id, key) → response` for at least 24 hours and replays the prior response on duplicate. Endpoints exempt: pure RPC reads, internal-only POSTs. List which endpoints honor the key in the OpenAPI description.
|
|
144
|
+
|
|
145
|
+
<!-- oracle-packs:start product.agentsfleet -->
|
|
146
|
+
**Owner-approved workspace exception.** `POST /v1/workspaces` accepts no replay
|
|
147
|
+
key. It requires a tenant-unique name and performs one tenant-scoped insert with
|
|
148
|
+
a server-assigned ID. After an uncertain browser response, the dashboard
|
|
149
|
+
refreshes every cursor page from `GET /v1/tenants/me/workspaces`; the
|
|
150
|
+
command-line interface performs one exact-name GET and does not retry the POST.
|
|
151
|
+
A deliberate same-name retry returns 409 and cannot create a second row. Do not
|
|
152
|
+
generalize this exception to other POST endpoints.
|
|
153
|
+
<!-- oracle-packs:end -->
|
|
154
|
+
|
|
155
|
+
**When in doubt:** if the client supplies the ID and the body fully describes the resource, use PUT. If the server assigns the ID, use POST. If only some fields change, use PATCH.
|
|
156
|
+
|
|
157
|
+
### Long-running operations
|
|
158
|
+
|
|
159
|
+
Endpoints that can't complete inside the request window MUST follow this convention — no per-endpoint reinvention:
|
|
160
|
+
|
|
161
|
+
1. The kicking POST returns `202 Accepted` with `Location: /v1/operations/{operation_id}` and a body containing `operation_id` and `status: "pending"`.
|
|
162
|
+
2. `GET /v1/operations/{operation_id}` returns:
|
|
163
|
+
```json
|
|
164
|
+
{ "operation_id": "...", "done": false, "status": "running", "started_at": 1735689600000 }
|
|
165
|
+
```
|
|
166
|
+
When `done: true`, the body carries either `result` (the final resource, inline) or `error` (an RFC 7807 envelope per §5).
|
|
167
|
+
3. Terminal states are `succeeded`, `failed`, `cancelled`. No others.
|
|
168
|
+
|
|
169
|
+
If you need a different shape, amend this doc first. Do not invent a parallel polling URL.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## §3 — Request body shape
|
|
174
|
+
|
|
175
|
+
### List response envelope
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"items": [ {...}, {...} ],
|
|
180
|
+
"total": 42,
|
|
181
|
+
"next_cursor": "eyJ0Ijoi..."
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The exact key set is `items`, `total`, `next_cursor` — no synonyms. `results`, `data`, `entries`, `nodes`, `records` are forbidden.
|
|
186
|
+
|
|
187
|
+
<!-- oracle-packs:start product.agentsfleet -->
|
|
188
|
+
Security-bound exception: `GET /v1/tenants/me/workspaces` also returns the
|
|
189
|
+
authoritative `tenant_id`. Browser and command-line clients persist that value
|
|
190
|
+
with the workspace list so a refreshed identity cannot mix local state from
|
|
191
|
+
two tenants.
|
|
192
|
+
<!-- oracle-packs:end -->
|
|
193
|
+
|
|
194
|
+
- `items` — always present, always an array. Empty result is `200 {"items": []}` — never `204`.
|
|
195
|
+
- `total` — always present as an `integer | null` field. `null` means "not computed" (page-bounded count would have been too expensive). The OpenAPI schema MUST declare `nullable: true`. Removing the field entirely is forbidden — it forces SDK consumers into branches.
|
|
196
|
+
- `next_cursor` — opaque string when more pages exist, `null` on the last page. Always present.
|
|
197
|
+
|
|
198
|
+
### Single resource response
|
|
199
|
+
|
|
200
|
+
The resource itself, no envelope:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"id": "01HZQ...",
|
|
205
|
+
"name": "platform-ops",
|
|
206
|
+
"created_at": 1735689600000
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
NOT `{ "data": { ... } }`. NOT `{ "resource": { ... } }`. The top-level response IS the resource.
|
|
211
|
+
|
|
212
|
+
### Filtering, sorting, pagination
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
GET /products?status=active&sort=-created_at&starting_after=01HZQ...&limit=50
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- **Filtering grammar — exactly one form per case:**
|
|
219
|
+
- **Equality:** `?status=active` (single value), `?status=active,paused` (multi-value, comma-separated, single key).
|
|
220
|
+
- **Time ranges:** `?created_after=<ts_ms>&created_before=<ts_ms>`. Bracket grammar (`?created_at[gte]=...`) is forbidden.
|
|
221
|
+
- **No boolean explosions.** Don't add `?include_x=true&include_y=true` — use `?include=x,y` with a documented enum of legal values, OR don't expose a knob.
|
|
222
|
+
- **Sorting:** `sort=field` ascending; `sort=-field` descending. Single sort key per request — no multi-key.
|
|
223
|
+
- **Pagination — Stripe-style keyset only.** Request: `?starting_after=<resource_id>&limit=<int>`. Response: `next_cursor: <resource_id> | null` (the field is named `next_cursor` even though the request param is `starting_after`). Cursor encode/decode goes through the shared `src/agentsfleetd/fleet_runtime/keyset_cursor.zig` (`parse()`/`format()`) — mirror `fleets/list.zig`'s usage. `limit` parsing is currently per-handler (no shared query-parsing helper exists yet for this shape); mirror `fleets/list.zig`'s local `parseLimitFromQs`. Default `limit=50`, max `limit=100`. To page forward, send the response's `next_cursor` value back as the next request's `starting_after`. **Forbidden:** page-based `?page=&page_size=` (the `api_keys/list.zig` shape, backed by `pagination.zig::parsePageParams`, is legacy — do not copy it for new endpoints); custom request-side `?cursor=` names (the `approvals/list.zig` keyset shape predates this rule — do not copy it).
|
|
224
|
+
- **Sparse fieldsets / `?include=` / `?fields=`:** not supported in v1. If you need to slim a payload, design a smaller endpoint. Don't invent.
|
|
225
|
+
|
|
226
|
+
### Bulk operations
|
|
227
|
+
|
|
228
|
+
If an endpoint accepts a batch (e.g. `POST /agents/batch`), it MUST return `207 Multi-Status` with this exact shape — no per-endpoint reinvention:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"items": [
|
|
233
|
+
{ "id": "01HZQ...", "status": "succeeded" },
|
|
234
|
+
{ "id": "01HZR...", "status": "failed", "error": { "type": "...", "title": "...", "status": 409, "detail": "..." } }
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Per-item `status` ∈ `{succeeded, failed, skipped}`. Per-item `error` follows the §5 RFC 7807 envelope (without `request_id` — that's on the outer response). Whole-request 4xx still applies for malformed batches.
|
|
240
|
+
|
|
241
|
+
### Null vs omitted fields
|
|
242
|
+
|
|
243
|
+
- **Optional fields the server doesn't have a value for: omit the key.** Don't serialize `null` for "not present."
|
|
244
|
+
- **`null` is reserved for "explicitly cleared."** A PATCH with `{ "label": null }` clears the label; a GET response containing `"label": null` means the row has been cleared.
|
|
245
|
+
|
|
246
|
+
This rule is binding on responses AND requests. SDK consumers branch on `field === undefined` vs `field === null` — getting it wrong breaks them silently.
|
|
247
|
+
|
|
248
|
+
### IDs
|
|
249
|
+
|
|
250
|
+
- Use **UUIDv7** for all externally-exposed IDs (sortable, time-encoded). See [uuid7.com](https://uuid7.com).
|
|
251
|
+
- Do not expose database serial integers.
|
|
252
|
+
- Sensitive IDs (workspace_id, agent_id) live in the path, not the body. Never log their values at INFO level (§11).
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## §4 — Response shape
|
|
257
|
+
|
|
258
|
+
### Success — use `hx.ok`
|
|
259
|
+
|
|
260
|
+
```zig
|
|
261
|
+
hx.ok(.ok, .{ .agent_id = id, .status = "active" });
|
|
262
|
+
hx.ok(.created, .{ .agent_id = id, .key = raw_key });
|
|
263
|
+
hx.ok(.accepted, .{ .status = "accepted", .event_id = event_id });
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The `Hx` helper writes the JSON response with the right status code and content-type. Never call `hx.res.json(...)` directly except for SSE streams (and Slack's "ack-and-drop" pattern).
|
|
267
|
+
|
|
268
|
+
### Status codes
|
|
269
|
+
|
|
270
|
+
| Code | When |
|
|
271
|
+
|------|------|
|
|
272
|
+
| `200` | Successful read or update |
|
|
273
|
+
| `201` | Resource created |
|
|
274
|
+
| `202` | Accepted for async processing |
|
|
275
|
+
| `204` | Successful delete or no-content op |
|
|
276
|
+
| `400` | Client error in request shape |
|
|
277
|
+
| `401` | Missing or invalid authentication |
|
|
278
|
+
| `403` | Authenticated but unauthorized |
|
|
279
|
+
| `404` | Resource not found |
|
|
280
|
+
| `409` | State-transition conflict — the resource's current state forbids the requested transition. Body MUST include `current_state` in `detail` or as an extension field. |
|
|
281
|
+
| `412` | Precondition failed — `If-Match` ETag mismatch. Body MUST include the current `etag` so the client can refetch. |
|
|
282
|
+
| `413` | Payload too large |
|
|
283
|
+
| `429` | Rate limited — MUST include `Retry-After` header (seconds, integer) and `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` (epoch seconds). |
|
|
284
|
+
| `500` | Internal server error |
|
|
285
|
+
|
|
286
|
+
`409` and `412` are distinct: `409` is "the state of the resource forbids this"; `412` is "your version of the resource is stale." Don't merge them.
|
|
287
|
+
|
|
288
|
+
If you find yourself wanting another status code, check RFC 7231/7807 — don't invent one.
|
|
289
|
+
|
|
290
|
+
### Datetime fields
|
|
291
|
+
|
|
292
|
+
**Integer milliseconds since Unix epoch (UTC), serialized as JSON number, OpenAPI `type: integer, format: int64`.** Field name uses the `_at` suffix.
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
"created_at": 1735689600000
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
NOT ISO 8601 strings, NOT seconds, NOT floats, NOT a string-encoded integer. This matches the codebase (`EXTRACT(EPOCH FROM created_at) * 1000` in handlers; `i64` in response structs; `format: int64` in `components/schemas.yaml`). Anyone proposing ISO 8601 string datetimes for a new endpoint is wrong — match the existing convention or amend this rule first.
|
|
299
|
+
|
|
300
|
+
When the field has not yet been set (e.g. an unrevoked key's `revoked_at`), serialize as JSON `null` and declare `nullable: true` in the schema.
|
|
301
|
+
|
|
302
|
+
### Duration / interval / timeout fields
|
|
303
|
+
|
|
304
|
+
**Integer with explicit unit suffix in the field name.** Allowed suffixes:
|
|
305
|
+
|
|
306
|
+
- `_ms` — milliseconds (default for sub-second to multi-second internal timeouts).
|
|
307
|
+
- `_seconds` — seconds (TTLs, intervals where Redis or another sub-system uses seconds natively).
|
|
308
|
+
|
|
309
|
+
Banned:
|
|
310
|
+
|
|
311
|
+
- Bare names: `timeout`, `ttl`, `interval`, `duration`, `expiration`. The unit MUST be in the name.
|
|
312
|
+
- ISO 8601 durations (`PT5M`, `PT1H30M`). Forbidden in both requests and responses.
|
|
313
|
+
- Floats. Use the smaller unit if you need sub-integer precision.
|
|
314
|
+
- Mixing units in a single endpoint (e.g. one field in `_ms`, a peer in `_seconds`) without a stated reason in the OpenAPI description.
|
|
315
|
+
|
|
316
|
+
Pick `_ms` unless the underlying system speaks seconds (Redis `EX`, JWT `exp`). When in doubt, `_ms`.
|
|
317
|
+
|
|
318
|
+
### ETags and optimistic concurrency
|
|
319
|
+
|
|
320
|
+
For any resource where concurrent edits are realistic (anything mutable that two principals can touch), the `GET` and the response of `PATCH`/`PUT` MUST include an `ETag` response header. The client MUST send `If-Match: <etag>` on subsequent `PATCH`/`PUT`/`DELETE`. Mismatch → `412`. Resources without realistic concurrent edits (workspace-private append-only logs, single-tenant config) may opt out — note that decision in the spec's "Failure Modes" section.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## §5 — Error handling
|
|
325
|
+
|
|
326
|
+
### Use the error registry
|
|
327
|
+
|
|
328
|
+
The error-code registry (`src/agentsfleetd/errors/error_entries.zig`) owns the HTTP status, RFC 7807 `title`, and `docs_uri`. Your handler supplies only the code and a human-readable `detail`:
|
|
329
|
+
|
|
330
|
+
```zig
|
|
331
|
+
hx.fail(ec.ERR_INVALID_REQUEST, "workspace_id must be a valid UUIDv7");
|
|
332
|
+
hx.fail(ec.ERR_FORBIDDEN, "Workspace access denied");
|
|
333
|
+
hx.fail(ec.ERR_AGENT_NOT_FOUND, ec.MSG_AGENT_NOT_FOUND);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
NOT `common.errorResponse(hx.res, "...", "...", req_id)`. NOT `hx.res.status = 400; hx.res.body = "{...}"`.
|
|
337
|
+
|
|
338
|
+
If you need an error code that doesn't exist:
|
|
339
|
+
|
|
340
|
+
1. Add it to the registry with status, title, docs_uri.
|
|
341
|
+
2. Add a corresponding test asserting the registry entry is reachable.
|
|
342
|
+
3. Document in your spec's "Error Contracts" table.
|
|
343
|
+
|
|
344
|
+
### Registry `title` style
|
|
345
|
+
|
|
346
|
+
Titles are **short imperative noun phrases, 2–5 words, sentence case, no trailing punctuation**. Match the existing codebase style:
|
|
347
|
+
|
|
348
|
+
- Good: `Invalid UUID canonical format`, `Database unavailable`, `Insufficient role`, `Agent name already exists`, `Invalid webhook signature`.
|
|
349
|
+
- Bad: `error: database is currently unavailable.` (sentence, lowercase, period), `WORKSPACE_NOT_FOUND` (screaming snake, that's the code not the title), `Something went wrong while processing your request` (vague + long).
|
|
350
|
+
|
|
351
|
+
The title must be safe to render verbatim in a UI toast — think "what would I show a user."
|
|
352
|
+
|
|
353
|
+
### `detail` field hygiene
|
|
354
|
+
|
|
355
|
+
`detail` is user-facing. It MUST follow these rules — not "should":
|
|
356
|
+
|
|
357
|
+
1. **Length:** ≤200 characters. If you need more, you're explaining implementation; cut it.
|
|
358
|
+
2. **Voice:** one sentence OR one fragment. Be consistent within a code: pick one shape for `ERR_INVALID_REQUEST` and stick to it.
|
|
359
|
+
3. **Audience:** a developer integrating the API. Not a database admin, not a Zig engineer.
|
|
360
|
+
4. **Templating:** `{s}` placeholders are allowed for **enumerable safe values** — role labels, sort options, supported services. `{s}` placeholders are forbidden for **entity values** — never interpolate a `workspace_id`, `agent_id`, `user_id`, email, IP, hostname, or any UUID into `detail`.
|
|
361
|
+
5. **Ban list — `detail` MUST NOT contain any of these substrings, even when paraphrased:**
|
|
362
|
+
- Internal table/column names: `pg_`, `pg.`, table names from `schema/*.sql`, column names not exposed in the OpenAPI response.
|
|
363
|
+
- SQL fragments: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `WHERE`, `JOIN`, `CONSTRAINT`, `relation does not exist`, `duplicate key value violates`.
|
|
364
|
+
- Code internals: `panic`, `unreachable`, `error.`, `.zig`, `src/`, `*.zig` line numbers, file paths starting with `/`, allocator names, struct names from non-public modules.
|
|
365
|
+
- Stack traces, addresses (`0x...`), thread IDs, request-internal pointer values.
|
|
366
|
+
- Secret-shaped strings: anything matching `sk_`, `pk_`, `Bearer `, `eyJ` (JWT), `op://`, hex blobs >16 chars.
|
|
367
|
+
- Entity values: literal UUIDs, emails, raw bearer tokens, vault key names, file paths from the workspace.
|
|
368
|
+
6. **Acceptable shapes — match these patterns:**
|
|
369
|
+
- Validation: `"<field> must <constraint>"` — `key_name must be 1-64 chars, alphanumeric + hyphen + underscore`.
|
|
370
|
+
- Capability: `"<resource/action> <verb-form>"` — `Workspace access denied`, `Tenant context required`.
|
|
371
|
+
- State: `"<noun> already exists"` / `"<noun> not found"` / `"<noun> expired"` — `Agent name already exists`, `token expired`.
|
|
372
|
+
- Format help: `"<param>: use <format>"` — `invalid_since_format: use Go-style duration (15s, 30m, 2h, 7d) or RFC 3339 (YYYY-MM-DDTHH:MM:SSZ)`.
|
|
373
|
+
|
|
374
|
+
When you write a new `hx.fail`, find the closest existing call site in `src/agentsfleetd/http/handlers/**` and copy its shape. Don't freelance.
|
|
375
|
+
|
|
376
|
+
### Internal 500s — direct calls
|
|
377
|
+
|
|
378
|
+
For DB / pool / operation failures, use the helpers directly:
|
|
379
|
+
|
|
380
|
+
```zig
|
|
381
|
+
common.internalDbUnavailable(hx.res, hx.req_id); // pool.acquire failed
|
|
382
|
+
common.internalDbError(hx.res, hx.req_id); // query failed
|
|
383
|
+
common.internalOperationError(hx.res, "detail", hx.req_id);
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
These are NOT wrapped on `Hx`.
|
|
387
|
+
|
|
388
|
+
### Error response body (RFC 7807)
|
|
389
|
+
|
|
390
|
+
```json
|
|
391
|
+
{
|
|
392
|
+
"type": "https://docs.agentsfleet.net/errors/invalid-request",
|
|
393
|
+
"title": "Invalid request",
|
|
394
|
+
"status": 400,
|
|
395
|
+
"detail": "workspace_id must be a valid UUIDv7",
|
|
396
|
+
"request_id": "req_01HZQ..."
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
`request_id` (carried as `x-e2e-request-id` header on the response) is required for traceability.
|
|
401
|
+
|
|
402
|
+
**Status-specific extensions** — the envelope is open per RFC 7807 §3.2. Two extensions are mandatory at their corresponding statuses:
|
|
403
|
+
|
|
404
|
+
- `409` MUST include `current_state: <string>` naming the state that forbade the transition (e.g. `"current_state": "approved"` when the caller tried to approve an already-approved gate).
|
|
405
|
+
- `412` MUST include `etag: <string>` carrying the resource's current ETag so the client can refetch and retry.
|
|
406
|
+
|
|
407
|
+
Don't invent other extensions without amending this doc.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## §6 — OpenAPI editing
|
|
412
|
+
|
|
413
|
+
**`public/openapi.json` is a build artifact.** Never edit it directly. Edits get wiped on `make check-openapi` and CI's bundle-in-sync gate fails the PR.
|
|
414
|
+
|
|
415
|
+
The source of truth lives under `public/openapi/`:
|
|
416
|
+
|
|
417
|
+
```
|
|
418
|
+
public/openapi/
|
|
419
|
+
├── root.yaml # info, servers, tags, security, paths map with $refs
|
|
420
|
+
├── paths/<tag>.yaml # one file per tag, hard cap 400 lines — split by sub-resource when exceeded
|
|
421
|
+
└── components/
|
|
422
|
+
├── schemas.yaml
|
|
423
|
+
├── responses.yaml
|
|
424
|
+
└── security.yaml
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Adding, renaming, or removing an endpoint
|
|
428
|
+
|
|
429
|
+
1. Edit the relevant YAML under `public/openapi/paths/<tag>.yaml`.
|
|
430
|
+
2. Add / rename / remove the corresponding `match()` arm in `src/agentsfleetd/http/router.zig`.
|
|
431
|
+
3. Run `make check-openapi` — bundles YAML → JSON, runs Redocly lint, runs `check_openapi_errors.py`, runs `check_openapi_url_shape.py` (REST §1).
|
|
432
|
+
4. Commit YAML + bundled JSON + `router.zig` together. Splitting these across commits leaves CI red.
|
|
433
|
+
|
|
434
|
+
**Router ↔ openapi.json parity is reviewer-enforced.** There is no mechanical gate cross-checking that every `router.match()` arm has a documented openapi path or vice versa. When you add, rename, or remove a route, both surfaces must move in the same diff and the reviewer must verify it. The previous Python parity gate (`audits/check_openapi_sync.py`) and its data file (`route_manifest.zig`, deleted) were retired in M61_002.
|
|
435
|
+
|
|
436
|
+
**Agent-edit recipe:** see `public/openapi/AGENTS.md` for copy-paste-ready rename / append / remove / update-description workflows.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## §7 — Registering a route
|
|
441
|
+
|
|
442
|
+
Six places, in order. Steps 1–5 fail loudly at build/runtime; step 6 is reviewer-enforced — see §6 for the parity rule.
|
|
443
|
+
|
|
444
|
+
| Skipped step | Failure mode |
|
|
445
|
+
|---|---|
|
|
446
|
+
| 1 (`Route` variant) | Compile error in `route_table.zig::specFor()` — the `.my_endpoint` arm references an undefined enum variant. |
|
|
447
|
+
| 2 (`match()` arm) | Runtime — your URL returns 404 even though the rest is wired; no compile error. |
|
|
448
|
+
| 3 (`route_table.zig::specFor()`) | Compile error — exhaustive switch on `Route` union is missing your arm. |
|
|
449
|
+
| 4 (`route_scopes.zig::requiredScopes()`) | Compile error — exhaustive switch on `Route` is missing your arm; the route can't be assigned a capability requirement until you do. |
|
|
450
|
+
| 5 (invoke shim) | Compile error — `specFor` references `invoke.invokeMyEndpoint` which doesn't exist. |
|
|
451
|
+
| 6 (OpenAPI YAML) | **No automated check.** Router ↔ openapi.json parity is reviewer-enforced (§6). Reviewer must confirm both surfaces moved in the same diff. |
|
|
452
|
+
|
|
453
|
+
If you only see #2's silent-404 failure mode, you've forgotten the matcher even though the route compiles. Test the URL after wiring.
|
|
454
|
+
|
|
455
|
+
1. **`src/agentsfleetd/http/routes.zig`** — add a variant to the `Route` union (with path params). `router.zig` re-exports it as `router.Route`; every other consumer imports through that re-export unchanged.
|
|
456
|
+
2. **`src/agentsfleetd/http/router.zig::match()`** — add the path parser that returns your variant.
|
|
457
|
+
3. **`src/agentsfleetd/http/route_table.zig::specFor()`** — map the variant to a `RouteSpec`:
|
|
458
|
+
|
|
459
|
+
```zig
|
|
460
|
+
.my_endpoint => .{ .middlewares = registry.bearer(), .invoke = invoke.invokeMyEndpoint },
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
4. **`src/agentsfleetd/http/route_scopes.zig::requiredScopes()`** — assign the capability requirement (an empty slice for authenticated-only, or `&NONE`/never-runs for a `none`-policy route):
|
|
464
|
+
|
|
465
|
+
```zig
|
|
466
|
+
.my_endpoint => &MY_RESOURCE_WRITE,
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
5. **The invoke shim** — add `pub fn invokeMyEndpoint` to `route_table_invoke.zig` or the sibling file for your domain (`route_table_invoke_<domain>.zig`; each keeps `route_table_invoke.zig` under the RULE FLL line cap and is imported + re-exported from there):
|
|
470
|
+
|
|
471
|
+
```zig
|
|
472
|
+
pub fn invokeMyEndpoint(hx: *Hx, req: *httpz.Request, route: router.Route) void {
|
|
473
|
+
if (req.method != .POST) { common.respondMethodNotAllowed(hx.res); return; }
|
|
474
|
+
my_handler.innerMyEndpoint(hx.*, req, route.my_endpoint);
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
6. **`public/openapi/paths/<tag>.yaml`** — add the endpoint (§6).
|
|
479
|
+
|
|
480
|
+
### Matcher style — segment-based, not substring-based
|
|
481
|
+
|
|
482
|
+
All path matchers operate on a canonical `Path` view: a stack-allocated array
|
|
483
|
+
of segments parsed once at the dispatch boundary. The matcher functions
|
|
484
|
+
themselves live in `src/agentsfleetd/http/route_matchers.zig` plus sibling
|
|
485
|
+
per-domain files (`route_matchers_connectors.zig`, `route_matchers_fleet.zig`,
|
|
486
|
+
`route_matchers_runner.zig`, `route_matchers_billing.zig`,
|
|
487
|
+
`route_matchers_webhook.zig`) — same RULE FLL split as the invoke shims.
|
|
488
|
+
Matchers compare by **segment count + segment[i] equality**. Disambiguation is
|
|
489
|
+
shape-driven, not order-driven.
|
|
490
|
+
|
|
491
|
+
**Pattern (mandatory for any new matcher):**
|
|
492
|
+
|
|
493
|
+
```zig
|
|
494
|
+
pub fn matchMyEndpoint(p: Path) ?MyRoute {
|
|
495
|
+
if (p.segs.len != N) return null; // exact segment count
|
|
496
|
+
if (!p.eq(0, "literal-1")) return null; // literal slots
|
|
497
|
+
if (!p.eq(2, "literal-2")) return null;
|
|
498
|
+
const id_a = p.param(1) orelse return null; // path-param slots: param() rejects empty
|
|
499
|
+
const id_b = p.param(3) orelse return null;
|
|
500
|
+
return .{ .field_a = id_a, .field_b = id_b };
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**Banned in new matchers:**
|
|
505
|
+
- `std.mem.startsWith` / `endsWith` / `indexOf` on the path string.
|
|
506
|
+
- Suffix-driven dispatch (`if endsWith(path, "/foo")`).
|
|
507
|
+
- Implicit segment boundaries via offset arithmetic.
|
|
508
|
+
- Reservations encoded as call-site ordering ("must run matcher A before B").
|
|
509
|
+
|
|
510
|
+
**Required properties of a matcher set:**
|
|
511
|
+
- **Mutual exclusivity by structure.** Any two matchers must be reject-incompatible
|
|
512
|
+
by segment count or segment-equality predicates. If two matchers can both
|
|
513
|
+
fire on the same path, one of them needs a tighter predicate (e.g. an explicit
|
|
514
|
+
`if (p.eq(i, RESERVED)) return null` exclusion). Order in `match()` must not
|
|
515
|
+
decide correctness.
|
|
516
|
+
- **Empty-segment safety.** `Path.parse` preserves empty segments from `//` and
|
|
517
|
+
trailing slashes. Matchers extract path-parameter slots via `param(idx)`,
|
|
518
|
+
which returns null on empty — empty IDs reject at the matcher, not the
|
|
519
|
+
handler.
|
|
520
|
+
- **Semantic-named typed structs.** Each `Route` enum variant gets its own
|
|
521
|
+
struct with semantic field names (`credential_name`, `agent_id`, `grant_id`,
|
|
522
|
+
`memory_key`, `gate_id`, …). Parsing logic may be shared via a private
|
|
523
|
+
helper, but the public surface stays type-distinct.
|
|
524
|
+
|
|
525
|
+
**Single API-version site.** The dispatcher in `router.zig::match()` calls
|
|
526
|
+
`Path.parse(...)` once, checks `segs[0]` against the API version, then hands
|
|
527
|
+
`p.tail(1)` to the version-specific matchers (`matchV1`, future `matchV2`).
|
|
528
|
+
The string `"v1"` lives in exactly one place — that dispatch line. No matcher
|
|
529
|
+
body checks the API version.
|
|
530
|
+
|
|
531
|
+
### Middleware policy table — pick one at step 3
|
|
532
|
+
|
|
533
|
+
The registry exposes exactly these six policies (`src/agentsfleetd/auth/middleware/mod.zig::MiddlewareRegistry`). Admin/operator capability is no longer a separate policy — it's data in the route→scope table (step 4), enforced by the single `require_scope` middleware that every authenticated chain composes with.
|
|
534
|
+
|
|
535
|
+
| Policy | Use for |
|
|
536
|
+
|--------|---------|
|
|
537
|
+
| `auth_mw.MiddlewareRegistry.none` | Public endpoint; no auth, OR handler does its own (OAuth/connector callbacks, webhook receivers, Slack events ingress) |
|
|
538
|
+
| `registry.bearer()` | Standard user-facing endpoint; Bearer token or `agt_t` API key + capability gate. The former bearer/admin/operator/platformAdmin chains collapsed into this one policy — `route_scopes.zig::requiredScopes()` decides what the principal must hold |
|
|
539
|
+
| `registry.runnerBearer()` | Runner-token (`agt_r`) machine principal + capability gate; used by the `/v1/runners/me/*` self-plane, which requires `runner:self` |
|
|
540
|
+
| `registry.webhookHmac()` | Approval webhook (HMAC-signed body) |
|
|
541
|
+
| `registry.webhookSig()` | Per-agent HMAC signature for webhooks routed to a fleet (no Bearer fallback) |
|
|
542
|
+
| `registry.svix()` | Svix v1 multi-sig HMAC (Clerk) |
|
|
543
|
+
|
|
544
|
+
Middleware runs BEFORE your handler: token verification, `hx.principal` population, and 401/403 short-circuits are already done when your `inner*` runs. For `none` policy routes, `hx.principal` is zero-valued — do not read it.
|
|
545
|
+
|
|
546
|
+
### Raw-handler exceptions
|
|
547
|
+
|
|
548
|
+
Some endpoints register with the `none` middleware policy (step 3) and verify — or deliberately don't need to verify — inside the handler itself, instead of through a bearer/runner/HMAC chain. Each must carry an explanatory comment at the top of the file:
|
|
549
|
+
|
|
550
|
+
| File | Reason | Comment |
|
|
551
|
+
|------|--------|---------|
|
|
552
|
+
| `src/agentsfleetd/http/handlers/health.zig` (`healthz`/`readyz`/`metrics`) | Unauthenticated by design — health/metrics | `// unauthenticated — health` (or equivalent) |
|
|
553
|
+
| `src/agentsfleetd/http/handlers/auth/sessions.zig` | Per-function (create/poll/verify public; approve/delete are `registry.bearer()`-authed Clerk JWT, not raw) | per-function comments |
|
|
554
|
+
| `src/agentsfleetd/http/handlers/auth/identity_events_clerk.zig` | Svix-signed `user.created` event, verified inline against `CLERK_WEBHOOK_SECRET` | `// Svix-verified — does not use bearer/HMAC middleware` |
|
|
555
|
+
| `src/agentsfleetd/http/handlers/connectors/callback.zig` (+ provider hooks `src/agentsfleetd/http/handlers/connectors/github/callback.zig`, `src/agentsfleetd/http/handlers/connectors/slack/callback.zig`) | Generic OAuth-callback dispatcher; verifies the signed `state` param inline before delegating to the provider hook | `// OAuth callback — verifies signed state, not bearer` |
|
|
556
|
+
| `src/agentsfleetd/http/handlers/connectors/slack/events.zig` | Slack Events API ingress; Slack v0 request signature only (no Bearer fallback) | `// Slack v0 signature-verified — mirrors the webhook plane` |
|
|
557
|
+
| `src/agentsfleetd/http/handlers/ingress/qstash.zig` | QStash schedule ingress; QStash JSON Web Token signature only (no Bearer fallback) | `// QStash signature-verified — no bearer or generic webhook fallback` |
|
|
558
|
+
|
|
559
|
+
If you write a new raw handler, add it to this table AND add a first-10-lines comment in the file explaining why it's registered with `none` and what verifies the caller instead. A `none`-policy handler file lacking either its own verification call OR an explanatory comment in its first 10 lines is a bug — not an oversight.
|
|
560
|
+
|
|
561
|
+
### Reference implementations
|
|
562
|
+
|
|
563
|
+
When in doubt, mirror an existing handler:
|
|
564
|
+
|
|
565
|
+
| Pattern | Look at |
|
|
566
|
+
|---------|---------|
|
|
567
|
+
| Simple list/get | `src/agentsfleetd/http/handlers/fleets/list.zig::innerListFleets` |
|
|
568
|
+
| POST with body + validation | `src/agentsfleetd/http/handlers/fleets/create.zig::innerCreateFleet` |
|
|
569
|
+
| DELETE with idempotency | `src/agentsfleetd/http/handlers/fleets/delete.zig::innerDeleteFleet` |
|
|
570
|
+
| Admin-only | `src/agentsfleetd/http/handlers/admin/platform_keys.zig::innerGetAdminPlatformKeys` |
|
|
571
|
+
| Multi-method on one path (GET/POST/PATCH/DELETE) | `src/agentsfleetd/http/handlers/fleets/secrets.zig` |
|
|
572
|
+
| Workspace auth + tenant context | `src/agentsfleetd/http/handlers/workspaces/events.zig::innerListWorkspaceEvents` |
|
|
573
|
+
| Webhook with inline auth | `src/agentsfleetd/http/handlers/webhooks/fleet.zig::innerReceiveWebhook` |
|
|
574
|
+
| Streaming (SSE) | `src/agentsfleetd/http/handlers/fleets/events_stream.zig::innerEventsStream` |
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## §8 — Handler signature rule
|
|
579
|
+
|
|
580
|
+
Every HTTP handler in `agentsfleetd` follows this shape. Enforced by `make lint-zig` and by the dispatcher in `src/agentsfleetd/http/server.zig::dispatchMatchedRoute()`, which builds the per-request arena, runs the middleware chain, constructs `Hx`, and calls the route's invoke shim (§7) — a handler never constructs any of this itself.
|
|
581
|
+
|
|
582
|
+
```zig
|
|
583
|
+
pub fn innerMyEndpoint(hx: Hx, req: *httpz.Request, ...path_params) void {
|
|
584
|
+
// 1. validate inputs
|
|
585
|
+
// 2. db / state work (acquire via hx.ctx.pool.acquire(), release via hx.ctx.pool.release())
|
|
586
|
+
// 3. respond via hx.ok(...) or hx.fail(...)
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Rules
|
|
591
|
+
|
|
592
|
+
- **Name prefix `inner`** (never `handle`). The `invokeXxx` shim (§7 step 5) is the only public entry — your function is the inner implementation it calls after middleware has populated `hx`.
|
|
593
|
+
- **First parameter: `hx: Hx`** — never `ctx: *Context`, `res: *Response`, `req_id: []const u8`, or an arena allocator. All of those live inside `hx`.
|
|
594
|
+
- **Second parameter: `req: *httpz.Request`** — only if you actually read it (body, query, headers). Drop it if you only need path params.
|
|
595
|
+
- **Path params come after `req`** — order matches the `Route` variant in `routes.zig` (§7 step 1).
|
|
596
|
+
- **Return `void`.** Errors are written to the response; never return a Zig error.
|
|
597
|
+
|
|
598
|
+
### What NOT to do
|
|
599
|
+
|
|
600
|
+
- ❌ `handleMyEndpoint(ctx, req, res)` — old signature; don't add new ones.
|
|
601
|
+
- ❌ Building an `ArenaAllocator` inside the handler — `hx.alloc` is already request-scoped (the dispatcher owns its lifetime).
|
|
602
|
+
- ❌ `common.errorResponse(hx.res, ...)` — use `hx.fail(...)`.
|
|
603
|
+
- ❌ `hx.res.json(body, .{})` or `res.status = 200; res.body = "{}"` — use `hx.ok(.ok, body)`. Acceptable only for SSE streams and Slack ack-and-drop.
|
|
604
|
+
- ❌ Returning a Zig error from the handler — write to `hx.res` and return void.
|
|
605
|
+
|
|
606
|
+
### `Hx` struct reference
|
|
607
|
+
|
|
608
|
+
`Hx` is defined in `src/agentsfleetd/http/handlers/hx.zig`. Five fields, passed by value; three methods, each wrapping a distinct wire shape:
|
|
609
|
+
|
|
610
|
+
```zig
|
|
611
|
+
pub const Hx = struct {
|
|
612
|
+
alloc: std.mem.Allocator, // request-scoped arena, built by the dispatcher
|
|
613
|
+
principal: common.AuthPrincipal, // populated by bearer/runner middleware; zero-value for none-policy routes
|
|
614
|
+
req_id: []const u8, // unique request ID
|
|
615
|
+
ctx: *common.Context, // pool, queue, oidc, telemetry, app_url
|
|
616
|
+
res: *httpz.Response, // response writer
|
|
617
|
+
|
|
618
|
+
pub fn ok(self: Self, status: std.http.Status, body: anytype) void; // JSON envelope
|
|
619
|
+
pub fn fail(self: Self, code: []const u8, detail: []const u8) void; // RFC 7807 error
|
|
620
|
+
pub fn noContent(self: Self) void; // spec-compliant empty-body 204
|
|
621
|
+
};
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
There is no `hx.db()`/`hx.releaseDb()`/`hx.redis()` — acquire the DB pool directly via `hx.ctx.pool.acquire()` / `hx.ctx.pool.release(conn)` (mirror `fleets/list.zig::innerListFleets`); for internal-500s and body-size checks call `common.internalDbError(hx.res, hx.req_id)` etc. directly (§5).
|
|
625
|
+
|
|
626
|
+
Auth is 100% middleware-chain-driven (§7) — there are no `authenticated()`/`authenticatedWithParam()` comptime wrappers to pick between by path-param count. Every handler, regardless of how many path params it takes, receives its populated `Hx` the same way: the dispatcher builds it after the middleware chain runs and passes it to the invoke shim.
|
|
627
|
+
|
|
628
|
+
### Guardrails on `hx.zig`
|
|
629
|
+
|
|
630
|
+
- `hx.zig` stays small — methods are one-liners delegating to `common.zig`. No business logic in `hx.zig`.
|
|
631
|
+
- A new method on `Hx` is justified only when ≥2 in-tree call sites exist **at merge time** — both verifiable by `rg` from `main`. The PR description MUST list both call sites as `path/to/file.zig:line`. "Two handlers will need this soon" is not a justification; ship the second caller in the same PR or keep the helper local.
|
|
632
|
+
- Zero `ArenaAllocator.init` calls in handler files — the dispatcher (`server.zig::dispatchMatchedRoute()`) is the only place that owns the per-request arena's lifetime.
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## §9 — Versioning
|
|
637
|
+
|
|
638
|
+
URI-based: `/v1/...`. All current endpoints sit under `/v1`. Bump to `/v2` only when a breaking change is unavoidable; default to additive evolution within `/v1`.
|
|
639
|
+
|
|
640
|
+
Header-based versioning (e.g. `X-API-Version: 2026-04-25`) is NOT used in this project. Don't introduce it.
|
|
641
|
+
|
|
642
|
+
### What is additive vs breaking
|
|
643
|
+
|
|
644
|
+
| Change | Class | Allowed within `/v1`? |
|
|
645
|
+
|---|---|---|
|
|
646
|
+
| New endpoint | Additive | Yes |
|
|
647
|
+
| New optional request field | Additive | Yes |
|
|
648
|
+
| New response field | Additive | Yes — but mark its stability class (below) |
|
|
649
|
+
| Tightening validation (narrower regex, lower max length) | Breaking | No |
|
|
650
|
+
| Loosening validation (wider regex, higher max length) | Additive | Yes |
|
|
651
|
+
| Adding a new enum value to a request param | Additive | Yes |
|
|
652
|
+
| Adding a new enum value to a response field | Breaking for typed SDK consumers — see "Enum extension" below |
|
|
653
|
+
| Renaming a field (request or response) | Breaking | No |
|
|
654
|
+
| Removing a field | Breaking | No — deprecate first |
|
|
655
|
+
| Changing a field's type or unit | Breaking | No |
|
|
656
|
+
| Making an optional field required | Breaking | No |
|
|
657
|
+
| Making a required field optional | Additive | Yes |
|
|
658
|
+
| Changing default pagination `limit` | Breaking (silently changes per-page billing/UI) | No |
|
|
659
|
+
| Adding a new error code to an existing endpoint | Additive | Yes |
|
|
660
|
+
| Changing a 4xx status code for an existing failure mode | Breaking | No |
|
|
661
|
+
|
|
662
|
+
### No field renames within `/v1`
|
|
663
|
+
|
|
664
|
+
Once a field name is exposed, it's immortal until `/v2`. To evolve, add the new field, populate both, mark the old one deprecated (see below), and remove only at the next major bump. Same rule for path-param names and query-param names.
|
|
665
|
+
|
|
666
|
+
### Enum extension policy
|
|
667
|
+
|
|
668
|
+
Adding a value to a response enum is a **breaking change for typed SDK consumers** (Go, Rust, TypeScript SDKs generate exhaustive switches; a new value triggers compile errors or silent fallthrough). Two options:
|
|
669
|
+
|
|
670
|
+
1. **Defer to `/v2`** — preferred when the new value changes semantics meaningfully.
|
|
671
|
+
2. **Add as a `additional_*` companion field** — leave the typed enum frozen, add `additional_status: string` for the new value. Document in the OpenAPI description.
|
|
672
|
+
|
|
673
|
+
If you ship a new enum value inside `/v1`, the PR description MUST acknowledge the SDK regeneration impact and name the consumers that will need a release.
|
|
674
|
+
|
|
675
|
+
### Deprecation
|
|
676
|
+
|
|
677
|
+
When deprecating an endpoint or field:
|
|
678
|
+
|
|
679
|
+
1. Set the response header `Deprecation: true` and `Sunset: <RFC 1123 date>` on every response from the deprecated endpoint (or every response that includes the deprecated field).
|
|
680
|
+
2. Minimum 90-day clock between announcing deprecation and removing the endpoint/field. 180 days for anything an external SDK consumer touches.
|
|
681
|
+
3. Add a `Link: <docs-url>; rel="deprecation"` header pointing to the migration guide.
|
|
682
|
+
4. Document the deprecation in the OpenAPI YAML with `deprecated: true` AND a one-line migration hint in the description.
|
|
683
|
+
|
|
684
|
+
No silent removals. No removals that skip the `Deprecation` header period.
|
|
685
|
+
|
|
686
|
+
### Response field stability classes
|
|
687
|
+
|
|
688
|
+
Every response field has one of three classes, declared via OpenAPI extension `x-stability`:
|
|
689
|
+
|
|
690
|
+
- `stable` — covered by the deprecation policy above. Default.
|
|
691
|
+
- `beta` — may change shape or be removed without the 90-day clock; flagged as `beta` in the OpenAPI description; SDK generators may strip or wrap as optional.
|
|
692
|
+
- `internal` — present in responses for tooling/debugging; SDK generators MUST strip; no compatibility guarantee. Avoid leaking these — internal fields tend to leak production state.
|
|
693
|
+
|
|
694
|
+
Every new response field added in a PR MUST declare a class. Default is `stable` if you forget, which is binding — the deprecation clock starts the moment it ships.
|
|
695
|
+
|
|
696
|
+
### PR-level surface diff
|
|
697
|
+
|
|
698
|
+
Every PR that changes the HTTP surface MUST open its description with a "Surface diff" section listing added / renamed / removed `(method, path)` pairs and added / renamed / removed schema fields. This is how reviewers and the orly-babysit-prs loop detect breaking changes that the diff itself buries.
|
|
699
|
+
|
|
700
|
+
---
|
|
701
|
+
|
|
702
|
+
## §10 — Pre-PR checklist (testing + ship)
|
|
703
|
+
|
|
704
|
+
Before opening a PR touching any handler:
|
|
705
|
+
|
|
706
|
+
- [ ] `zig build` clean
|
|
707
|
+
- [ ] `zig build test` passes
|
|
708
|
+
- [ ] `make test-unit-agentsfleetd` passes — `route_scopes.zig`'s exhaustive switch + `route_scopes_test.zig` cover the auth gate matrix
|
|
709
|
+
- [ ] `make test-integration` passes (HTTP + DB + Redis E2E)
|
|
710
|
+
- [ ] Cross-compile: `zig build -Dtarget=x86_64-linux && zig build -Dtarget=aarch64-linux`
|
|
711
|
+
- [ ] `make lint-zig` — all Zig gates pass (350-line file cap, `check-pg-drain`, zlint)
|
|
712
|
+
- [ ] Handler file ≤ 350 lines; split if it grows
|
|
713
|
+
- [ ] Integration test covers the happy path AND at least one error path per `hx.fail` call in the handler
|
|
714
|
+
- [ ] PATCH endpoints have an idempotency test: same body issued twice → identical 200 + identical row state. Skip only if the spec's "Failure Modes" explicitly declares non-idempotent PATCH with a reason (§2)
|
|
715
|
+
- [ ] POST endpoints accepting `Idempotency-Key` have a replay test: same key + same body → cached response; same key + different body → 4xx (§2)
|
|
716
|
+
- [ ] Mutable resources have an ETag/`If-Match` test: stale `If-Match` → 412 with current `etag` returned (§4)
|
|
717
|
+
- [ ] OpenAPI updated — endpoint definition, request/response schemas, error responses
|
|
718
|
+
- [ ] `make check-openapi` — bundle in sync, redocly lint, error-schema + URL-shape checks, router parity
|
|
719
|
+
- [ ] `gitleaks detect` clean
|
|
720
|
+
- [ ] No new file over 350 lines
|
|
721
|
+
|
|
722
|
+
---
|
|
723
|
+
|
|
724
|
+
## §11 — Security
|
|
725
|
+
|
|
726
|
+
- Use HTTPS for all endpoints. The Cloudflare Tunnel layer (see `playbooks/ARCHITECTURE.md`) enforces this for public ingress.
|
|
727
|
+
- Bearer tokens for user-facing endpoints. OAuth (Clerk) for the auth-issuance flow.
|
|
728
|
+
- Sensitive IDs (workspace_id, agent_id, user_id) live in the path, not the body. **Never log their literal values at INFO or above.** A log call whose format string OR struct argument references one of these IDs MUST be `DEBUG` or below, OR carry a same-line comment `// log-id-allowed: <reason>` explaining why this specific call is safe (e.g. it's a hashed prefix, not the raw value).
|
|
729
|
+
- Use **UUIDv7** for all IDs.
|
|
730
|
+
- **Secret-shaped response fields are write-only or one-time-read.** Any field whose name contains `token`, `secret`, `key`, `password`, `credential` (and is not just a key-by-name like `key_name`) MUST either be: (a) write-only — never returned by GET, only echoed in the POST that creates it, or (b) one-time-read — returned in the create response and never again. Document which in the OpenAPI description with `x-secret-handling: write-only | one-time-read`. This includes raw API keys, OAuth tokens, HMAC shared secrets, and webhook signing secrets.
|
|
731
|
+
- Never log secret values. Substitution happens at the tool bridge inside the executor sandbox; tokens never enter the agent's context, the event log, or any handler-level log line. (See `docs/ARCHITECTURE.md` §10 for the substrate guarantees.)
|
|
732
|
+
- Webhook receivers verify HMAC signatures using `std.crypto.utils.timingSafeEql`. Never string-compare HMACs.
|
|
733
|
+
- **CORS posture is per-endpoint and explicit.** Endpoints intended for browser callers (the dashboard at `app.agentsfleet.net`) MUST declare their allowed origin in the spec; everything else MUST refuse cross-origin requests at the edge. Do not enable wildcard `Access-Control-Allow-Origin: *`. Adding browser exposure to an existing endpoint is a surface change — call it out in the §9 PR-level surface diff.
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
## §12 — Performance
|
|
738
|
+
|
|
739
|
+
Targets (measured in CI bench under `make bench`):
|
|
740
|
+
|
|
741
|
+
- p99 latency < 200 ms
|
|
742
|
+
- p95 latency < 150 ms
|
|
743
|
+
- Zero allocator leaks (`std.testing.allocator` integration tests pass)
|
|
744
|
+
- No unbounded query loops. Pagination caps live in §3 (default `limit=50`, max `limit=100`).
|
|
745
|
+
|
|
746
|
+
If your endpoint can't meet these in a normal load profile, the spec's "Performance Considerations" section MUST contain ALL of:
|
|
747
|
+
|
|
748
|
+
1. **Endpoint** — exact `(method, path)`.
|
|
749
|
+
2. **Measured numbers** — p50, p95, p99 from a representative load run; cite the bench command and config used.
|
|
750
|
+
3. **Load profile** — qps, payload size, concurrency, dataset size that produced the numbers.
|
|
751
|
+
4. **Reason** — what makes this endpoint structurally slower (e.g. unavoidable cross-region call, large aggregation, third-party API).
|
|
752
|
+
5. **Remediation milestone** — the spec ID where the carve-out is planned to be retired (`M{N}_{NNN}`), OR an explicit `accepted-permanent` with a justification reviewed by the user.
|
|
753
|
+
|
|
754
|
+
A one-line "this endpoint is slow because reasons" is not a carve-out. Without all five, the carve-out doesn't merge.
|
|
755
|
+
|
|
756
|
+
---
|
|
757
|
+
|
|
758
|
+
## §13 — Reference specifications
|
|
759
|
+
|
|
760
|
+
When the project's conventions don't cover a case, defer to (in priority order):
|
|
761
|
+
|
|
762
|
+
1. **This document** — project-specific overrides everything.
|
|
763
|
+
2. [Microsoft REST API Guidelines](https://github.com/microsoft/api-guidelines) — for naming + pagination semantics.
|
|
764
|
+
3. [Google API Design Guide](https://cloud.google.com/apis/design) — for resource modeling.
|
|
765
|
+
4. [GitHub REST API Docs](https://docs.github.com/en/rest) — for query-param + filtering patterns.
|
|
766
|
+
5. [OpenAPI Specification](https://swagger.io/specification/) — schema validity.
|
|
767
|
+
6. [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) — error response envelope.
|
|
768
|
+
7. [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) — newer Problem Details (informational).
|
|
769
|
+
|
|
770
|
+
If a project rule conflicts with an external guide, the project rule wins; document the conflict in this file.
|
|
771
|
+
|
|
772
|
+
---
|
|
773
|
+
|
|
774
|
+
## SDK generation
|
|
775
|
+
|
|
776
|
+
This API is designed for OpenAPI-driven SDK generation. SDK generators tested against the OpenAPI bundle:
|
|
777
|
+
|
|
778
|
+
- [Stainless](https://stainlessapi.com/)
|
|
779
|
+
- [OpenAPI Generator](https://openapi-generator.tech/)
|
|
780
|
+
- [Speakeasy](https://speakeasy.com/)
|
|
781
|
+
- [APIMatic](https://www.apimatic.io/)
|
|
782
|
+
|
|
783
|
+
If you add a non-standard pattern (e.g., a polymorphic response shape) without checking SDK generator output, you risk breaking client codegen on the next bundle. When in doubt, mirror an existing endpoint that's known-clean under codegen.
|