feat(rustd): afd_fleet — the runner row, enrolment, and the verdict that gates work
The first slice of M177 §1. `afd_fleet` exists, enrols a runner, reads its own
row back, and resolves the degraded verdict — with the crate seam placed where
the Zig has none.
`service.zig` and its siblings are handlers: they hold a request context, reach
into a pool, write a response, and log on the way past. Nothing separates
"decide" from "answer over HTTP", which is how `assign.select` can swallow a
Postgres failure and return the same `null` an idle fleet returns. Here nothing
in `afd_fleet` names axum, a status code, or a response body; every operation
answers a value or `afd_fleet::Error`, and the handlers decide what that becomes
on the wire. Behaviour is unchanged — a transient failure still answers no-work
with a backoff hint — but it is decided once, where it can be read.
Crates instead of hand-rolls, per Indy's standing direction. Each of these was a
hand-written body in the Zig and is a call here:
- `uuid` v7 for identifier minting. `id_format.zig` hand-writes the 48-bit
big-endian timestamp, both nibble masks and the hex render;
`uuid::Builder::from_unix_timestamp_millis` takes exactly the same inputs.
Already in the lock via moka, and not on `afd_core`'s FORBIDDEN list, so
this is a feature flag on a compiled crate rather than a new dependency.
- `hex` for the token body, where the Zig walks bytes by hand.
- `zeroize` for the minted credential, matching `afd_auth::Presented`.
Three behaviours stayed hand-written because the crate cannot express them, and
each is named where it lives: `uuid` MASKS a timestamp past the 48-bit field
where `id_format.zig` refuses, so the bound is still checked (a truncated
instant mints an identifier that sorts wrongly forever, and nothing downstream
could detect it); a pre-epoch instant would wrap through `u64`; and
`Uuid::parse_str` normalises case where this product REJECTS uppercase, so a
minted value is rendered and handed to `Uuid7::parse` — one definition of
canonical, and no way for the encoder to emit what the parser would refuse.
`id_format.zig`'s nine `generate*Id` functions do not survive. Every body is
`return allocUuidV7(alloc)`; they differ in their names and nothing else, and
each returns `[]const u8`, so any one is accepted wherever another is expected
and the compiler checks none of it. Where an identifier's entity genuinely needs
checking, the check belongs on the struct carrying it — which is what
`sql::runner::RegisterRow` is.
That struct answers a real defect surface the port would otherwise inherit.
`INSERT_RUNNER_WITH_EVENT` takes SEVENTEEN positional parameters, `$8` is
referenced four times, and the VALUES list mentions `$13` after `$16`. The Zig
call site passes a flat seventeen-element tuple; it is correct, and confirming
that means counting arguments against the statement text. `host_id` and
`token_hash` are both text and one is a CREDENTIAL — transpose them and it
compiles. sqlx's `.bind()` is positionally identical and this workspace disables
sqlx's `macros` feature, so there is no compile-time query check to fall back on.
The `$n` order is now written once, beside the text it orders. Statements at four
binds or fewer bind at the call site; three problems do not justify forty
solutions (`M-SIMPLE-ABSTRACTIONS`).
Two Zig structs became enums, both because the struct could spell states that
must not exist:
- `Verdict { degraded: bool, reason: ?[]const u8 }` can be degraded with no
reason, or healthy WITH one. `Verdict::Healthy` has nowhere to put a reason
and `Degraded` cannot omit one.
- `TierNeeds` is four bools, which spells sixteen states; `tierNeeds` returns
all-false or all-true and nothing else, so fourteen exist only to be
impossible. Clippy notices the shape; the fix is to say what is true — a
tier either builds a cage or does not.
`afd_auth` gains `Digest::of_minted`, and `Digest::of` becomes its wrapper. A
digest taken over anything but the whole presented value authenticates nothing,
and that is a mistake with no failing test — only a fleet that cannot log in.
Both paths now hash through one function. It also removes an unreachable
blank-check branch at every future mint site, which would be dead code this
workspace measures.
SQL lives in `sql/` as a module tree, split by domain. `fleet/sql.zig` hit its
length cap and was carved into two siblings re-exported back through it, purely
so RULE SQLMOD's "one grepable module" survived — a workaround for a gate, not a
design. The reason to collect at all is Invariant 5: the ONLY enforcement of
verbatim-SQL parity is REVIEW reading these side by side against the Zig, and
that read cannot be done if the statements are scattered through handler bodies.
`~/Projects/oss/core_api-develop` inlines its SQL in `models/<entity>.rs` with no
`sql.rs` anywhere, but its statements are one-line stored-procedure calls whose
logic lives in Postgres functions — a shape unavailable here, where SCHEMA GUARD
is "no". What DOES transfer is its row/`into.rs` split, which is why `record.rs`
answers columns rather than a wire payload.
Files Changed gains `afd_core` and `afd_wire`: the encoder belongs beside the
parser that defines canonical, and `afd_wire` needed registering in
`workspace.dependencies` (no wire shape is touched — M175 owns those).
Verified: `cargo clippy --workspace --all-features` clean under the full deny
set; `cargo test -p afd_core -p afd_auth -p afd_fleet` green (15 in the id suite,
including the truncation-refusal test that pins the uuid-masks / we-refuse
difference); `make harness-verify` ALL GATES GREEN. UFS fired once on a bare
numeric in the new test and was fixed by naming it, not by annotating it away.
Longest new source file is 283 lines.
Handlers, the runnerBearer layer and heartbeat are the rest of §1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> K
Kishore Kumar committed
5f7beed8ba24848c4645ddd12dad5ad64faedd9d
Parent: 87ee0c8