feat(rustd): fleet config resolves — and every Rust emit says what it is
M177 §5's claim-time half, plus the orly 0.7.0 pack that arrived mid-slice and
immediately found sixty-five things wrong with the daemon's logging.
## §5 — the stored document becomes a policy
`FleetSession::claimFleet` resolves a fleet's config on every claim, which is
why §5 was pulled ahead of §2's money half: the budget gate, the approval gate
and `ExecutionPolicy` all read what this crate now produces.
`afd_fleet_runtime` is NOT a transliteration, and the scoping in the handoff was
wrong in a way worth recording: it named `config_parser.zig` (299 lines) plus
`config.zig` (65). `config.zig` is a façade that re-exports six implementation
modules; the real surface is ~1,850 non-test lines across seven files. Most of
it does not survive the port, because most of it is not about fleets.
Three stages, and only the third is written here:
serde shape — presence, types, collections, tagged unions, defaults,
ownership
garde bounds — how long a list may be, how long an entry may be, what form
an entry takes
us meaning — a name is a kebab slug, a ceiling is positive, two webhook
triggers may not share a source
`config_parser.zig` and its helpers interleave all three: `obj.get(key) orelse
return error`, a `switch` per value, a bounds check written out per list, an
`alloc.dupe` per string, and an `errdefer` behind each to unwind a partial
struct. Stages one and two are entirely mechanical and both are things a crate
does from a declaration.
What that deletes, rather than reorganises:
- **The whole `errdefer`/`deinit`/`freeStringSlice`/`freeFleetTrigger`/
`freeGatePolicy` apparatus.** Ownership does it at scope exit.
- **`comptime { assert(@sizeOf(FleetConfig) == 288) }`** — a struct-layout
assertion standing in for "did you remember to update `deinit`". `Drop` makes
the drift it guards against unrepresentable, so the guard has nothing to say.
- **`requireString` and `optionalString`**, which are byte-identical twelve-line
functions whose first name promises an enforcement it does not perform —
every caller still writes `orelse return error`.
garde over `validator` is a deliberate minority pick and the trade is named
rather than hidden: `validator` has roughly seventeen times the downloads and
is actively maintained, but its validation kinds are `length, range, email,
url, regex, contains, must_match, required, ip, cards, non_control_character` —
`length` bounds a COLLECTION and there is no per-element rule. This schema is
mostly `Vec<String>` needing per-ENTRY bounds (`tools`, `credentials`,
`events`, `repositories` twice over, `allow`, `read_post_paths`), so `validator`
would mean a hand-written loop per list — the thing a validation crate is for.
garde's `inner(...)` expresses it declaratively, and its "every field must
carry an attribute, even if that attribute is `skip`" rule makes forgetting a
bound a COMPILE error. The Zig leaves `tools` and `credentials` unbounded and
nothing notices; both are stored and both are re-read by the fleet page.
Three bounds sit outside garde and each is deliberate. The newtypes
(`FleetName`, `CredentialName`, `Version`) are constructors returning `Result`,
because garde validates a value in place and cannot PRODUCE a type that carries
the proof forward — `config_validate.zig` answers `void`, so what it checks
stays a `[]const u8` that every later reader is free to re-check or to forget
to. The trigger set's arity sits beside its uniqueness rule because garde
refuses to combine `dive` with `inner`. And the numeric caps are type
invariants: `Dollars` also refuses a non-finite amount, which a range
annotation cannot express — `config_helpers.zig` bounds a ceiling with `daily
<= 0.0 or daily > MAX`, and BOTH comparisons are false for a NaN.
**Four verdicts differ from the Zig on the same input, declared in `error.rs`
rather than absorbed:**
1. A wrong-typed `name`, `triggers`, `tools`, `network` or `budget` answers
`InvalidFieldType`, where the Zig answers `MissingRequiredField` at seven
sites — telling an author to add a key they can plainly see. The split is
structural, not a discipline: every schema field is an `Option`, so serde is
never asked for a required field and cannot raise "missing field", which
leaves a deserialize failure only ever a shape failure.
2. A non-string `skill` is a shape error. The Zig returns `null` — it DROPS the
field and reports nothing, so a fleet silently loses its skill reference,
while its sibling `model` answers a shape error for identical input.
3. A gate rule's failure keeps its own class. The Zig maps every error out of
`parseGatePolicy` onto `MissingRequiredField`, so a `threshold_count` of
zero reports as a missing field.
4. An out-of-range anomaly threshold answers `InvalidThreshold`. The Zig bounds
a COUNT OF ACTIONS by `MAX_BUDGET_UNITS`, a constant named for dollars
(RULE UFS), and reports it with the budget's error.
No `UZ-` code is declared here (RULE ERR): the wire code is the existing
`UZ-AGT-008`, and the mapping happens at the HTTP boundary — the split
`afd_fleet::error::detail` already draws between what went wrong and what the
caller is told.
`provider.rs` is the seam, not a client. A `WebhookProvider` answers three
pieces of metadata so an authored signature block can be completed; nothing
here opens a socket or verifies a signature. `webhook_verify.zig` fuses the two
and the cost is that a config parse drags the verification path behind it.
`octocrab` and `slack-morphism` were checked and are real, but belong at the
sites that make the call — and `octocrab` only with `default-features = false`,
because its defaults enable `rustls-ring` and `jwt-rust-crypto` and this
workspace resolves to one crypto provider.
## The logging gate learned Rust, and the daemon had not
`orly 0.7.0` extends `audits/logging.sh` to scan `rustd/**/*.rs`. It found
**sixty-five** production emits with no `event` field — a whole substrate's
worth, because M176 put the event name in the trailing MESSAGE slot instead.
Every one is fixed, and §8A's EVENT-COMPAT rule is why the twenty-six that
already held a snake_case name keep their exact bytes: dashboards match on
them. The rest gain a name chosen once, with the prose kept as §3's optional
`msg`.
Two were real findings rather than mechanical:
- `cli.rs` printed the `agentsfleetd migrate` summary to stdout while every
step that produced it — `migrate_conn_acquired`, `migrate_lock_acquired`,
`migrate_refused_schema_ahead` — already emitted a structured event. The
summary was the one part of that path that could not be queried beside the
events it summarised. The command's contract is its EXIT STATUS, which is
what the lane asserts and which has not changed.
- `enrolment.rs` passed a const named `EVENT` in field-shorthand position,
which names the field `EVENT`. Now `event = EVENT`.
Six `rust-direct` hits remain and are NOT defects in this repository: four are
`build.rs` lines where stdout is cargo's IPC channel and converting them would
silently break the build, and two are `banner.rs` and `fatal.rs`, where writing
to a stream IS the design. `audits/logging.sh` is orly-managed, so the fix
belongs upstream rather than in a local edit that `orly update` erases —
`PROMPT_ORLY_LOGGING_GATE_RUST.md` carries it, with the two generic parts:
the Rust carve-out is missing `build.rs` and `examples/` where the Zig one
enumerates six patterns, and LOGGING lacks the reason-carrying inline hatch
`ufs.sh` already advertises.
`make harness-verify-all` therefore stays red on those six until 0.7.1.
## A gate that grades nothing reports OK
Worth recording, because it was nearly missed. `make harness-verify` runs every
audit in `--staged` scope, and with an empty index the LOGGING row printed
`OK: no source files in scope (--staged)` — while `--all` was failing on
sixty-five violations. The pre-commit lens grades the diff, never the
repository, so debt that has already landed cannot enter its scope. That is the
lens working as designed; the mistake was reading a vacuous OK as a pass.
Verified: `cargo clippy --workspace --all-features --all-targets` clean under
the full deny set; `cargo fmt --all --check` clean; `cargo test -p
afd_fleet_runtime` 37 passed; `make harness-verify` ALL GATES GREEN across 45
staged files, with LOGGING now reporting `clean (blocking layer)` rather than an
empty scope. `make test-unit-all` passed on this branch before the final
`cli.rs` edit and has not been re-run since; the integration lane has not run
against this commit.
Longest new source file is 329 lines.
§2's money half is next, now that the config it needs resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> K
Kishore Kumar committed
42dcbee78f2a282e6a8d2604b54bea8a59b23e5f
Parent: feec329