@agentsfleet/orly 0.9.3 → 0.10.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/AGENTS.md CHANGED
@@ -144,7 +144,7 @@ Guards fire pre-hoc regardless of lifecycle stage. Override: `<GATE>: SKIPPED pe
144
144
  | write auth-flow / token-minting files | `write_auth` | `dispatch/write_auth.md` — auth invariants; reads the product repo's `docs/AUTH.md` before · 🟣 delegated (product repo). |
145
145
  | write published docs / OpenAPI prose | `write_documentation` | `dispatch/write_documentation.md` → `docs/DOCUMENTATION_RULES.md` before narrower guides; page, fragment, API, changelog scopes differ · 🟣 delegated to repository pre-commit checks. |
146
146
  | write a changelog entry | `write_changelog` | `dispatch/write_changelog.md` — changelog voice (one headline, no marketing words, `**Bold lead-noun**` bullets, history append-only); reads `docs/CHANGELOG_VOICE.md` · 🤔 judgment-only. |
147
- | write/update a PR or MR body or its session notes | `write_pr_description` | `dispatch/write_pr_description.md` — append numbered `Session notes`, `Review`, and `Make`; measured fenced diagrams; review dispositions; commands with real output. |
147
+ | write/update a PR or MR body or its session notes | `write_pr_description` | `dispatch/write_pr_description.md` — append numbered `Session notes`, `Review`, and `Make`; measured fenced diagrams; visual evidence from the lane's own artifacts; review dispositions; commands with real output. |
148
148
  | claim "tests pass / ready / shipping" | `verify` | `dispatch/verify.md` — verification tiers (`make` canonical; package-scoped runners are **not** verification), done-message glyph format · 🤔 judgment-only, `VERIFY GATE: <target> skipped per environment constraint (reason: ...)` only when genuinely unrunnable. |
149
149
  | name a stream/channel/Redis namespace/queue/RPC/Postgres schema, or describe a flow | `name_architecture` | `dispatch/name_architecture.md` — architecture-consult discipline; grep relevant `docs/architecture/` (chat brainstorming counts) · **no override** — doc wins until reconciled. |
150
150
  | run a lifecycle stage (open→close a stream, worktree setup, milestone bootstrap) | `lifecycle` | `dispatch/lifecycle.md` — stage runbooks: CHORE(open/close) checklists, PLAN expansions, spec discipline, deferral format, pre-PR gates, LAND · 🤔 judgment-only. |
@@ -1,60 +1,51 @@
1
1
  #!/usr/bin/env bash
2
- # error-codes.sh — orphan + dead code detection against the canonical
3
- # error registry at src/agentsfleetd/errors/error_registry.zig, plus a raw-literal check
4
- # enforcing that UZ-<CAT>-<NNN> strings only appear in registry/allowlist
5
- # files (every other call site must reference the registry symbol).
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 `make harness-verify` in `agentsfleet`).
8
+ # Fires in: CONFORM (via the repository's declared `conform` command).
9
9
  #
10
- # TECHNICAL DEBT (acknowledged on migration to dotfiles, 2026-05-11):
11
- # The script hard-codes:
12
- # - `UZ-` as the error-code prefix (agentsfleet schema)
13
- # - `src/agentsfleetd/errors/error_registry.zig` as the canonical registry path
14
- # - The raw-literal allowlist below (src/agentsfleetd/errors/**, src/runner/engine/client_errors.zig)
15
- # A parameterised version that reads prefix + registry path + allowlist
16
- # from a per-project config (e.g. `.audit-error-codes.toml`) is the right
17
- # long-term shape; not done yet. Today, this script lives in dotfiles so
18
- # the gate body and the audit binary stay co-located.
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 UZ-<CAT>-<NNN> string literal in error_registry.zig
22
- # - USED — every UZ-<CAT>-<NNN> reference in src/**/*.zig (excluding
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 — UZ-<CAT>-<NNN> string literal in a source file outside
30
- # the registry/allowlist (BLOCKING — RULE UFS at the error
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
- # Tests INCLUDED for orphan detection — same code-registry rules apply to
48
- # test code. A test referencing a fake code (e.g. UZ-FAKE-999) for
49
- # negative-path testing must annotate the line:
50
- # `// audit-error-codes: intentional-fake` and the audit will skip it.
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 findings never block).
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
- REGISTRY="src/agentsfleetd/errors/error_registry.zig"
66
- [[ -f "$REGISTRY" ]] || { printf "FAIL: registry missing: %s\n" "$REGISTRY" >&2; exit 1; }
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 -oE 'UZ-[A-Z][A-Z0-9]*-[0-9]{3,}\b' "$REGISTRY" | sort -u)
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 $REGISTRY (registry empty?)"
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 -E '^src/' \
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
- find src -type f -name '*.zig' 2>/dev/null \
102
- | grep -vE '^src/agentsfleetd/errors/error_registry\.zig$'
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, /UZ-[A-Z][A-Z0-9]*-[0-9][0-9][0-9][0-9]*/)) {
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 $REGISTRY):"
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
- /"UZ-[A-Z][A-Z0-9-]*-[0-9]+"/ {
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
- '^src/agentsfleetd/errors/|/_?test_harness\.zig$|_test\.zig$|^src/runner/engine/client_errors\.zig$|^src/zbench_fixtures\.zig$' \
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
@@ -148,7 +148,7 @@ Guards fire pre-hoc regardless of lifecycle stage. Override: `<GATE>: SKIPPED pe
148
148
  | write auth-flow / token-minting files | `write_auth` | `dispatch/write_auth.md` — auth invariants; reads the product repo's `docs/AUTH.md` before · 🟣 delegated (product repo). | <!-- oracle-packs:domain.auth -->
149
149
  | write published docs / OpenAPI prose | `write_documentation` | `dispatch/write_documentation.md` → `docs/DOCUMENTATION_RULES.md` before narrower guides; page, fragment, API, changelog scopes differ · 🟣 delegated to repository pre-commit checks. | <!-- oracle-packs:domain.documentation -->
150
150
  | write a changelog entry | `write_changelog` | `dispatch/write_changelog.md` — changelog voice (one headline, no marketing words, `**Bold lead-noun**` bullets, history append-only); reads `docs/CHANGELOG_VOICE.md` · 🤔 judgment-only. | <!-- oracle-packs:domain.changelog -->
151
- | write/update a PR or MR body or its session notes | `write_pr_description` | `dispatch/write_pr_description.md` — append numbered `Session notes`, `Review`, and `Make`; measured fenced diagrams; review dispositions; commands with real output. | <!-- oracle-packs:universal.authoring -->
151
+ | write/update a PR or MR body or its session notes | `write_pr_description` | `dispatch/write_pr_description.md` — append numbered `Session notes`, `Review`, and `Make`; measured fenced diagrams; visual evidence from the lane's own artifacts; review dispositions; commands with real output. | <!-- oracle-packs:universal.authoring -->
152
152
  | claim "tests pass / ready / shipping" | `verify` | `dispatch/verify.md` — verification tiers (`make` canonical; package-scoped runners are **not** verification), done-message glyph format · 🤔 judgment-only, `VERIFY GATE: <target> skipped per environment constraint (reason: ...)` only when genuinely unrunnable. | <!-- oracle-packs:product.agentsfleet -->
153
153
  | name a stream/channel/Redis namespace/queue/RPC/Postgres schema, or describe a flow | `name_architecture` | `dispatch/name_architecture.md` — architecture-consult discipline; grep relevant `docs/architecture/` (chat brainstorming counts) · **no override** — doc wins until reconciled. | <!-- oracle-packs:universal.authoring -->
154
154
  | run a lifecycle stage (open→close a stream, worktree setup, milestone bootstrap) | `lifecycle` | `dispatch/lifecycle.md` — stage runbooks: CHORE(open/close) checklists, PLAN expansions, spec discipline, deferral format, pre-PR gates, LAND · 🤔 judgment-only. |
@@ -84,6 +84,37 @@ their numbers:
84
84
  0.003-0.005ms 0.1ms 1.0ms 12-21ms
85
85
  ```
86
86
 
87
+ ## Visual evidence
88
+
89
+ A change to a surface a person looks at, or a failure a picture explains faster
90
+ than a paragraph, carries the picture. A reviewer who can see the wall, the
91
+ dialog or the red run decides in seconds what a description makes them
92
+ reconstruct.
93
+
94
+ **Take it from what already ran, not from a staged pose.** A lane that failed
95
+ has already produced the image: `gh run download <id> -n <artifact>` yields
96
+ Playwright's `test-failed-*.png`, its `trace.zip` and its `error-context.md`.
97
+ Those are measured evidence of the state under review. A browser driven by hand
98
+ is the fallback, for a surface no lane photographs.
99
+
100
+ **Screenshot the claim, not the neighbourhood.** One image per assertion, and
101
+ the assertion named in the caption — "one tile, not two" beats "the fleets
102
+ page". Never crop a failure out of a frame that contained it.
103
+
104
+ **Attaching is the author's step, and say so rather than pretending.** No forge
105
+ accepts an image upload to a pull-request body over its command-line interface;
106
+ `gh` and `glab` both have no such call. So the agent hands the file to the
107
+ person and the person drags it in. What the agent MUST do in the body is make
108
+ the evidence retrievable without the image: name the run id, the artifact and
109
+ the path inside it, and quote the decisive line. A body that says
110
+ `error-context.md:27 records unexpected value "hidden"` survives a screenshot
111
+ nobody attached; a body that says "see screenshot" does not.
112
+
113
+ **A before/after belongs in a fence, not in two images**, when the difference is
114
+ countable — the diagram rules above already cover it, and a diagram diffs
115
+ better than a pair of pictures. Use images for what only rendering shows:
116
+ alignment, spacing, contrast, what a person actually sees.
117
+
87
118
  ## Review
88
119
 
89
120
  Lead with the review result. Record every finding with P0, P1, or P2 severity,
@@ -142,6 +173,11 @@ a green pipeline.
142
173
  <Useful measured diagram, at most 78 columns.>
143
174
  ```
144
175
 
176
+ <Visual evidence, when a surface changed or a picture explains a failure:
177
+ each image named with the assertion it carries and with the run, artifact and
178
+ path it came from, plus the decisive line quoted so the claim survives an
179
+ image nobody attached.>
180
+
145
181
  ## Review
146
182
 
147
183
  <P0/P1/P2 entries with dispositions, No findings., or not-run reason.>
@@ -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 `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.
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 YAML** edited under `public/openapi/paths/<tag>.yaml`; tag is 1:1 with resource; file ≤400 lines (§6)
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** matches `inner*(hx: Hx, req: *httpz.Request, ...)` (§8)
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 `hx.fail` + idempotency double-PATCH + `Idempotency-Key` replay (where applicable) + ETag mismatch (§10)
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/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>").
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 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).
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 — use `hx.ok`
253
+ ### Success — return the wire type
258
254
 
