feat(rustd): the runner plane answers — guard, heartbeat, and the lease claim
M177 §1 complete and §2's assignment half. A runner token now proves itself
against `agentsfleetd-rs`, enrols, reads its own row, beats, and takes a fenced
claim on a fleet's next event. Five of the eighty-one tabled routes are mounted;
the rest still answer 404, which is the truth.
## The plane boundary stops being a wiring convention
`docs/AUTH.md` states it plainly — a runner token must never satisfy a tenant
route, and a tenant token must never satisfy a runner route. The Zig daemon
enforces that by WHICH middleware is mounted where: `runnerBearer` on
`/v1/runners/me/*`, `bearer_or_api_key` everywhere else. Mount one a route too
wide and the boundary is gone with no test failing, because nothing anywhere
states the pairing.
Here it is `auth::plane_of` — a total match from `Guard` to `afd_auth::Plane`,
read by the router while it mounts. A new guard fails the BUILD until it says
which plane it proves against, and a route cannot be mounted under a guard it
does not declare in its own `RouteMeta`, because the router reads both from the
same table row. `Guard::Open`, the two webhook guards and `Svix` answer `None`:
a payload-authenticated delivery carries its proof in the body, which is an
absence with a reason rather than a gap.
`RunnerIdentity` is the other half. Every runner verb needs the same two facts —
that a principal was proven, and that it is a RUNNER — and `Extension<Principal>`
would leave the second to a `match` in each of ten handler bodies, each free to
answer a different code. The extractor is that narrowing once, so a handler that
names it cannot run for a tenant caller and the arm where it would have has
nowhere left to live. Its unreachable branch answers 500 rather than 401,
deliberately: the daemon never looked at the credential, and calling that an
authentication error is a wiring bug wearing an auth failure's clothes.
Three facts, three layers, decided at mount rather than per request.
`route_table.zig` re-runs that switch inside `dispatch` on every request for an
answer that is a constant in the table. An unmetered route now has no admission
layer in its stack to consult and an unguarded one no authenticator to reach.
The order is the Zig daemon's and is load-bearing: admission outermost, because
a shed has to stay cheaper than the work it refuses and proving a credential is
a datastore round trip.
## OIDC is required at boot, and `Identity::Absent` is gone
`runtime_validate.zig` refuses to boot without `OIDC_ISSUER` and
`OIDC_AUDIENCE`; M176 stated the same gate and did not have it. Preflight now
reads four required knobs — issuer, audience, `CLERK_API_BASE`,
`CLERK_SECRET_KEY` — with `OIDC_JWKS_URL` optional and derived from the issuer
when unset. Verified against the real binary in both directions.
The runner plane consults neither identity seam: a runner token resolves through
Postgres and derives capabilities from its credential class. That is why an
unbuildable provider degrades the tenant surface alone — and it is NOT a licence
to boot without one. An unconfigured provider is an OUTAGE (`UZ-AUTH-004`, 503),
never an empty capability set, because an empty set authenticates a caller and
then refuses them at every gate exactly as a real demotion would.
**This is operator-visible.** A deployment that upgrades without the four knobs
gets a daemon that refuses to start. It is the alternative that is worse: a
daemon answering tenant requests differently from the one it replaces is a
cutover divergence discovered in production.
## §2 — the claim, the fence, and what happens when a claim is orphaned
`Leases` owns BOTH datastores, and that is the design rather than an
accommodation. A lease is the one verb that cannot be served from either alone;
splitting them would let a caller take a claim without being able to read the
event it claimed for. `pool()` and `queue()` are `pub(crate)`, so nothing
outside the crate can run a statement that is not in `sql/` — the property that
makes Invariant 5's side-by-side parity read meaningful, since REVIEW is its
only enforcement.
`select()` returns `Result` where the Zig swallows. `assign.select` catches
every error and answers null, so a Redis outage reads as "no work" and a fleet
goes quiet with nothing logged. Porting that literally violates FN-RS. The store
propagates; the handler will convert a fault to the same observable
200-no-work, visibly, where it can be read.
`Acquired` parses the producer's contract ONCE at the boundary — `workspace_id`
as a `Uuid7`, `event_created_at` as a `UnixMillis` — so no downstream caller
re-parses and none can disagree about what the column meant.
`afd_core::timing` single-sources the lease clock from `constants.zig`, in
milliseconds rather than `Duration`, because every one of these is compared
against a bigint column of epoch millis and a conversion is where a unit gets
lost. The relationships between them are asserted as `const` rather than left to
a comment, which is what the Zig's `comptime` block is for.
`runner::spelling` and `runner::policy` are separate files because they fail
differently. A wrong decode voids an assignment and the host refuses to lease,
loudly. A wrong spelling writes a row every future decode reads as garbage,
silently, until a fleet stops leasing — so every variant `spelling` writes is
parsed back through `policy::parse_wire` in a round-trip test, which is the only
reason `parse_wire` is visible outside its module.
`runner::bounds` refuses lenient, both ways. A malformed capability or selftest
report reads as "nothing reported this beat" and the beat still succeeds: a
runner token must not be able to fail its own liveness by sending nonsense, and
a host that cannot beat is a host the fleet reads as dead. The Zig's
three-variant `Rejection` becomes `Result<(), _>`, because its `none` arm is the
success path wearing an error's clothes and a caller that forgets it stores
nothing.
`Services::now()` is read ONCE per verb and threaded through it, so every row
one request writes carries the same instant. `heartbeat.zig` calls
`clock.nowMillis()` separately in each of its four writes, which leaves a beat's
liveness stamp a millisecond or two after its own transition event.
`error.rs` splits into `error/{mod,detail}` — what went wrong, and what the
caller is told. They are different audiences and were one type.
## The money half is NOT here, and one divergence is now on the record
§2's gates and debits need the fleet config, which §5 owns, so they are not in
this commit. What IS here is the decision, written down before the code exists:
`data_flow.md` §C now carries the budget gate, first-delivery-only receive debit,
and the fail-open posture of both money gates — and states outright that the
issue-time run debit is a **declared daemon divergence during cutover**.
`fleet/service_billing.zig` ends its gate pass with *"No issue-time stage debit:
run fee + tokens meter on /renew + settle at report"*, and `metering.zig`
exports `debitReceive` as its only debit. The spec and `data_flow.md` both say
two debits. Decision (Indy): implement the DOCUMENTED behaviour in Rust, leave
the Zig alone, and register the divergence rather than let two daemons quietly
charge differently. The run estimate is an ESTIMATE reconciled by §3's settle —
never a second charge. M181 §4 carries it into the cutover register, because
anything reconciling ledger rows across the two daemons has to know which one
wrote them.
Two further decisions are recorded rather than left to be re-litigated: OTLP
export is deferred to M181 as its new §5 (the Rust daemon exports no telemetry
at all today, which blocks M181's own Dimension 4.3, not anything here); and §5
lands before §2's remainder, against the spec's batch table, because
`FleetSession::claimFleet` resolves config and the table was written without
that.
## Verified
`make test-integration-rustd` — 74 passed, 0 failed, exit 0, against live
Postgres and Redis; the baseline was 57, and §1 and §2's suites are the delta.
`make test-unit-all` — all unit lanes green, cargo workspace plus every
TypeScript coverage gate. `cargo clippy --workspace --all-features --all-targets`
clean under the full deny set; `cargo fmt --all -- --check` clean.
`make harness-verify` ALL GATES GREEN. `gitleaks detect` no leaks.
Redis is not per-test — the readiness index is one hash at a fixed key and
streams are keyed by fleet — so every lease test derives its fleet ids from
`process::id()` plus a counter. A constant id inherits the previous run's stream
entries and passes for the wrong reason. `Fixtures::create()` is Postgres-only
for the same class of reason: connecting a queue per test stormed TLS handshakes
and timed out four §1 tests, so `create_with_queue()` is used only where the
queue is actually read.
Longest new source file is 326 lines.
§5's config parser is next; §2's gates, debits and `ExecutionPolicy` follow it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> K
Kishore Kumar committed
feec3292e60531f38fb0871ec99c85489bf338fb
Parent: 6469d2b