Files
Hanzo Dev 59ed9a2992 dataset: every source scan is admitted, every outcome is recorded, one mint
Red held this plane on three ship-blockers, all of which were the same shape:
a property the package claimed and did not have.

EVERY READ OF THE SOURCE IS NOW ADMITTED. `lineage` re-ran the exact census a
materialisation is charged 10c for — an exact distinct-count over up to 400 days
of one tenant's feature surface — with no gate, no meter and no bound of any
kind, on a GET any authenticated caller could loop in parallel against the one
stateful store every product on api.hanzo.ai shares. It is not fixed by adding
three checks to that op: `census` and `facts` now take a `scan`, a value with no
exported field that exists only where `admit` returned one, so an unpriced
unbounded warehouse read is not a thing this package can spell. admit is the one
door — it prices at the meter, takes the tenant's single slot and one of the
plane's eight, and lineage additionally runs under the plane's own deadline
rather than the caller's patience. A test reads the package's own AST and fails
if any function that reads the source takes no admission.

A JOB CAN RECORD THAT IT RAN OUT OF TIME. The write that ended a job shared the
context of the work it was reporting on, so the one case a refusal exists for —
the fifteen-minute wall expiring — could never be written: the driver refused
before sending anything and the version sat in `materializing` with an EMPTY
refusal forever. The work's context and the record's are now two, and `record`
opens its own, so they cannot be fused again.

ONE MINT. hanzo.risk_feature.org is written by the rollup's `qualify`, which
lower-cases the brand and requires a registered one, and was read here by a mint
that only trimmed and accepted anything. CLOUD_BRAND=Hanzo therefore had the
writer filing rows under `hanzo/acme` while this plane asked for `Hanzo/acme`
and got nothing, forever, with no error anywhere. The mint canonicalises and
requires a brand the registry vouches for; a test pins it byte-for-byte against
the writer's algorithm over every brand and spelling, and Mount refuses at boot
rather than 403-ing every request.

Also closed, from the same review:

  the biller's own words     A gate that cannot be ASKED fails with the peer's
                             transport detail, which zip renders as the body of a
                             500. Both priced ops now refuse through cloud.Denied
                             — the fleet's one 402/503 contract — and the reason
                             goes to the log.
  version numbers            Disposal dropped the register, so the counter reset:
                             a second `orders` reached v3 again with different
                             bytes and nothing recorded that the first v3 existed.
                             Disposal now drops the BYTES and marks the register,
                             `disposed` outranking `ready` in the engine's own
                             version column, so numbering is monotone across a
                             disposal and "prove you deleted it" is answered by a
                             record instead of by silence.
  a falsifiable claim        lineage certified a window re-derivable whenever the
                             source had not SHRUNK — but the source is fed by a
                             rollup running behind the events, so holding MORE is
                             the ordinary case and re-running the spec would not
                             reproduce the digest. Reproducible now means exact
                             agreement on all four recorded measurements.
  one brand's org            cloud trusts every white-label brand's issuer, so a
                             lux.id token reached a hanzo deployment and minted
                             `hanzo/<org>` for it. The identity boundary now
                             stamps the brand it VERIFIED from `iss`, and the
                             mint refuses when the two facts disagree.
  what is per process        The package claimed two processes over one store
                             answer identically. Reads, declarations and disposals
                             do; ADMISSION does not, and that is now stated with
                             the deployment contract it implies rather than
                             claimed.

And one the review missed: the branch already failed the fleet's own
TestRequestEscapeHatchIsPinned, because `who` reads the request for the BILLING
identity — the ledger, the validated project, the attribution — which is
deliberately a different value from the tenant. The reason is now on the record
in allowedRequestUses, where the next reader will find it.

Every fix carries a regression test, and each was mutation-tested: with the fix
reverted, its test fails.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 18:15:38 -07:00

136 lines
6.7 KiB
Go