259
- ```zig
260
- hx.ok(.ok, .{ .agent_id = id, .status = "active" });
261
- hx.ok(.created, .{ .agent_id = id, .key = raw_key });
262
- hx.ok(.accepted, .{ .status = "accepted", .event_id = event_id });
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 `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).
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 (`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`:
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
- ```zig
330
- hx.fail(ec.ERR_INVALID_REQUEST, "workspace_id must be a valid UUIDv7");
331
- hx.fail(ec.ERR_FORBIDDEN, "Workspace access denied");
332
- hx.fail(ec.ERR_AGENT_NOT_FOUND, ec.MSG_AGENT_NOT_FOUND);
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
- NOT `common.errorResponse(hx.res, "...", "...", req_id)`. NOT `hx.res.status = 400; hx.res.body = "{...}"`.
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`, `unreachable`, `error.`, `.zig`, `src/`, `*.zig` line numbers, file paths starting with `/`, allocator names, struct names from non-public modules.
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 `hx.fail`, find the closest existing call site in `src/agentsfleetd/http/handlers/**` and copy its shape. Don't freelance.
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
- For DB / pool / operation failures, use the helpers directly:
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
- ```zig
380
- common.internalDbUnavailable(hx.res, hx.req_id); // pool.acquire failed
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
- These are NOT wrapped on `Hx`.
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 source of truth lives under `public/openapi/`:
413
+ **The document is generated. There is nothing to hand-edit.**
413
414
 
414
- ```
415
- public/openapi/
416
- ├── root.yaml # info, servers, tags, security, paths map with $refs
417
- ├── paths/<tag>.yaml # one file per tag, hard cap 400 lines — split by sub-resource when exceeded
418
- └── components/
419
- ├── schemas.yaml
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. Edit the relevant YAML under `public/openapi/paths/<tag>.yaml`.
427
- 2. Add / rename / remove the corresponding `match()` arm in `src/agentsfleetd/http/router.zig`.
428
- 3. Commit YAML + bundled JSON + `router.zig` together. Splitting these across commits leaves CI red.
429
-
430
- **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.
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
- **Agent-edit recipe:** see `public/openapi/AGENTS.md` for copy-paste-ready rename / append / remove / update-description workflows.
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
- Six places, in order. Steps 1–5 fail loudly at build/runtime; step 6 is reviewer-enforced — see §6 for the parity rule.
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
- | Skipped step | Failure mode |
439
+ | Test | What it refuses |
441
440
  |---|---|
442
- | 1 (`Route` variant) | Compile error in `route_table.zig::specFor()` — the `.my_endpoint` arm references an undefined enum variant. |
443
- | 2 (`match()` arm) | Runtime — your URL returns 404 even though the rest is wired; no compile error. |
444
- | 3 (`route_table.zig::specFor()`) | Compile error — exhaustive switch on `Route` union is missing your arm. |
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
- 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.
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
- 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.
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
- ```zig
456
- .my_endpoint => .{ .middlewares = registry.bearer(), .invoke = invoke.invokeMyEndpoint },
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
- 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):
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
- ```zig
462
- .my_endpoint => &MY_RESOURCE_WRITE,
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
- 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):
489
+ 4. **`public/openapi.json`** — add `#[cfg_attr(feature = "openapi", utoipa::path(…))]`
490
+ beside the handler and regenerate (§6).
466
491
 
