@agentsfleet/orly 0.9.3 → 0.9.4
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/audits/error-codes.sh +136 -61
- package/docs/REST_API_DESIGN_GUIDELINES.md +208 -210
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/package.json +1 -1
package/audits/error-codes.sh
CHANGED
|
@@ -1,60 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# error-codes.sh — orphan + dead code detection against
|
|
3
|
-
# error registry
|
|
4
|
-
#
|
|
5
|
-
#
|
|
2
|
+
# error-codes.sh — orphan + dead code detection against a repository's canonical
|
|
3
|
+
# error registry, plus a raw-literal check enforcing that <PREFIX>-<CAT>-<NNN>
|
|
4
|
+
# strings only appear in registry/allowlist files (every other call site must
|
|
5
|
+
# reference the registry symbol).
|
|
6
6
|
#
|
|
7
7
|
# Dispatch façade: dispatch/write_any.md (Error Registry Gate)
|
|
8
|
-
# Fires in: CONFORM (via
|
|
8
|
+
# Fires in: CONFORM (via the repository's declared `conform` command).
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
10
|
+
# WHAT IS CONFIGURED, AND WHY IT IS NOT A PATH IN THIS FILE.
|
|
11
|
+
# This script used to hard-code one repository's registry path, source globs and
|
|
12
|
+
# allowlist. That repository retired the runtime those paths named, and the gate
|
|
13
|
+
# then pointed at nothing — the same silent-green failure the UFS parity check
|
|
14
|
+
# was fixed for. Every one of them is now resolved at run time, and the file
|
|
15
|
+
# names no repository's layout:
|
|
16
|
+
#
|
|
17
|
+
# ORLY_ERROR_PREFIX code prefix (default: UZ)
|
|
18
|
+
# ORLY_ERROR_REGISTRY registry file(s), a glob (default: autodetected)
|
|
19
|
+
# ORLY_ERROR_SOURCES where codes may be USED (default: by runtime)
|
|
20
|
+
# ORLY_ERROR_ALLOWLIST extra raw-literal paths (regex, appended)
|
|
21
|
+
#
|
|
22
|
+
# AUTODETECTION picks the runtime that actually declares codes:
|
|
23
|
+
# Rust — a file declaring `ErrorCode::declare("<PREFIX>-…")`; sources are the
|
|
24
|
+
# crate tree's `*.rs`.
|
|
25
|
+
# Zig — a file named `error_registry.zig`; sources are `src/**/*.zig`.
|
|
26
|
+
# A repository with neither SKIPS with a reason and exits 0. It does not fail,
|
|
27
|
+
# and it does not pass by scanning nothing and calling that clean.
|
|
19
28
|
#
|
|
20
29
|
# Definitions:
|
|
21
|
-
# - DECLARED — every
|
|
22
|
-
# - USED — every
|
|
23
|
-
# the registry itself). agentsfleet/src/** is intentionally
|
|
24
|
-
# out of scope — the CLI consumes server-emitted UZ-* codes
|
|
25
|
-
# as opaque strings from API responses; the dispatcher's
|
|
26
|
-
# typed CliError variants are the CLI's named surface.
|
|
30
|
+
# - DECLARED — every <PREFIX>-<CAT>-<NNN> literal in the registry
|
|
31
|
+
# - USED — every such reference in the source set, minus the registry
|
|
27
32
|
# - ORPHAN — USED but not DECLARED (BLOCKING)
|
|
28
33
|
# - DEAD — DECLARED but not USED (INFORMATIONAL)
|
|
29
|
-
# - RAW LEAK —
|
|
30
|
-
#
|
|
31
|
-
# surface; non-allowlisted call sites must use a registry
|
|
32
|
-
# symbol, never duplicate the literal).
|
|
33
|
-
#
|
|
34
|
-
# Raw-literal allowlist (files where UZ-* literals are legitimate):
|
|
35
|
-
# - src/agentsfleetd/errors/** (canonical registry + entry tables)
|
|
36
|
-
# - src/runner/engine/client_errors.zig (the runner engine's own mirror file
|
|
37
|
-
# — see file header for build-module
|
|
38
|
-
# isolation rationale; deliberate
|
|
39
|
-
# duplication, all other executor
|
|
40
|
-
# sources import from there)
|
|
41
|
-
# - **/*_test.zig (tests)
|
|
42
|
-
# - src/zbench_fixtures.zig (benchmark fixture seeds)
|
|
43
|
-
# - src/agentsfleetd/http/test_harness.zig (HTTP test harness)
|
|
44
|
-
# - lines starting with `//` (comments)
|
|
45
|
-
# - lines annotated `// audit-error-codes: intentional-fake`
|
|
34
|
+
# - RAW LEAK — a code literal in a source file outside the registry and the
|
|
35
|
+
# allowlist (BLOCKING — RULE UFS at the error surface)
|
|
46
36
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# `// audit-error-codes: intentional-fake` and the
|
|
37
|
+
# Raw-literal allowlist: the registry files themselves, anything test-shaped
|
|
38
|
+
# (`*_test.zig`, `*_test.rs`, a `tests/` directory, a test harness), plus
|
|
39
|
+
# whatever `ORLY_ERROR_ALLOWLIST` adds. Lines starting `//` are comments, and a
|
|
40
|
+
# line annotated `// audit-error-codes: intentional-fake` skips itself and the
|
|
41
|
+
# line below it — which is how a negative test names a code that must not exist.
|
|
51
42
|
#
|
|
52
43
|
# Modes:
|
|
53
44
|
# --staged diff-scope: only files in `git diff --cached` are checked
|
|
54
45
|
# for new orphan refs (registry checked unconditionally)
|
|
55
46
|
# --all (default) full repo scan
|
|
56
47
|
#
|
|
57
|
-
# Exits 0 clean, 1 on orphans or raw leaks (DEAD
|
|
48
|
+
# Exits 0 clean or skipped, 1 on orphans or raw leaks (DEAD never blocks).
|
|
58
49
|
|
|
59
50
|
set -euo pipefail
|
|
60
51
|
|
|
@@ -62,8 +53,54 @@ MODE="${1:-${SCOPE:-all}}"
|
|
|
62
53
|
ROOT="$(git rev-parse --show-toplevel)"
|
|
63
54
|
cd "$ROOT"
|
|
64
55
|
|
|
65
|
-
|
|
66
|
-
[[
|
|
56
|
+
PREFIX="${ORLY_ERROR_PREFIX:-UZ}"
|
|
57
|
+
CODE_RE="${PREFIX}-[A-Z][A-Z0-9]*-[0-9]{3,}"
|
|
58
|
+
|
|
59
|
+
# Registry files, then the runtime whose sources may reference them. Resolved
|
|
60
|
+
# together: the source set follows from what declares the codes, so a repository
|
|
61
|
+
# cannot end up scanning one runtime's files for another runtime's registry.
|
|
62
|
+
REGISTRY_PATHS=()
|
|
63
|
+
RUNTIME=""
|
|
64
|
+
if [[ -n "${ORLY_ERROR_REGISTRY:-}" ]]; then
|
|
65
|
+
while IFS= read -r f; do [[ -n "$f" ]] && REGISTRY_PATHS+=("$f"); done < <(
|
|
66
|
+
git ls-files -- $ORLY_ERROR_REGISTRY 2>/dev/null || true)
|
|
67
|
+
[[ ${#REGISTRY_PATHS[@]} -gt 0 ]] || {
|
|
68
|
+
printf "FAIL: ORLY_ERROR_REGISTRY matched no tracked file: %s\n" "$ORLY_ERROR_REGISTRY" >&2
|
|
69
|
+
exit 1
|
|
70
|
+
}
|
|
71
|
+
case "${REGISTRY_PATHS[0]}" in *.rs) RUNTIME=rust ;; *.zig) RUNTIME=zig ;; *) RUNTIME=other ;; esac
|
|
72
|
+
else
|
|
73
|
+
while IFS= read -r f; do [[ -n "$f" ]] && REGISTRY_PATHS+=("$f"); done < <(
|
|
74
|
+
git grep -l -E "ErrorCode::declare\(\"${PREFIX}-" -- '*.rs' 2>/dev/null \
|
|
75
|
+
| grep -vE "_tests?\.rs$|(^|/)tests?/|(^|/)tests?\.rs$" | sort || true)
|
|
76
|
+
if [[ ${#REGISTRY_PATHS[@]} -gt 0 ]]; then
|
|
77
|
+
RUNTIME=rust
|
|
78
|
+
else
|
|
79
|
+
while IFS= read -r f; do [[ -n "$f" ]] && REGISTRY_PATHS+=("$f"); done < <(
|
|
80
|
+
git ls-files -- '*error_registry.zig' 2>/dev/null | sort || true)
|
|
81
|
+
[[ ${#REGISTRY_PATHS[@]} -gt 0 ]] && RUNTIME=zig
|
|
82
|
+
fi
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
if [[ ${#REGISTRY_PATHS[@]} -eq 0 ]]; then
|
|
86
|
+
printf "OK: audit-error-codes: skipped — no %s-* registry found, so this repository has no source of truth to compare against\n" "$PREFIX"
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# Where a code may legitimately be REFERENCED.
|
|
91
|
+
if [[ -n "${ORLY_ERROR_SOURCES:-}" ]]; then
|
|
92
|
+
SOURCE_GLOB="$ORLY_ERROR_SOURCES"
|
|
93
|
+
elif [[ "$RUNTIME" = "rust" ]]; then
|
|
94
|
+
SOURCE_GLOB='*.rs'
|
|
95
|
+
else
|
|
96
|
+
SOURCE_GLOB='*.zig'
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# The registry files are their own allowlist, joined with the test-shaped paths
|
|
100
|
+
# every runtime spells differently, plus whatever the caller adds.
|
|
101
|
+
REGISTRY_RE="$(printf '%s\n' "${REGISTRY_PATHS[@]}" | sed 's|[.[\*^$/]|\\&|g' | paste -sd'|' -)"
|
|
102
|
+
ALLOWLIST_RE="^(${REGISTRY_RE})$|_tests?\\.(zig|rs)$|(^|/)tests?/|(^|/)tests?\\.rs$|/_?test_harness\\.[a-z]+$|_test_fixtures\\.[a-z]+$"
|
|
103
|
+
[[ -n "${ORLY_ERROR_ALLOWLIST:-}" ]] && ALLOWLIST_RE="${ALLOWLIST_RE}|${ORLY_ERROR_ALLOWLIST}"
|
|
67
104
|
|
|
68
105
|
FAIL=0
|
|
69
106
|
fail() { printf "FAIL: %s\n" "$*" >&2; FAIL=1; }
|
|
@@ -78,9 +115,9 @@ note() { printf "NOTE: %s\n" "$*"; }
|
|
|
78
115
|
# the entire UZ-UUIDV7-* family (its category ends in a digit), leaving those
|
|
79
116
|
# codes invisible to the orphan/dead/used passes below.
|
|
80
117
|
# ---------------------------------------------------------------------------
|
|
81
|
-
declared_codes=$(grep -
|
|
118
|
+
declared_codes=$(grep -hoE "${CODE_RE}\b" "${REGISTRY_PATHS[@]}" | sort -u)
|
|
82
119
|
if [[ -z "$declared_codes" ]]; then
|
|
83
|
-
fail "no codes declared in $
|
|
120
|
+
fail "no codes declared in ${REGISTRY_PATHS[*]} (registry empty?)"
|
|
84
121
|
exit 1
|
|
85
122
|
fi
|
|
86
123
|
declared_count=$(printf '%s\n' "$declared_codes" | wc -l | tr -d ' ')
|
|
@@ -93,13 +130,12 @@ declared_count=$(printf '%s\n' "$declared_codes" | wc -l | tr -d ' ')
|
|
|
93
130
|
gather_used_paths() {
|
|
94
131
|
case "$MODE" in
|
|
95
132
|
--staged|staged)
|
|
96
|
-
git diff --cached --name-only --diff-filter=ACMRT \
|
|
97
|
-
| grep -
|
|
98
|
-
| grep -vE '^src/agentsfleetd/errors/error_registry\.zig$' || true
|
|
133
|
+
git diff --cached --name-only --diff-filter=ACMRT -- $SOURCE_GLOB 2>/dev/null \
|
|
134
|
+
| grep -vE "^(${REGISTRY_RE})$" || true
|
|
99
135
|
;;
|
|
100
136
|
--all|all)
|
|
101
|
-
|
|
102
|
-
| grep -vE
|
|
137
|
+
git ls-files -- $SOURCE_GLOB 2>/dev/null \
|
|
138
|
+
| grep -vE "^(${REGISTRY_RE})$" || true
|
|
103
139
|
;;
|
|
104
140
|
*)
|
|
105
141
|
printf "usage: %s [--staged|--all]\n" "$0" >&2
|
|
@@ -117,18 +153,40 @@ if [[ ${#USED_PATHS[@]} -eq 0 ]]; then
|
|
|
117
153
|
exit 0
|
|
118
154
|
fi
|
|
119
155
|
|
|
120
|
-
used_codes=$(awk '
|
|
156
|
+
used_codes=$(awk -v prefix="$PREFIX" '
|
|
121
157
|
# Reset per-file so a trailing intentional-fake marker in the previous
|
|
122
158
|
# file cannot carry skip_next=1 into the first line of the next file.
|
|
123
|
-
FNR == 1 { skip_next = 0 }
|
|
159
|
+
FNR == 1 { skip_next = 0; in_rs_test = 0; rs_open = 0; rs_depth = 0 }
|
|
160
|
+
# Rust keeps its unit tests INSIDE the file they cover, under
|
|
161
|
+
# `#[cfg(test)] mod tests { ... }`, so a negative test naming a code it
|
|
162
|
+
# expects to be refused reads as a raw literal in the production half of
|
|
163
|
+
# that same file. Same brace-depth shape as audits/ufs.sh.
|
|
164
|
+
FILENAME ~ /\.rs$/ {
|
|
165
|
+
if (in_rs_test) {
|
|
166
|
+
tmp = $0
|
|
167
|
+
rs_depth += gsub(/\{/, "{", tmp) - gsub(/\}/, "}", tmp)
|
|
168
|
+
if (!rs_open && index($0, "{") > 0) rs_open = 1
|
|
169
|
+
if (rs_open && rs_depth <= 0) in_rs_test = 0
|
|
170
|
+
next
|
|
171
|
+
}
|
|
172
|
+
if ($0 ~ /^[[:space:]]*#\[cfg\(test\)\]/ ||
|
|
173
|
+
$0 ~ /^[[:space:]]*#\[[A-Za-z_:]*test\]/) {
|
|
174
|
+
in_rs_test = 1; rs_open = 0; rs_depth = 0
|
|
175
|
+
next
|
|
176
|
+
}
|
|
177
|
+
}
|
|
124
178
|
# Skip the line carrying the marker AND the next non-blank line (the
|
|
125
179
|
# marker sits on the comment line above the code line in Zig style).
|
|
126
180
|
/audit-error-codes: intentional-fake/ { skip_next = 1; next }
|
|
181
|
+
# A code NAMED in prose is a reference to history, not a call site: Rust doc
|
|
182
|
+
# comments routinely say which code a path used to return. Skipping them here
|
|
183
|
+
# matches the raw-literal scan below, which has always skipped comments.
|
|
184
|
+
/^[[:space:]]*\/\// { next }
|
|
127
185
|
/^[[:space:]]*$/ { next }
|
|
128
186
|
skip_next { skip_next = 0; next }
|
|
129
187
|
{
|
|
130
188
|
line = $0;
|
|
131
|
-
while (match(line,
|
|
189
|
+
while (match(line, prefix "-[A-Z][A-Z0-9]*-[0-9][0-9][0-9][0-9]*")) {
|
|
132
190
|
m = substr(line, RSTART, RLENGTH);
|
|
133
191
|
# Reject codes whose digit run is followed by a word char (rejects
|
|
134
192
|
# placeholder forms like UZ-INTERNAL-00X that grep would truncate).
|
|
@@ -153,7 +211,7 @@ used_count=0
|
|
|
153
211
|
# ---------------------------------------------------------------------------
|
|
154
212
|
orphans=$(comm -23 <(printf '%s\n' "$used_codes") <(printf '%s\n' "$declared_codes") | grep -v '^$' || true)
|
|
155
213
|
if [[ -n "$orphans" ]]; then
|
|
156
|
-
fail "orphan codes (used but not declared in $
|
|
214
|
+
fail "orphan codes (used but not declared in ${REGISTRY_PATHS[*]}):"
|
|
157
215
|
while IFS= read -r code; do
|
|
158
216
|
[[ -z "$code" ]] && continue
|
|
159
217
|
printf " %s refs:\n" "$code" >&2
|
|
@@ -188,20 +246,37 @@ fi
|
|
|
188
246
|
# 2-segment pattern. We don't want a "UZ-X-Y-NNN" code reintroduced
|
|
189
247
|
# via a local const that the orphan pass silently ignores.
|
|
190
248
|
# ---------------------------------------------------------------------------
|
|
191
|
-
raw_leaks=$(awk '
|
|
192
|
-
FNR == 1 { skip_next = 0 }
|
|
249
|
+
raw_leaks=$(awk -v prefix="$PREFIX" '
|
|
250
|
+
FNR == 1 { skip_next = 0; in_rs_test = 0; rs_open = 0; rs_depth = 0 }
|
|
251
|
+
# Rust keeps its unit tests INSIDE the file they cover, under
|
|
252
|
+
# `#[cfg(test)] mod tests { ... }`, so a negative test naming a code it
|
|
253
|
+
# expects to be refused reads as a raw literal in the production half of
|
|
254
|
+
# that same file. Same brace-depth shape as audits/ufs.sh.
|
|
255
|
+
FILENAME ~ /\.rs$/ {
|
|
256
|
+
if (in_rs_test) {
|
|
257
|
+
tmp = $0
|
|
258
|
+
rs_depth += gsub(/\{/, "{", tmp) - gsub(/\}/, "}", tmp)
|
|
259
|
+
if (!rs_open && index($0, "{") > 0) rs_open = 1
|
|
260
|
+
if (rs_open && rs_depth <= 0) in_rs_test = 0
|
|
261
|
+
next
|
|
262
|
+
}
|
|
263
|
+
if ($0 ~ /^[[:space:]]*#\[cfg\(test\)\]/ ||
|
|
264
|
+
$0 ~ /^[[:space:]]*#\[[A-Za-z_:]*test\]/) {
|
|
265
|
+
in_rs_test = 1; rs_open = 0; rs_depth = 0
|
|
266
|
+
next
|
|
267
|
+
}
|
|
268
|
+
}
|
|
193
269
|
# Skip the line carrying the marker AND the next non-blank line.
|
|
194
270
|
/audit-error-codes: intentional-fake/ { skip_next = 1; next }
|
|
195
271
|
/^[[:space:]]*\/\// { next }
|
|
196
272
|
/^[[:space:]]*$/ { next }
|
|
197
273
|
skip_next { skip_next = 0; next }
|
|
198
274
|
# Match any "UZ-XXX(-YYY)*-NNN" string literal.
|
|
199
|
-
|
|
275
|
+
$0 ~ "\"" prefix "-[A-Z][A-Z0-9-]*-[0-9]+\"" {
|
|
200
276
|
print FILENAME ":" FNR ":" $0;
|
|
201
277
|
}
|
|
202
278
|
' $(printf '%s\n' "${USED_PATHS[@]}" | grep -vE \
|
|
203
|
-
|
|
204
|
-
|| true) 2>/dev/null || true)
|
|
279
|
+
"$ALLOWLIST_RE" || true) 2>/dev/null || true)
|
|
205
280
|
|
|
206
281
|
if [[ -n "$raw_leaks" ]]; then
|
|
207
282
|
fail "raw UZ-* literals found outside the registry allowlist (must reference a registry symbol):"
|
|
@@ -215,7 +290,7 @@ fi
|
|
|
215
290
|
# ---------------------------------------------------------------------------
|
|
216
291
|
# 5. Verdict.
|
|
217
292
|
# ---------------------------------------------------------------------------
|
|
218
|
-
ok "registry: $declared_count declared, $used_count used"
|
|
293
|
+
ok "registry (${RUNTIME:-unknown}): $declared_count declared, $used_count used"
|
|
219
294
|
if [[ $FAIL -ne 0 ]]; then
|
|
220
295
|
printf "\n🔴 ERROR REGISTRY GATE: violations found. Reference the registry symbol (e.g. \`error_codes.ERR_X\` or \`client_errors.ERR_X\` for the executor crate) instead of duplicating the literal.\n" >&2
|
|
221
296
|
exit 1
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# REST API Design Guidelines — `agentsfleet/agentsfleetd`
|
|
2
2
|
|
|
3
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
|
|
4
|
+
**Trigger:** the global instruction `HTTP handler or OpenAPI changes → read docs/REST_API_DESIGN_GUIDELINES.md first` fires when the diff touches a handler, `public/openapi.json`, 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
5
|
|
|
6
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
7
|
|
|
@@ -37,12 +37,12 @@ Run this checklist as part of `CHORE(close)` (per `~/.claude/CLAUDE.md` lifecycl
|
|
|
37
37
|
- [ ] **Status codes** — 409 includes `current_state`; 412 includes `etag`; 429 includes `Retry-After` + `X-RateLimit-*` (§4)
|
|
38
38
|
- [ ] **ETag/`If-Match`** wired for any resource with realistic concurrent edits (§4)
|
|
39
39
|
- [ ] **Error responses** use the registry; `detail` follows hygiene rules (no IDs, no SQL, no paths, ≤200 chars) (§5)
|
|
40
|
-
- [ ] **OpenAPI
|
|
40
|
+
- [ ] **OpenAPI document** regenerated from the build, not hand-edited; the coverage gate is green (§6)
|
|
41
41
|
- [ ] **Route registered** in all six places (§7)
|
|
42
|
-
- [ ] **Handler signature**
|
|
42
|
+
- [ ] **Handler signature** takes only the extractors it reads and returns `Result<Response, Refusal>` (§8)
|
|
43
43
|
- [ ] **Middleware policy** picked from the table; raw handlers carry first-10-lines comment (§7)
|
|
44
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
|
|
45
|
+
- [ ] **Tests** — happy path + one error per refusal + idempotency double-PATCH + `Idempotency-Key` replay (where applicable) + ETag mismatch (§10)
|
|
46
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
47
|
- [ ] **The repository's declared `verify.unit` command is clean** — the route-scope table's exhaustive match and its tests cover the auth gate matrix (§10)
|
|
48
48
|
- [ ] **No file over 350 lines** (§10)
|
|
@@ -90,7 +90,7 @@ Nested paths express the containment relationship. Don't flatten — `/agents?wo
|
|
|
90
90
|
|
|
91
91
|
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.
|
|
92
92
|
|
|
93
|
-
**Collision check** — before adding `POST /v1/.../{id}:verb`, grep the same resource's schema in `public/openapi
|
|
93
|
+
**Collision check** — before adding `POST /v1/.../{id}:verb`, grep the same resource's schema in `public/openapi.json` 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>").
|
|
94
94
|
|
|
95
95
|
### Path-param naming consistency
|
|
96
96
|
|
|
@@ -141,7 +141,6 @@ If the parent ID is in the path, never repeat it in the body.
|
|
|
141
141
|
|
|
142
142
|
**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.
|
|
143
143
|
|
|
144
|
-
<!-- oracle-packs:start product.agentsfleet -->
|
|
145
144
|
**Owner-approved workspace exception.** `POST /v1/workspaces` accepts no replay
|
|
146
145
|
key. It requires a tenant-unique name and performs one tenant-scoped insert with
|
|
147
146
|
a server-assigned ID. After an uncertain browser response, the dashboard
|
|
@@ -149,7 +148,6 @@ refreshes every cursor page from `GET /v1/tenants/me/workspaces`; the
|
|
|
149
148
|
command-line interface performs one exact-name GET and does not retry the POST.
|
|
150
149
|
A deliberate same-name retry returns 409 and cannot create a second row. Do not
|
|
151
150
|
generalize this exception to other POST endpoints.
|
|
152
|
-
<!-- oracle-packs:end -->
|
|
153
151
|
|
|
154
152
|
**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.
|
|
155
153
|
|
|
@@ -183,12 +181,10 @@ If you need a different shape, amend this doc first. Do not invent a parallel po
|
|
|
183
181
|
|
|
184
182
|
The exact key set is `items`, `total`, `next_cursor` — no synonyms. `results`, `data`, `entries`, `nodes`, `records` are forbidden.
|
|
185
183
|
|
|
186
|
-
<!-- oracle-packs:start product.agentsfleet -->
|
|
187
184
|
Security-bound exception: `GET /v1/tenants/me/workspaces` also returns the
|
|
188
185
|
authoritative `tenant_id`. Browser and command-line clients persist that value
|
|
189
186
|
with the workspace list so a refreshed identity cannot mix local state from
|
|
190
187
|
two tenants.
|
|
191
|
-
<!-- oracle-packs:end -->
|
|
192
188
|
|
|
193
189
|
- `items` — always present, always an array. Empty result is `200 {"items": []}` — never `204`.
|
|
194
190
|
- `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.
|
|
@@ -219,7 +215,7 @@ GET /products?status=active&sort=-created_at&starting_after=01HZQ...&limit=50
|
|
|
219
215
|
- **Time ranges:** `?created_after=<ts_ms>&created_before=<ts_ms>`. Bracket grammar (`?created_at[gte]=...`) is forbidden.
|
|
220
216
|
- **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.
|
|
221
217
|
- **Sorting:** `sort=field` ascending; `sort=-field` descending. Single sort key per request — no multi-key.
|
|
222
|
-
- **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
|
|
218
|
+
- **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 `afd_core::paging::cursor`, and both the parameter name and the bounds are constants there: `QUERY_STARTING_AFTER`, `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (100). Read the limit through that module rather than parsing the query string in a handler. To page forward, send the response's `next_cursor` value back as the next request's `starting_after`. **Forbidden:** page-based `?page=&page_size=`, and custom request-side `?cursor=` names. Both spellings predate this rule where they survive; do not copy either into a new endpoint.
|
|
223
219
|
- **Sparse fieldsets / `?include=` / `?fields=`:** not supported in v1. If you need to slim a payload, design a smaller endpoint. Don't invent.
|
|
224
220
|
|
|
225
221
|
### Bulk operations
|
|
@@ -254,15 +250,18 @@ This rule is binding on responses AND requests. SDK consumers branch on `field =
|
|
|
254
250
|
|
|
255
251
|
## §4 — Response shape
|
|
256
252
|
|
|
257
|
-
### Success —
|
|
253
|
+
### Success — return the wire type
|
|
258
254
|
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
255
|
+
```rust
|
|
256
|
+
Ok(Json(FleetDetailResponse::from(&detail)).into_response())
|
|
257
|
+
Ok((StatusCode::CREATED, Json(CreatedResponse { id, key })).into_response())
|
|
258
|
+
Ok(StatusCode::NO_CONTENT.into_response())
|
|
263
259
|
```
|
|
264
260
|
|
|
265
|
-
The
|
|
261
|
+
The wire type owns the field names and `IntoResponse` writes the status and
|
|
262
|
+
content-type. A response shape belongs in `afd_wire`, not spelled inline in a
|
|
263
|
+
handler, so the same struct is what the OpenAPI document is generated from.
|
|
264
|
+
Build a response by hand only for a Server-Sent Events stream.
|
|
266
265
|
|
|
267
266
|
### Status codes
|
|
268
267
|
|
|
@@ -324,15 +323,17 @@ For any resource where concurrent edits are realistic (anything mutable that two
|
|
|
324
323
|
|
|
325
324
|
### Use the error registry
|
|
326
325
|
|
|
327
|
-
The error-code registry (`
|
|
326
|
+
The error-code registry (`rustd/crates/afd_core/src/error_code.rs` and the family modules beside it) owns the HTTP status, RFC 7807 `title`, and `docs_uri`. Your handler supplies only the code and a human-readable `detail`:
|
|
328
327
|
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
328
|
+
```rust
|
|
329
|
+
Refusal::malformed(DETAIL_WORKSPACE_ID) // the sentence is a constant
|
|
330
|
+
Refusal::coded(FLEET_NOT_FOUND, DETAIL_NOT_FOUND)
|
|
331
|
+
Refusal::preconditioned(error.code(), error.detail(), current)
|
|
333
332
|
```
|
|
334
333
|
|
|
335
|
-
|
|
334
|
+
Never assemble a `Problem` by hand and never write a status and body directly:
|
|
335
|
+
the status, title and documentation link come from the registry entry, and a
|
|
336
|
+
hand-built body is how two spellings of one error reach clients.
|
|
336
337
|
|
|
337
338
|
If you need an error code that doesn't exist:
|
|
338
339
|
|
|
@@ -360,7 +361,7 @@ The title must be safe to render verbatim in a UI toast — think "what would I
|
|
|
360
361
|
5. **Ban list — `detail` MUST NOT contain any of these substrings, even when paraphrased:**
|
|
361
362
|
- Internal table/column names: `pg_`, `pg.`, table names from `schema/*.sql`, column names not exposed in the OpenAPI response.
|
|
362
363
|
- SQL fragments: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `WHERE`, `JOIN`, `CONSTRAINT`, `relation does not exist`, `duplicate key value violates`.
|
|
363
|
-
- Code internals: `panic`, `
|
|
364
|
+
- Code internals: `panic`, `unwrap`, `error.`, a source path, a file extension, a line number, file paths starting with `/`, allocator names, struct names from non-public modules.
|
|
364
365
|
- Stack traces, addresses (`0x...`), thread IDs, request-internal pointer values.
|
|
365
366
|
- Secret-shaped strings: anything matching `sk_`, `pk_`, `Bearer `, `eyJ` (JWT), `op://`, hex blobs >16 chars.
|
|
366
367
|
- Entity values: literal UUIDs, emails, raw bearer tokens, vault key names, file paths from the workspace.
|
|
@@ -370,19 +371,19 @@ The title must be safe to render verbatim in a UI toast — think "what would I
|
|
|
370
371
|
- State: `"<noun> already exists"` / `"<noun> not found"` / `"<noun> expired"` — `Agent name already exists`, `token expired`.
|
|
371
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)`.
|
|
372
373
|
|
|
373
|
-
When you write a new
|
|
374
|
+
When you write a new refusal, find the closest existing call site under `rustd/crates/afd_api_*/src/handler/**` and copy its shape. Don't freelance.
|
|
374
375
|
|
|
375
376
|
### Internal 500s — direct calls
|
|
376
377
|
|
|
377
|
-
|
|
378
|
+
A datastore or service failure is not a sentence a handler writes. Lift it with
|
|
379
|
+
`Refusal::at`, naming the operation that failed:
|
|
378
380
|
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
common.internalDbError(hx.res, hx.req_id); // query failed
|
|
382
|
-
common.internalOperationError(hx.res, "detail", hx.req_id);
|
|
381
|
+
```rust
|
|
382
|
+
services.fleet_detail(&context, fleet_id).await.map_err(Refusal::at(FLEET_READ))?
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
-
|
|
385
|
+
The operation constant is what identifies the failure in logs and telemetry;
|
|
386
|
+
the caller gets the registry's 500 and no internal detail.
|
|
386
387
|
|
|
387
388
|
### Error response body (RFC 7807)
|
|
388
389
|
|
|
@@ -409,150 +410,163 @@ Don't invent other extensions without amending this doc.
|
|
|
409
410
|
|
|
410
411
|
## §6 — OpenAPI editing
|
|
411
412
|
|
|
412
|
-
The
|
|
413
|
+
**The document is generated. There is nothing to hand-edit.**
|
|
413
414
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
├── responses.yaml
|
|
421
|
-
└── security.yaml
|
|
422
|
-
```
|
|
415
|
+
`public/openapi.json` is emitted from the daemon's own handlers — the route
|
|
416
|
+
table decides the paths and methods, `#[utoipa::path]` beside each handler
|
|
417
|
+
carries the prose and the status codes, and the `afd_wire` types carry the
|
|
418
|
+
schemas. The `public/openapi/` YAML tree this section used to describe was
|
|
419
|
+
retired with the Zig daemon; a hand-edit to the JSON is reverted by the next
|
|
420
|
+
regeneration and fails a test before it gets that far.
|
|
423
421
|
|
|
424
422
|
### Adding, renaming, or removing an endpoint
|
|
425
423
|
|
|
426
|
-
1.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
424
|
+
1. Add, rename or remove the [`Route`] variant and its template, and say which
|
|
425
|
+
methods it answers in that family's `verbs()`.
|
|
426
|
+
2. Mount it in `rustd/crates/afd_api/src/router/mount.rs`.
|
|
427
|
+
3. Put a `#[cfg_attr(feature = "openapi", utoipa::path(…))]` on the handler and
|
|
428
|
+
name it in that plane's `src/openapi.rs` collector.
|
|
429
|
+
4. Regenerate and commit the artifact with the code:
|
|
431
430
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
## §7 — Registering a route
|
|
431
|
+
```bash
|
|
432
|
+
cd rustd && cargo run -q -p agentsfleetd --features openapi \
|
|
433
|
+
--bin agentsfleetd -- --no-banner openapi > ../public/openapi.json
|
|
434
|
+
```
|
|
437
435
|
|
|
438
|
-
|
|
436
|
+
**Parity is mechanical, not reviewer-enforced.** Three tests grade it, and the
|
|
437
|
+
first is the one that used to be a review obligation:
|
|
439
438
|
|
|
440
|
-
|
|
|
439
|
+
| Test | What it refuses |
|
|
441
440
|
|---|---|
|
|
442
|
-
|
|
|
443
|
-
|
|
|
444
|
-
|
|
|
445
|
-
| 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. |
|
|
446
|
-
| 5 (invoke shim) | Compile error — `specFor` references `invoke.invokeMyEndpoint` which doesn't exist. |
|
|
447
|
-
| 6 (OpenAPI YAML) | **No automated check.** Router ↔ openapi.json parity is reviewer-enforced (§6). Reviewer must confirm both surfaces moved in the same diff. |
|
|
441
|
+
| `test_coverage_gate_rust_source` | a served route with no annotation, or an annotation for a route nobody mounts — named with its method and direction |
|
|
442
|
+
| `test_openapi_build_is_the_source` | a committed artifact that is not what the build emits |
|
|
443
|
+
| `test_documented_codes_match_refusals` | an operation that omits a refusal its guard or scope rung guarantees |
|
|
448
444
|
|
|
449
|
-
|
|
445
|
+
The prose is graded too: `scripts/check_documentation_rules.py` reads the
|
|
446
|
+
generated document, so a description that breaks the wording rules fails
|
|
447
|
+
`make lint-all`. Fix it at the annotation — or, for a schema description, at the
|
|
448
|
+
`afd_wire` type's doc comment, which is what utoipa publishes. Rationale that is
|
|
449
|
+
for maintainers rather than for API consumers belongs in a `//` comment beside
|
|
450
|
+
it, which the document does not carry.
|
|
450
451
|
|
|
451
|
-
|
|
452
|
-
2. **`src/agentsfleetd/http/router.zig::match()`** — add the path parser that returns your variant.
|
|
453
|
-
3. **`src/agentsfleetd/http/route_table.zig::specFor()`** — map the variant to a `RouteSpec`:
|
|
452
|
+
## §7 — Registering a route
|
|
454
453
|
|
|
455
|
-
|
|
456
|
-
|
|
454
|
+
**The router is built FROM the route table, not beside it.** `Route::all()` is
|
|
455
|
+
walked once at startup and every template it yields is mounted, so a path
|
|
456
|
+
cannot be served under a spelling the table does not carry, and it cannot be
|
|
457
|
+
served under a guard its own row does not declare. Adding an endpoint is
|
|
458
|
+
therefore a table edit plus a handler, in three places.
|
|
459
|
+
|
|
460
|
+
1. **`rustd/crates/afd_http/src/route/<family>.rs`** — add a variant to the
|
|
461
|
+
family enum (`FleetRoute`, `TenantRoute`, `AdminRoute`, `WebhookRoute`,
|
|
462
|
+
`WorkspaceRoute`, `ConnectorRoute`, `RunnerRoute`, `RunnerOpsRoute`,
|
|
463
|
+
`AuthRoute`, `OpsRoute`) **and to that family's `ALL` array**. `ALL` is what
|
|
464
|
+
`Route::all()` iterates; a variant missing from it compiles and is never
|
|
465
|
+
mounted.
|
|
466
|
+
|
|
467
|
+
2. **The same file's `meta()`** — return a `RouteMeta` for the variant:
|
|
468
|
+
|
|
469
|
+
```rust
|
|
470
|
+
Self::MyEndpoint => (
|
|
471
|
+
RouteClass::Api,
|
|
472
|
+
fleet_path!("/my-endpoint"),
|
|
473
|
+
Scopes::rw(FLEET_READ, FLEET_WRITE),
|
|
474
|
+
),
|
|
457
475
|
```
|
|
458
476
|
|
|
459
|
-
|
|
477
|
+
`RouteMeta::new(guard, class, template, scopes)` closes over four of the five
|
|
478
|
+
facts; the fifth, `ownership`, is derived from the template by
|
|
479
|
+
`Ownership::of` and is not yours to pass.
|
|
460
480
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
481
|
+
3. **The plane crate** — write the handler under
|
|
482
|
+
`rustd/crates/afd_api_{tenant,operator,ingress,runner}/src/handler/**`, then
|
|
483
|
+
return it from that plane's `*_handler_for` arm (`fleet_handler_for`,
|
|
484
|
+
`admin_handler_for`, and their siblings in each plane's `lib.rs`).
|
|
485
|
+
`afd_api/src/router/mount.rs` dispatches families to planes with a total
|
|
486
|
+
match, so a new family cannot silently inherit another plane's
|
|
487
|
+
authentication.
|
|
464
488
|
|
|
465
|
-
|
|
489
|
+
4. **`public/openapi.json`** — add `#[cfg_attr(feature = "openapi", utoipa::path(…))]`
|
|
490
|
+
beside the handler and regenerate (§6).
|
|
466
491
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
6. **`public/openapi/paths/<tag>.yaml`** — add the endpoint (§6).
|
|
475
|
-
|
|
476
|
-
### Matcher style — segment-based, not substring-based
|
|
477
|
-
|
|
478
|
-
All path matchers operate on a canonical `Path` view: a stack-allocated array
|
|
479
|
-
of segments parsed once at the dispatch boundary. The matcher functions
|
|
480
|
-
themselves live in `src/agentsfleetd/http/route_matchers.zig` plus sibling
|
|
481
|
-
per-domain files (`route_matchers_connectors.zig`, `route_matchers_fleet.zig`,
|
|
482
|
-
`route_matchers_runner.zig`, `route_matchers_billing.zig`,
|
|
483
|
-
`route_matchers_webhook.zig`) — same RULE FLL split as the invoke shims.
|
|
484
|
-
Matchers compare by **segment count + segment[i] equality**. Disambiguation is
|
|
485
|
-
shape-driven, not order-driven.
|
|
486
|
-
|
|
487
|
-
**Pattern (mandatory for any new matcher):**
|
|
488
|
-
|
|
489
|
-
```zig
|
|
490
|
-
pub fn matchMyEndpoint(p: Path) ?MyRoute {
|
|
491
|
-
if (p.segs.len != N) return null; // exact segment count
|
|
492
|
-
if (!p.eq(0, "literal-1")) return null; // literal slots
|
|
493
|
-
if (!p.eq(2, "literal-2")) return null;
|
|
494
|
-
const id_a = p.param(1) orelse return null; // path-param slots: param() rejects empty
|
|
495
|
-
const id_b = p.param(3) orelse return null;
|
|
496
|
-
return .{ .field_a = id_a, .field_b = id_b };
|
|
497
|
-
}
|
|
498
|
-
```
|
|
492
|
+
| Skipped step | Failure mode |
|
|
493
|
+
|---|---|
|
|
494
|
+
| 1, the enum variant | Compile error — `meta()` is an exhaustive match over the family. |
|
|
495
|
+
| 1, the `ALL` entry | **Silent.** The route compiles, is never walked, and answers 404. Nothing fails. Test the URL after wiring. |
|
|
496
|
+
| 2 (`meta()` arm) | Compile error — the match is not total without it. |
|
|
497
|
+
| 3 (`*_handler_for` arm) | The route is tabled and unmounted, so it answers 404 by design. `handler_for` returning `None` is the honest answer for a verb this binary does not serve; a 501 would claim the endpoint exists here. |
|
|
498
|
+
| 4 (the document) | `test_coverage_gate_rust_source` fails, naming the route, the method and which side is missing it (§6). |
|
|
499
499
|
|
|
500
|
-
|
|
501
|
-
- `std.mem.startsWith` / `endsWith` / `indexOf` on the path string.
|
|
502
|
-
- Suffix-driven dispatch (`if endsWith(path, "/foo")`).
|
|
503
|
-
- Implicit segment boundaries via offset arithmetic.
|
|
504
|
-
- Reservations encoded as call-site ordering ("must run matcher A before B").
|
|
505
|
-
|
|
506
|
-
**Required properties of a matcher set:**
|
|
507
|
-
- **Mutual exclusivity by structure.** Any two matchers must be reject-incompatible
|
|
508
|
-
by segment count or segment-equality predicates. If two matchers can both
|
|
509
|
-
fire on the same path, one of them needs a tighter predicate (e.g. an explicit
|
|
510
|
-
`if (p.eq(i, RESERVED)) return null` exclusion). Order in `match()` must not
|
|
511
|
-
decide correctness.
|
|
512
|
-
- **Empty-segment safety.** `Path.parse` preserves empty segments from `//` and
|
|
513
|
-
trailing slashes. Matchers extract path-parameter slots via `param(idx)`,
|
|
514
|
-
which returns null on empty — empty IDs reject at the matcher, not the
|
|
515
|
-
handler.
|
|
516
|
-
- **Semantic-named typed structs.** Each `Route` enum variant gets its own
|
|
517
|
-
struct with semantic field names (`credential_name`, `agent_id`, `grant_id`,
|
|
518
|
-
`memory_key`, `gate_id`, …). Parsing logic may be shared via a private
|
|
519
|
-
helper, but the public surface stays type-distinct.
|
|
520
|
-
|
|
521
|
-
**Single API-version site.** The dispatcher in `router.zig::match()` calls
|
|
522
|
-
`Path.parse(...)` once, checks `segs[0]` against the API version, then hands
|
|
523
|
-
`p.tail(1)` to the version-specific matchers (`matchV1`, future `matchV2`).
|
|
524
|
-
The string `"v1"` lives in exactly one place — that dispatch line. No matcher
|
|
525
|
-
body checks the API version.
|
|
526
|
-
|
|
527
|
-
### Middleware policy table — pick one at step 3
|
|
528
|
-
|
|
529
|
-
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.
|
|
530
|
-
|
|
531
|
-
| Policy | Use for |
|
|
532
|
-
|--------|---------|
|
|
533
|
-
| `auth_mw.MiddlewareRegistry.none` | Public endpoint; no auth, OR handler does its own (OAuth/connector callbacks, webhook receivers, Slack events ingress) |
|
|
534
|
-
| `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 |
|
|
535
|
-
| `registry.runnerBearer()` | Runner-token (`agt_r`) machine principal + capability gate; used by the `/v1/runners/me/*` self-plane, which requires `runner:self` |
|
|
536
|
-
| `registry.webhookHmac()` | Approval webhook (HMAC-signed body) |
|
|
537
|
-
| `registry.webhookSig()` | Per-agent HMAC signature for webhooks routed to a fleet (no Bearer fallback) |
|
|
538
|
-
| `registry.svix()` | Svix v1 multi-sig HMAC (Clerk) |
|
|
500
|
+
### Templates are low-cardinality, always
|
|
539
501
|
|
|
540
|
-
|
|
502
|
+
`RouteMeta.template` is the `http.route` span attribute as well as the mount
|
|
503
|
+
path. It carries `{workspace_id}`, `{fleet_id}` and their siblings as
|
|
504
|
+
parameters and never a concrete value — a real path would put tenant identity
|
|
505
|
+
into span attributes and give the tracing backend one route value per request.
|
|
541
506
|
|
|
542
|
-
|
|
507
|
+
`matchit`, the router underneath axum, **refuses a literal after a parameter
|
|
508
|
+
inside one segment**, so a custom verb cannot be spelled `{id}:sync`. Where the
|
|
509
|
+
Zig daemon and the shipped clients used that form, the Rust table spells it as
|
|
510
|
+
its own segment (`/schedules/{schedule_id}/sync`) and the change travels with
|
|
511
|
+
its clients in the same diff.
|
|
543
512
|
|
|
544
|
-
|
|
513
|
+
### The five facts a route row carries
|
|
545
514
|
|
|
546
|
-
|
|
|
547
|
-
|
|
548
|
-
| `
|
|
549
|
-
| `
|
|
550
|
-
| `
|
|
551
|
-
| `
|
|
552
|
-
| `
|
|
553
|
-
|
|
515
|
+
| Fact | Type | What it decides |
|
|
516
|
+
|---|---|---|
|
|
517
|
+
| `guard` | `Guard` | What must be presented before a handler runs. |
|
|
518
|
+
| `class` | `RouteClass` | `Ops` never sheds, `Stream` is capped separately because one Server-Sent Events connection is held for minutes, `Api` is subject to the in-flight ceiling. |
|
|
519
|
+
| `template` | `&'static str` | The mount path and the `http.route` attribute. |
|
|
520
|
+
| `scopes` | `Scopes` | `Always(&[…])` for one requirement, or `ByMethod { get, otherwise }` where reads are cheaper than writes. `otherwise` is always the more privileged of the pair, so a method nobody considered can only be refused too often. |
|
|
521
|
+
| `ownership` | `Ownership` | Derived from the template. Which object named in the path must belong to the caller. |
|
|
522
|
+
|
|
523
|
+
**Ownership is the half of authorization a handler used to have to remember.**
|
|
524
|
+
Capability and ownership are independent questions — may you do this kind of
|
|
525
|
+
thing, and is this particular thing yours. The Zig daemon answered the first in
|
|
526
|
+
its route table and the second by calling `authorizeWorkspace` by hand at the
|
|
527
|
+
top of every workspace handler, where forgetting it was a cross-tenant read
|
|
528
|
+
with nothing failing. Here it is mounted from the route's own template, so
|
|
529
|
+
forgetting is not a thing a handler can do.
|
|
530
|
+
|
|
531
|
+
### Guard — pick one at step 2
|
|
532
|
+
|
|
533
|
+
| Guard | Use for |
|
|
534
|
+
|--------|---------|
|
|
535
|
+
| `Guard::Open` | A probe, or a route authenticated by its own payload: the device-flow session endpoints, the connector callback, every webhook. |
|
|
536
|
+
| `Guard::Bearer` | The standard tenant-plane credential — session bearer, `agt_t`, or `afc_`. The capability the principal must hold is the row's `scopes`, not a second guard. |
|
|
537
|
+
| `Guard::RunnerBearer` | A runner-plane credential (`agt_r`). Refused for tenant callers by `afd_auth::Plane`, which is data rather than which router mounted it. |
|
|
538
|
+
| `Guard::WebhookHmac` | An HMAC over the request body, keyed per fleet. |
|
|
539
|
+
| `Guard::WebhookSignature` | The per-fleet webhook signature header. |
|
|
540
|
+
| `Guard::Svix` | A Svix-signed delivery. |
|
|
541
|
+
|
|
542
|
+
The layers run outside your handler and in a fixed order: **admission**
|
|
543
|
+
outermost, because a shed must stay cheaper than the work it refuses;
|
|
544
|
+
**authentication and the capability gate** next, so a handler never runs for a
|
|
545
|
+
caller who should not reach it; **ownership** innermost, because it is the only
|
|
546
|
+
one of the three that runs a statement. A caller who is over the ceiling or
|
|
547
|
+
short a capability is refused before this daemon reaches Postgres for them.
|
|
548
|
+
|
|
549
|
+
Each layer is decided while the router is BUILT, not per request: a route that
|
|
550
|
+
is not metered has no admission layer in its stack to consult, and a route with
|
|
551
|
+
no guard has no authenticator in its stack to reach.
|
|
552
|
+
|
|
553
|
+
### `Guard::Open` routes verify their own callers
|
|
554
|
+
|
|
555
|
+
An open route is not an unauthenticated one, except for the probes. The proof
|
|
556
|
+
is the body or a signed parameter, which is why it cannot be checked before the
|
|
557
|
+
body is read — and why those routes carry a buffer cap the bearer-proven ones
|
|
558
|
+
do not need.
|
|
559
|
+
|
|
560
|
+
| Route family | What verifies the caller instead |
|
|
561
|
+
|------|------|
|
|
562
|
+
| `OpsRoute` (`/healthz`, `/readyz`) | Nothing. Unauthenticated by design — an orchestrator that cannot reach these has no other way to ask. |
|
|
563
|
+
| `AuthRoute::{CreateSession, PollSession, VerifySession}` | The device-flow code itself; approve and delete are `Guard::Bearer`. |
|
|
564
|
+
| `WebhookRoute`, every variant | A signature over the body — per-fleet HMAC, the fleet signature header, or Svix. |
|
|
565
|
+
| `ConnectorRoute` callback and events | A signed `state` parameter verified inline before the provider hook runs, or the provider's own request signature. |
|
|
554
566
|
|
|
555
|
-
|
|
567
|
+
A new `Guard::Open` route states in its module documentation what verifies the
|
|
568
|
+
caller instead. One that verifies nothing and says nothing is a bug, not an
|
|
569
|
+
oversight.
|
|
556
570
|
|
|
557
571
|
### Reference implementations
|
|
558
572
|
|
|
@@ -560,74 +574,58 @@ When in doubt, mirror an existing handler:
|
|
|
560
574
|
|
|
561
575
|
| Pattern | Look at |
|
|
562
576
|
|---------|---------|
|
|
563
|
-
|
|
|
564
|
-
|
|
|
565
|
-
|
|
|
566
|
-
|
|
|
567
|
-
|
|
|
568
|
-
|
|
|
569
|
-
|
|
|
570
|
-
| Streaming (SSE) | `src/agentsfleetd/http/handlers/fleets/events_stream.zig::innerEventsStream` |
|
|
571
|
-
|
|
572
|
-
---
|
|
577
|
+
| Read, edit and purge one resource | `rustd/crates/afd_api_tenant/src/handler/fleet/detail.rs` |
|
|
578
|
+
| Request parsing and its refusal sentences | `rustd/crates/afd_api_tenant/src/handler/fleet/detail_request.rs` |
|
|
579
|
+
| Keyset pagination | `rustd/crates/afd_api_tenant/src/handler/tenant/models.rs` |
|
|
580
|
+
| Platform plane, operator-held | `rustd/crates/afd_api_operator/src/handler/admin/platform_keys.rs` |
|
|
581
|
+
| Streaming (Server-Sent Events) | `rustd/crates/afd_api_tenant/src/handler/stream.rs` |
|
|
582
|
+
| Signature-proven ingress | `rustd/crates/afd_api_ingress/src/handler/webhook/receive_route.rs` |
|
|
583
|
+
| A runner speaking for itself | `rustd/crates/afd_api_runner/src/handler/runner/lease.rs` |
|
|
573
584
|
|
|
574
585
|
## §8 — Handler signature rule
|
|
575
586
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
587
|
+
A handler is an axum handler. The router built it into a stack that has already
|
|
588
|
+
admitted the request, proven the caller, checked the capability and confirmed
|
|
589
|
+
ownership, so a handler receives what it needs as extractors and constructs
|
|
590
|
+
none of it.
|
|
591
|
+
|
|
592
|
+
```rust
|
|
593
|
+
pub async fn my_endpoint<D: Services>(
|
|
594
|
+
State(services): State<Arc<D>>,
|
|
595
|
+
context: WorkspaceContext,
|
|
596
|
+
Path(path): Path<FleetPath>,
|
|
597
|
+
Json(body): Json<MyRequest>,
|
|
598
|
+
) -> Result<Response, Refusal> {
|
|
599
|
+
// 1. parse and bound the inputs
|
|
600
|
+
// 2. call the service
|
|
601
|
+
// 3. return the wire type
|
|
583
602
|
}
|
|
584
603
|
```
|
|
585
604
|
|
|
586
605
|
### Rules
|
|
587
606
|
|
|
588
|
-
- **
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
- **
|
|
592
|
-
|
|
607
|
+
- **Take what you read, and nothing else.** `State` for services, the auth
|
|
608
|
+
context for the caller, `Path` for identifiers, `Json` or `Bytes` for a body.
|
|
609
|
+
A handler that does not read the body does not extract one.
|
|
610
|
+
- **Return `Result<Response, Refusal>`**, or a wire type that implements
|
|
611
|
+
`IntoResponse`. `Refusal` carries the error code and the sentence; the
|
|
612
|
+
envelope is built for you.
|
|
613
|
+
- **Bounds are declared on the request type**, with `garde`, not re-spelled per
|
|
614
|
+
handler. The refusal sentence a field earns is mapped from the path `garde`
|
|
615
|
+
reports.
|
|
616
|
+
- **Never authenticate inside a handler.** The guard on the route's row did it.
|
|
617
|
+
Never call an ownership check by hand either; the template mounted it.
|
|
618
|
+
- **Refusal sentences live beside the parser that produces them**, as
|
|
619
|
+
constants, so the test that asserts one reads it from the same place.
|
|
593
620
|
|
|
594
621
|
### What NOT to do
|
|
595
622
|
|
|
596
|
-
- ❌
|
|
597
|
-
|
|
598
|
-
- ❌
|
|
599
|
-
|
|
600
|
-
- ❌
|
|
601
|
-
|
|
602
|
-
### `Hx` struct reference
|
|
603
|
-
|
|
604
|
-
`Hx` is defined in `src/agentsfleetd/http/handlers/hx.zig`. Five fields, passed by value; three methods, each wrapping a distinct wire shape:
|
|
605
|
-
|
|
606
|
-
```zig
|
|
607
|
-
pub const Hx = struct {
|
|
608
|
-
alloc: std.mem.Allocator, // request-scoped arena, built by the dispatcher
|
|
609
|
-
principal: common.AuthPrincipal, // populated by bearer/runner middleware; zero-value for none-policy routes
|
|
610
|
-
req_id: []const u8, // unique request ID
|
|
611
|
-
ctx: *common.Context, // pool, queue, oidc, telemetry, app_url
|
|
612
|
-
res: *httpz.Response, // response writer
|
|
613
|
-
|
|
614
|
-
pub fn ok(self: Self, status: std.http.Status, body: anytype) void; // JSON envelope
|
|
615
|
-
pub fn fail(self: Self, code: []const u8, detail: []const u8) void; // RFC 7807 error
|
|
616
|
-
pub fn noContent(self: Self) void; // spec-compliant empty-body 204
|
|
617
|
-
};
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
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).
|
|
621
|
-
|
|
622
|
-
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.
|
|
623
|
-
|
|
624
|
-
### Guardrails on `hx.zig`
|
|
625
|
-
|
|
626
|
-
- `hx.zig` stays small — methods are one-liners delegating to `common.zig`. No business logic in `hx.zig`.
|
|
627
|
-
- 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.
|
|
628
|
-
- Zero `ArenaAllocator.init` calls in handler files — the dispatcher (`server.zig::dispatchMatchedRoute()`) is the only place that owns the per-request arena's lifetime.
|
|
629
|
-
|
|
630
|
-
---
|
|
623
|
+
- ❌ Reading a query string directly for `limit` or `starting_after` — use
|
|
624
|
+
`afd_core::paging`.
|
|
625
|
+
- ❌ Building a `Problem` by hand — construct it from the declared error code so
|
|
626
|
+
the status, title and documentation link come from the registry (§5).
|
|
627
|
+
- ❌ A second unique spelling of a capability check inside the handler body.
|
|
628
|
+
- ❌ Re-parsing a path parameter the extractor already typed.
|
|
631
629
|
|
|
632
630
|
## §9 — Versioning
|
|
633
631
|
|
|
@@ -706,7 +704,7 @@ Before opening a PR touching any handler:
|
|
|
706
704
|
- [ ] Cross-compile: `zig build -Dtarget=x86_64-linux && zig build -Dtarget=aarch64-linux`
|
|
707
705
|
- [ ] the declared `verify.lint` command passes — every language gate the repository declares
|
|
708
706
|
- [ ] Handler file ≤ 350 lines; split if it grows
|
|
709
|
-
- [ ] Integration test covers the happy path AND at least one error path per
|
|
707
|
+
- [ ] Integration test covers the happy path AND at least one error path per refusal the handler can return
|
|
710
708
|
- [ ] 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)
|
|
711
709
|
- [ ] POST endpoints accepting `Idempotency-Key` have a replay test: same key + same body → cached response; same key + different body → 4xx (§2)
|
|
712
710
|
- [ ] Mutable resources have an ETag/`If-Match` test: stale `If-Match` → 412 with current `etag` returned (§4)
|
package/docs/RULE_ENFORCEMENT.md
CHANGED
|
@@ -7,14 +7,14 @@ regenerates this file and fails on any difference. To update it, run
|
|
|
7
7
|
| Rule document | enforced by | judged | acknowledged | classified | trigger |
|
|
8
8
|
|---|---|--:|--:|--:|---|
|
|
9
9
|
| `docs/LOGGING_STANDARD.md` | `LOG` → `logging.sh` | 11 | 13 | 30/30 | mechanical |
|
|
10
|
-
| `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/
|
|
10
|
+
| `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/65 | mechanical |
|
|
11
11
|
| `docs/SCHEMA_CONVENTIONS.md` | — | 0 | 0 | 0/16 | mechanical |
|
|
12
12
|
| `docs/DOCUMENTATION_RULES.md` | — | 0 | 0 | 0/22 | latent |
|
|
13
13
|
| `docs/LIFECYCLE_PATTERNS.md` | — | 0 | 0 | 0/22 | mechanical |
|
|
14
14
|
| `docs/CHANGELOG_VOICE.md` | — | 0 | 0 | 0/3 | latent |
|
|
15
15
|
| `docs/VERIFY_TIERS.md` | — | 0 | 0 | 0/7 | uncited |
|
|
16
16
|
| `docs/greptile-learnings/RULES.md` | — | 0 | 0 | 0/114 | mechanical |
|
|
17
|
-
| **corpus** | | | | **30/
|
|
17
|
+
| **corpus** | | | | **30/279** | |
|
|
18
18
|
|
|
19
19
|
## How to read this
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentsfleet/orly",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|