package cloud
// typed.go is the seam between a REQUEST and a TYPED op. ZipApp (scope.go) gets a
// subsystem to the registry; this gets each request across it.
//
// zip.Get[In,Out] is the ONE registration every projection reads — REST,
// OpenAPI, MCP and the CLI all derive from that single registry entry, which is
// exactly why a route that is NOT a typed op is invisible to all four. But a
// typed handler receives only a context.Context and its decoded In, so three
// facts a cloud handler needs are not in its hands:
//
// - the VALIDATED org, and beside it the bare fact that the caller was
// VALIDATED AT ALL — the two facts a gate turns on, since a plane with no
// org-scoped rows (engine's shared runtime) authenticates without a tenant.
// Both live in headers the typed handler cannot see, and neither may EVER
// become an In field: an In field is caller-supplied, so a tenant key read
// from one is a cross-tenant read the caller asserted for itself.
// - the REQUEST itself, for a subsystem that FORWARDS the caller's identity
// instead of only reading it. A tenant-scoped proxy (clients/visor) passes
// the caller's own identity headers — and, where no service credential is
// configured, their bearer — to the upstream that owns the resource, so an
// op that cannot reach the request drops the caller's identity on the far
// side of the hop. Reaching it is not an escape from "typed": the request is
// the same value the raw handler beside it holds, and an op that wants only
// its tenant asks principal.OrgFrom and never sees a header.
// - the response status. zip writes 200 (204 for a nil Out) and has no
// vocabulary for 201 Created or 202 Accepted, so a route converted without
// one silently downgrades its status — a wire break for every client that
// checks it.
//
// All three are the same shape of problem — a per-request VALUE the typed
// signature drops — so they share ONE middleware, installed where it precedes
// the typed routes it serves: fiber runs middleware in registration order, so
// one installed after its leaves never runs. Serve installs it once for the
// whole binary, right after the identity boundary that makes the org
// trustworthy; a subsystem whose routes all sit under a prefix it owns may
// install it on that group instead. Nesting is harmless — the inner one is the
// one the handler sees, and the outer finds nothing to apply.
//
// FAIL CLOSED OFF THE HTTP PATH. The CLI projection's LocalInvoke runs an op
// with no request at all, so Request, principal.OrgFrom and principal.ValidatedFrom
// all read nothing there and a gated op refuses — the handler's own 403 gate, with
// no second gate to keep in sync. An MCP tools/call at POST /mcp is an ordinary
// HTTP request and does carry them, so it is gated exactly like the REST route
// it mirrors: a validated principal is served, an anonymous one is refused.
import (
"context"
"net/http"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
)
// bound is everything Bridge carries for one request: the request itself, and
// the slot an op writes a non-default status into. ONE value under one key, so
// the middleware costs one allocation however many facts have to cross.
type bound struct {
req *zip.Ctx
status int
}
// boundKey names the request-scoped slot. Unexported zero-size type:
// unforgeable from another package.
type boundKey struct{}
// Bridge carries into a typed op the request facts its signature drops, and
// carries back out the one fact it cannot state. Install it BEFORE the typed
// routes it serves — fiber runs middleware in registration order, so one
// installed after its leaves never runs — and after the identity boundary, so
// the identity it parks is the validated one.
//
// It parks the TWO facts a gate turns on, in ONE expression, so they are always
// set together and can never disagree: the validated ORG (principal.WithOrg, for
// a plane with rows to scope) and VALIDATED-NESS itself (principal.WithValidated,
// for a plane whose reads are deployment-global and whose gate is therefore
// authentication). Both are read back through principal, so a gate that needs
// either does not reach for the request.
func Bridge() zip.Handler {
return func(c *zip.Ctx) error {
b := &bound{req: c}
ctx := principal.WithBrand(principal.WithValidated(principal.WithOrg(c.Context(), c), c), c)
c.SetContext(context.WithValue(ctx, boundKey{}, b))
err := c.Continue()
// Success only: an error already carries its own status. fasthttp writes
// the response after the whole chain returns, so setting it here still
// lands on the wire.
if err == nil && b.status != 0 {
c.Status(b.status)
}
return err
}
}
// Request returns the request a typed op is serving, for the ops that must
// FORWARD the caller's identity rather than merely read it (see the package
// note). Absent off the HTTP path, where the honest answer is that there is no
// request — a caller that needs one refuses rather than inventing an identity.
func Request(ctx context.Context) (*zip.Ctx, bool) {
b, ok := ctx.Value(boundKey{}).(*bound)
if !ok || b.req == nil {
return nil, false
}
return b.req, true
}
// Created marks the response 201 Created and Accepted marks it 202 Accepted.
//
// Deprecated: declare the status on the op instead — `zip.WithStatus(201)` —
// which is the same fact in the one place every projection reads.
//
// These exist because zip once had no vocabulary for a success status other than
// 200 and 204, so the only way to answer 201 was to set it per request from
// inside the handler. That works on the wire and nowhere else: the status is a
// CONTRACT detail, and setting it here writes it into a side channel no
// projection can read. The document keeps saying 200, so does every SDK
// generated from it, and the route has always sent 201. It is the same failure
// as a query parameter's required-ness being invisible — a contract detail that
// exists only at run time is not a contract.
//
// zip v1.18.2 closed the gap: `zip.Post(app, path, fn, zip.WithStatus(201))`
// keys the document's response object on 201, so a generated client expects what
// the service sends. New ops declare it; these two stay so the call sites that
// predate it keep working while they are converted, and they still set the
// status they always did.
//
// Both are a no-op off the HTTP path, where there is no status to set.
func Created(ctx context.Context) { setStatus(ctx, http.StatusCreated) }
func Accepted(ctx context.Context) { setStatus(ctx, http.StatusAccepted) }
func setStatus(ctx context.Context, code int) {
if b, ok := ctx.Value(boundKey{}).(*bound); ok {
b.status = code
}
}