467
- ```zig
468
- pub fn invokeMyEndpoint(hx: *Hx, req: *httpz.Request, route: router.Route) void {
469
- if (req.method != .POST) { common.respondMethodNotAllowed(hx.res); return; }
470
- my_handler.innerMyEndpoint(hx.*, req, route.my_endpoint);
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
- **Banned in new matchers:**
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
- 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.
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
- ### Raw-handler exceptions
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
- 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:
513
+ ### The five facts a route row carries
545
514
 
546
- | File | Reason | Comment |
547
- |------|--------|---------|
548
- | `src/agentsfleetd/http/handlers/health.zig` (`healthz`/`readyz`/`metrics`) | Unauthenticated by design — health/metrics | `// unauthenticated — health` (or equivalent) |
549
- | `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 |
550
- | `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` |
551
- | `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` |
552
- | `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` |
553
- | `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` |
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
- 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.
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
- | Simple list/get | `src/agentsfleetd/http/handlers/fleets/list.zig::innerListFleets` |
564
- | POST with body + validation | `src/agentsfleetd/http/handlers/fleets/create.zig::innerCreateFleet` |
565
- | DELETE with idempotency | `src/agentsfleetd/http/handlers/fleets/delete.zig::innerDeleteFleet` |
566
- | Admin-only | `src/agentsfleetd/http/handlers/admin/platform_keys.zig::innerGetAdminPlatformKeys` |
567
- | Multi-method on one path (GET/POST/PATCH/DELETE) | `src/agentsfleetd/http/handlers/fleets/secrets.zig` |
568
- | Workspace auth + tenant context | `src/agentsfleetd/http/handlers/workspaces/events.zig::innerListWorkspaceEvents` |
569
- | Webhook with inline auth | `src/agentsfleetd/http/handlers/webhooks/fleet.zig::innerReceiveWebhook` |
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
- Every HTTP handler in `agentsfleetd` follows this shape. Enforced by the repository's declared `verify.lint` command 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.
577
-
578
- ```zig
579
- pub fn innerMyEndpoint(hx: Hx, req: *httpz.Request, ...path_params) void {
580
- // 1. validate inputs
581
- // 2. db / state work (acquire via hx.ctx.pool.acquire(), release via hx.ctx.pool.release())
582
- // 3. respond via hx.ok(...) or hx.fail(...)
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
- - **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`.
589
- - **First parameter: `hx: Hx`** — never `ctx: *Context`, `res: *Response`, `req_id: []const u8`, or an arena allocator. All of those live inside `hx`.
590
- - **Second parameter: `req: *httpz.Request`** — only if you actually read it (body, query, headers). Drop it if you only need path params.
591
- - **Path params come after `req`** — order matches the `Route` variant in `routes.zig` (§7 step 1).
592
- - **Return `void`.** Errors are written to the response; never return a Zig error.
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
- - ❌ `handleMyEndpoint(ctx, req, res)` — old signature; don't add new ones.
597
- - ❌ Building an `ArenaAllocator` inside the handler — `hx.alloc` is already request-scoped (the dispatcher owns its lifetime).
598
- - ❌ `common.errorResponse(hx.res, ...)` — use `hx.fail(...)`.
599
- - ❌ `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.
600
- - ❌ Returning a Zig error from the handler — write to `hx.res` and return void.
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 `hx.fail` call in the handler
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)
@@ -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/73 | mechanical |
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/287** | |
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",
3
+ "version": "0.10.0",
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",
package/registry.json CHANGED
@@ -296,10 +296,6 @@
296
296
  "product.agentsfleet": {
297
297
  "extensions": [],
298
298
  "managed_files": [
299
- {
300
- "source": "audits/cross-tier-rates.sh",
301
- "target": "audits/cross-tier-rates.sh"
302
- },
303
299
  {
304
300
  "source": "audits/design-tokens.sh",
305
301
  "target": "audits/design-tokens.sh"
@@ -1,105 +0,0 @@
1
- #!/usr/bin/env bash
2
- # cross-tier-rates.sh — pin numeric parity of the rate constants
3
- # across the daemon + three TypeScript surfaces.
4
- #
5
- # RULE UFS extension. The shipped billing surface depends on these constants
6
- # having identical numeric values everywhere they appear:
7
- #
8
- # RUN_NANOS_PER_SEC — per-second run rate (both postures) in nanos
9
- #
10
- # Two free-trial constants were pinned here and are both gone. FREE_TRIAL_END_MS
11
- # went when the boundary moved onto the tenant row; FREE_TRIAL_STAGE_NANOS went
12
- # when the trial mechanism was removed outright, because a rate that could
13
- # silently price a metered slice to zero was worth deleting rather than
14
- # guarding. The free allowance is wallet credit now — a balance, not a rate —
15
- # so there is no cross-tier number to pin for it.
16
- #
17
- # Files (one definition site per constant per file):
18
- #
19
- # rustd/crates/afd_billing/src/nanos.rs — server enforces the charge
20
- # ui/packages/website/src/lib/rates.ts — pricing page display
21
- # ui/packages/app/lib/types.ts — dashboard display
22
- # cli/src/constants/billing.ts — `agentsfleet doctor --json` billing block
23
- #
24
- # A drift between any two = a billing-display lie or a server-vs-CLI disagreement.
25
- # The daemon is the source of truth (server enforces); the three TS surfaces echo it.
26
- #
27
- # Fires in: make harness-verify, make harness-verify-all.
28
- # Exits 0 clean, 1 on any drift.
29
-
30
- set -euo pipefail
31
-
32
- ROOT="$(git rev-parse --show-toplevel)"
33
- cd "$ROOT"
34
-
35
- readonly NAMES=(
36
- RUN_NANOS_PER_SEC
37
- )
38
-
39
- readonly FILES=(
40
- rustd/crates/afd_billing/src/nanos.rs
41
- ui/packages/website/src/lib/rates.ts
42
- ui/packages/app/lib/types.ts
43
- cli/src/constants/billing.ts
44
- )
45
-
46
- # Extract the numeric value bound to `name` in `file`. Matches definition
47
- # sites only — `^(pub |export )?const NAME` — not usages elsewhere in the
48
- # file. Normalises: strips underscores (Zig `1_000_000`, TS `1_000_000`),
49
- # strips the bigint `n` suffix (TS `1_000_000n`), trims whitespace.
50
- extract_value() {
51
- local file="$1" name="$2"
52
- grep -E "^[[:space:]]*(pub |export )?const ${name}\\b" "$file" 2>/dev/null \
53
- | head -1 \
54
- | sed -E "s|.*=[[:space:]]*||; s|[;,].*||; s|//.*||; s|[[:space:]_n]||g"
55
- }
56
-
57
- FAIL=0
58
- violations=()
59
-
60
- printf "audit-cross-tier-rates: pinning %d constants across %d files\n" \
61
- "${#NAMES[@]}" "${#FILES[@]}"
62
-
63
- for name in "${NAMES[@]}"; do
64
- expected=""
65
- expected_file=""
66
- for file in "${FILES[@]}"; do
67
- if [[ ! -f "$file" ]]; then
68
- violations+=("MISSING_FILE: $file")
69
- FAIL=1
70
- continue
71
- fi
72
- val="$(extract_value "$file" "$name")"
73
- if [[ -z "$val" ]]; then
74
- violations+=("MISSING_CONST: $name not defined in $file")
75
- FAIL=1
76
- continue
77
- fi
78
- if [[ -z "$expected" ]]; then
79
- expected="$val"
80
- expected_file="$file"
81
- continue
82
- fi
83
- if [[ "$val" != "$expected" ]]; then
84
- violations+=("DRIFT: $name = $val in $file vs $expected in $expected_file")
85
- FAIL=1
86
- fi
87
- done
88
- if [[ -n "$expected" ]]; then
89
- printf " OK %-26s = %s\n" "$name" "$expected"
90
- fi
91
- done
92
-
93
- if [[ $FAIL -ne 0 ]]; then
94
- printf "\nFAIL: cross-tier rate drift detected:\n" >&2
95
- for v in "${violations[@]}"; do
96
- printf " %s\n" "$v" >&2
97
- done
98
- printf "\nFix: align all four files on the canonical Zig value\n" >&2
99
- printf " (rustd/crates/afd_billing/src/nanos.rs is the server-side enforcer).\n" >&2
100
- exit 1
101
- fi
102
-
103
- printf "OK: all %d rate constants pin across %d files\n" \
104
- "${#NAMES[@]}" "${#FILES[@]}"
105
- exit 0