Compare commits

...
Author SHA1 Message Date
hanzo-dev 91da40a5c3 apps: a seam that wraps nothing is a seam that never runs
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m42s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
zip refuses a program whose middleware has no routes beneath it, and the refusal
was right about three surfaces here. Nothing in this repo called Build() from a
test, so the refusal could only ever surface as a startup panic — or, for a
subsystem no test drove, as silence.

auditlog and catalog declared Bridge (and audit's noStore) on a group at their
own prefix while declaring the op on the App with its WHOLE path. The op is
therefore that group's SIBLING, not its child, so the group's subtree was empty
and the middleware could not run. Both packages' entire test suites were
panicking out of app.Test, which builds. Use is the verb that says the true
thing, and it needs no second copy of the prefix: cloud's scope bounds it to the
subsystem's declared subtrees, and a bare *zip.App treats root middleware as
live. The ops keep their exact paths — moving them onto the group with an empty
leaf would publish /v1/audit/ and /v1/catalog/, which neither API has served.

zen was worse and not the same defect. Its Claim gates ai's "/v1", it declares no
prefix of its own (Coresident), and plugin/zen fed manifest.PrefixesFor("zen") —
a ROUTING answer — into cloud.Plugin.Prefixes, which is the MIDDLEWARE grant. One
field was answering two questions, so dropping "/v1" from zen's row (right, for
routing: it duplicated ai's claim) silently revoked the gate, and MountAll refused
the mount outright. manifest.App.Gates states the second fact where the first
cannot, GrantFor reads it, and TestGrantMatchesPrefixesForRoutedApps keeps it from
becoming a second list.

crm was the same failure wearing an ordering convention. Its intake limiter used
to cover "everything registered after this line" — the public form plus the three
staff routes. Under lexical scoping it narrowed to the one route chained onto it
and the staff routes lost cover with no error anywhere. The covered routes are
composed BENEATH the limiter now, which states the coverage instead of implying
it. TestIntakeRateLimitScope was red before this and is green after.

compose_build_test.go is the gate that was missing: Build() through BOTH routers,
because MountAll hands a scope that can HIDE a seam a bare app refuses. It also
pins the rule itself, so a zip that stopped refusing the shape could not let the
seam rot back in green.

Deps: base v1.5.15 (the sqlite_math guard is a probe now, so `go build ./...`
needs no tags) and zip v1.24.2, plus iam v1.34.19 and o11y v1.5.57 restored —
394ba2e8 downgraded all three and left go.sum missing commerce v1.49.64, so
origin/main did not build at all.

guide's blueprint assertion matched a flat tool-naming zip no longer uses, so it
selected nothing; it matches the subject now, not the scheme.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:00:40 -07:00
hanzo-dev 7d106d962a forward only: undo the downgrades 394ba2e8 pushed, and land the base bump it claimed
394ba2e8 said "base v1.5.15" and did not bump base. What it actually did was
roll THREE dependencies BACKWARD:

  iam       v1.34.19 -> v1.34.18
  zip       v1.24.2  -> v1.24.1
  o11y      v1.5.57  -> v1.5.56
  base      v1.5.11 unchanged   <- the only change it advertised

Cause, so it is not repeated: `go get` was run against a working tree that was
BEHIND origin/main. go get pins the versions it is handed and leaves the rest at
whatever the tree already had, so every dependency a concurrent lane had already
advanced got written back to the older pin. The commit message described the
intent; the diff recorded the accident. Nothing verified the two agreed.

This restores all three and lands the bump that was missed:

  zip v1.24.2, base v1.5.15, iam v1.34.19, o11y v1.5.57, commerce v1.49.64

Each is the newest tag on its remote, read with `git ls-remote` — GOPRIVATE
means proxy.golang.org cannot serve hanzoai/*, so the proxy is not an authority
for these and `@latest` silently answers from a stale public view.

base v1.5.15 is the one that pays for itself immediately: v1.5.11 carried a
guard whose symbol was defined nowhere, so `go build ./...` failed at default
CGO_ENABLED=1 and every build in this repo needed -tags sqlite_math_functions by
hand. Verified after this change: `go build ./...` with NO tags exits 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:00:27 -07:00
hanzo-dev 394ba2e878 base v1.5.15: go build ./... works again with no tags
base v1.5.11 carried a guard referencing cgoBuildNeedsSQLiteMathFunctions, a
symbol defined NOWHERE — its only mechanism was the compile error it produced.
So the plainest command in Go failed in this repo and in every other importer of
base/core, reporting a missing symbol rather than the actual problem, and the
`-tags sqlite_math_functions` workaround had to be passed by hand on every
build. It was also a false negative in the config the driver's own docs call
production, because the tag was a PROXY for a capability rather than a
measurement of it.

v1.5.14 replaced the guard with a probe that asks the ENGINE — it runs the real
expression once against a throwaway in-memory DB and refuses the connection when
the answer is no, so the check reports a measured absence instead of a failure
to measure. v1.5.15 carries that plus zip v1.24.2.

Verified here: `TMPDIR=... go build ./...` with NO tags exits 0.

zip v1.24.2 and commerce v1.49.63 were already on origin/main when this landed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:27:11 -07:00
hanzo-dev 37db0b3e93 Merge remote-tracking branch 'origin/main' into HEAD
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 2m4s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:17:08 -07:00
hanzo-dev 4e77abf3c5 apps: middleware gates by path, not by an empty group node
Fifteen plugins refused to compose and every route they own answered 503,
including /v1/event — the telemetry front door.

zip v1.24 refuses a group that declares middleware with no routes beneath
it, because that middleware would silently never run. Six apps wrote
app.Group("/v1/x").Use(mw) and then registered their routes on the app at
full paths, so the group node was always empty. f28bf6bd fixed exactly
this for scope.Use; these six bypassed it by reaching for Group directly.

app.Use is now the one way: scope.Use already gates a subsystem's
middleware to the prefixes it owns, by path, so the group node is not
needed and cannot go empty. One verb, one rule, no second mechanism.

Verified by running all sixteen affected plugin binaries: 0 compose
panics.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:17:05 -07:00
zeekayandhanzo-dev 3207685339 deps: zip v1.24.2 and the subsystem set that ships with it
zip v1.24.2 stops composition renaming a DECLARED operation id by the prefix it
is included under. That mattered here more than anywhere: this binary is the host
that includes o11y under /v1/o11y, and doing so was renaming 217 of its 353
declared ops — every cached MCP tool name, operationId, CLI command and generated
SDK method moved as a side effect of one wiring line.

With commerce v1.49.63, iam v1.34.19 and o11y v1.5.57, every subsystem this
binary mounts is published on the same framework version. That agreement is the
point: Router is the type a decorator implements, so a host on one version and a
subsystem on another is a decorator that cannot be written.

Measured against a clean tree on this host: 97 failing packages before, 97 after —
zero new. macOS SQLCipher, unrelated and unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:08:07 -07:00
hanzo-dev f340b6cb72 dataroom: the room an agent can open, because a typed op is the only kind it can see
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/dataroom/* has served fourteen routes and reached no agent. An untyped route
appends no op to zip's registry, and the MCP door renders tools FROM that
registry — so the fleet answered tools/list with 570 tools and not one of them
opened a data room. The hole was never the mount (dataroom mounts, and
/v1/dataroom/health has been answering in production); it was that every route
was an untyped relay.

Ten of them are typed ops now — every JSON route on the admin surface, which is
the whole demo surface: open a room, put documents in it, grant a party access,
and list what exists. Each still relays the bundle's own (status, body); what
changed is that it now carries an In and an Out, so the same declaration yields
the tool, the OpenAPI operation, the SDK method and the CLI command.

The package doc claimed NONE of these could be typed, on two premises the shared
kit answers: that a relayed answer is opaque (it is not — the bundle's shapers
are total and schema.go types them, which is what the models are), and that a
typed error path would overwrite the bundle's envelope (it does not — BundleErr
carries the bundle's status and BYTES). captable had already disproved both;
this makes that the second use rather than the second copy, so Scalar, SizedIn,
BundleErr and Envelope move to apps/goja, beside the bundle seam they serve.

ScalarList is the one piece captable did not need. A bundle substitutes an EMPTY
list for anything that is not an array, so an agent told allowList is a `string`
sends one, the room discards it, and the call SUCCEEDS having ignored the access
control — a link meant for one investor admitting everyone, reported as success.
Declaring the array is what puts that failure out of reach.

Four routes stay relays for reasons in the wire, each named at its registration:
the upload takes the file itself as the body, the two /file routes answer with a
byte stream, and the three public viewer routes have no principal to read.

Proven: the demo flow end to end over the typed routes; the reads byte-identical
to the bundle they replace; the cross-tenant link index still written, so a
granted link still opens for an anonymous visitor; org scoping; the room's own
refusal envelope intact; and the ten tools present with descriptions and schemas.

The regenerated captable subset is operationId-only (40 lines, 0 schema changes)
— pre-existing drift between the committed spelling and what zip v1.24.1 derives,
corrected by regenerating from source rather than by hand. The same drift had
left one captable test asserting a tool name nothing produces; it now asserts the
derived one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:01:09 -07:00
hanzo-dev db65fff2d0 sites: a name we operate is ours whoever asks, and however it is asked for
Hanzo CI/CD / cicd (push) Successful in 14s
CI/CD / gate (push) Successful in 16s
CI/CD / containment (push) Successful in 3m18s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Two independent ways a host WE run could serve a tenant's site. Both are the same
mistake: a set or a test that answers "is this ours" was allowed to shrink.

FIRST — the self-domain set was a default, not a floor.

        self := list("CLOUD_SITES_SELF_DOMAINS")
        if len(self) == 0 { self = defaultSelfDomains(apex, domain) }

An explicit list WON OUTRIGHT, so an operator adding a vanity domain silently
subtracted the derived ones. That set is the sole unconditional gate on the
site_hosts table (projects Store.bindHost asks sites.Ours) and the serve gate's
self-host exclusion, so dropping hanzo.ai makes every `<label>.hanzo.ai` a tenant's
to claim — a first-come row on api.hanzo.ai that denies us our own production host
for good, verbatim the defect SetSelfDomains was added to close. Nothing would have
caught it in review either: hanzo.ai reaches the set by DERIVATION from CLOUD_DOMAIN,
so no deployment states it and no deployment diff would show it leaving. The
reserved LABELS have had this floor all along ("trimming the env only ever ADDS ...
never subtracts"); the half guarding the more dangerous decision did not. Config now
adds to selfFloor and can never subtract from it.

In practice the brand domain also arrived a second way, via FirstPartyApex, so
BOTH had to be misconfigured before it actually vanished — which is why the new
test moves the first-party apex to prove the floor holds on its own.

SECOND — requestHost decided "is the parsed host real" by asking "is it a host we
would SERVE": siteSlug, else customCandidate. Those are different questions, and
the gap between them is exactly OUR OWN domains. api.hanzo.ai names no site, and
customCandidate excludes it BY DESIGN (IsSelfHost), so neither arm fired and the
client-supplied X-Forwarded-Host won:

        Host: login.hanzo.ai
        X-Forwarded-Host: <any bound custom domain>

resolved and served that domain's site — a tenant's content returned for a request
addressed to our auth apex, needing no site_hosts row on hanzo.ai at all. The
file's own comment already stated the property correctly ("a request that HAS a
host ignores the header completely"); the code did not have it. The two tests that
look like they pinned it both send a host that IS a site, so the early return fired
and the header was never read — they proved the property only where it already
held.

Whether we serve a host has nothing to do with which host was asked for, so
requestHost no longer asks: a parsed name with a dot is a hostname and is final.
That is the same set the three-way test actually admitted, minus the coupling that
made our own names the exception — and it leaves the header exactly the job it was
added for, the ingress case where fiber parses no host at all, plus bare internal
names no client addresses us by.

TestForwardedHostNeverOverridesOurOwnHost and TestSelfDomainsAreAFloorNotADefault
both fail on the parent:

    login.hanzo.ai: the binding resolver was asked about [attacker.example]
    hanzo.ai held only via the first-party apex — it must come from the floor

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:57:10 -07:00
hanzo-dev 29acc5233e projects: release and verify address only the names bind could have made
Bind, verify and release must agree on what a hostname IS, and they did not. Bind
required fqdn.Valid; verify and release required only fqdn.Clean, and release did
not even require that much beyond non-empty.

site_hosts holds TWO shapes: a project's custom FQDNs, and its BARE SLUG — the
structural row deploy.go binds so `<slug>.<apex>` serves. A bare label is not Valid
(nameRE wants labels, a dot and a TLD), so release accepted a row bind could never
re-create. The domains panel renders that row like any other claim, as a live
`https://<slug>`, with a delete control beside it. A tenant tidying away the
odd-looking entry drops its OWN subdomain, and the domains API cannot put it back:
resolution falls back to ResolveUniqueLiveSlug, which refuses once two live
projects share the slug, so the subdomain 404s for everyone — and the next tenant
holding that slug to deploy takes the freed row, and the subdomain, for good. An
add-only asymmetry in a first-come global namespace is a transfer primitive.

hostOf is now the ONE reading of a hostname off a request — canonical form, then
the syntax this surface deals in — and all three ops ask it. A blank entry inside a
bind LIST is still skipped rather than refused; that is a list semantic, not a
disagreement about names.

TestReleaseOnlyAddressesNamesBindCouldHaveMade drives the real DELETE at its real
path. Against the parent it fails with the takeover verbatim:

    release of the bare slug = 204, want 400
    EXPLOIT: the site's own subdomain row was DELETED through the domains API

It also holds the positive case — a real custom domain still releases 204 and the
row goes — so refusing everything would not pass.

TestVerifyDomainPromotesOnlyOnProof closes a coverage hole rather than a defect:
Store.VerifyHost was tested and fqdn.Verify was tested, but the HANDLER that joins
them had no test at all, so nothing pinned that this surface requires the proof. It
injects a fake resolver — the projects package had none, so any test that reached
verifyDomain would have hit real DNS — and drives no-record, wrong-token,
right-token, already-verified, unclaimed and un-addressable in one pass. The token
is read from the ROW, never from the request, and that is what the wrong-token case
pins.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:57:10 -07:00
hanzo-dev 419146784f platform: an org owns a registry namespace verbatim, or it owns nothing
Both halves of an authorization comparison must be the same value. The
registry-ownership lookup folded one of them:

        orgRegistryNamespaces[strings.ToLower(strings.TrimSpace(org))]

while the org it compares came from principal.Org, which returns the validated IAM
owner VERBATIM and never lowercases — deliberately, because "acme" and "ACME" are
DISTINCT tenants in IAM and a fold would let a member of one select the other
(principal.go; TestMembershipMatchIsByteExact pins it).

So a tenant who self-serves an org named `Hanzo` — a different owner from `hanzo`,
and one whose own RoleOwner makes IsOrgAdmin true INSIDE it — folded onto the
`hanzo` key and inherited the `hanzoai` namespace. That is both lanes at once:
push over another brand's production images on the build path (runner.go
imageInOrgRegistry, repoOwnerInOrg), and satisfy the org term of the RELEASE gate
on ghcr.io/hanzoai/cloud, the binary every pod in the fleet runs. A fold applied to
one side of a comparison is not a normalization, it is a collision, and here the
collision IS a cross-tenant privilege grant — the same defect 26f69224 removed from
the custom-domain operator set, in a lane with the fleet downstream of it.

ownedBy is now the ONE lookup, keyed verbatim, and both callers ask it. Trimming
stays: whitespace is not an identity. repoOwnerInOrg keeps EqualFold on the OTHER
side — the forge owner parsed out of a repo URL, which genuinely is
case-insensitive — because that side is not an identity this system issues.

TestRegistryOwnershipIsVerbatim drives both lanes over both directions. Against the
parent it fails with the grant verbatim:

    tenant "Hanzo" folded onto a brand's REGISTRY namespace — it could overwrite
    that brand's production images and cut a release of the binary the fleet runs
    tenant "Hanzo" folded onto a brand's FORGE owner

It holds the positive cases and the cross-brand refusal too, so narrowing the map
to nothing would not pass.

NOT addressed here, and flagged for review rather than changed: the release gate
admits `imageInOrgRegistry(releaseImage, org) && principal.IsOrgAdmin(c)` at all.
runner.go:255-267 argues that deliberately and at length — publishing your own
org's artifact is not cross-tenant — but the artifact is the platform's own binary,
`hanzo` is a customer org like any other, and its admin bit is one that org's own
admins set on their own members. fleet.go:254-261 reaches the opposite conclusion
for the mutation next door and gates it on cloud.Super. Overturning a documented
decision in this app is a call for its owner, not for this change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:57:10 -07:00
hanzo-dev ef9580c071 projects: the vouch is the platform predicate, and nothing else
Binding a custom domain WITHOUT proving control of it — the bind lands VERIFIED
and routes immediately, and the "a host we operate" refusal does not apply — is
platform authority. The gate read

        principal.IsSuperAdmin(c) || (operatorOrgs[org] && principal.IsOrgAdmin(c))

and that second term is not a second platform predicate, it is a self-service one
wearing config. `isAdmin` is org-scoped and an org's OWN admin sets it on a member
of THEIR org, so every `hanzo` admin could enrol any `hanzo` member into the gate
— and operatorOrgs defaults to the deployment's brand org in EVERY deployment
(config.go getenv CLOUD_BRAND, brand.Default "hanzo"). Naming the org in config
does not repair that: config grants a capability TO a tenant, but the tenant still
decides who inside it holds the role, so the deployment ends up delegating a proof
bypass to an authority it does not administer. A gate whose far side can enrol its
own callers is not a gate.

SuperAdmin <=> `owner == "admin"` is the ONE platform predicate. A second, weaker
spelling of platform authority IS the escalation, whatever conjunction dresses it
up, so vouches() is now that predicate alone:

        func vouches(c *zip.Ctx) bool { return principal.IsSuperAdmin(c) }

It is cross-tenant by construction, so it still vouches in ANY org — the operator
switched into a customer's org to bind the domain it manages DNS for, which is how
a customer domain is onboarded. That path is unchanged and stays pinned.

Everything the org term reached goes with it: state.operatorOrgs,
operatorOrgsFromEnv, and CLOUD_PLATFORM_OPERATOR_ORGS. The variable appears
nowhere in the universe manifests, so no deployment states it and none needs
editing — a deployment must never state a variable nothing reads. The gate now has
no configuration at all, which is also why a test can no longer under-configure
it: there is nothing left to pass, so the tests drive exactly what production runs.

TestVouchIsSuperAdminOnly drives the real handler over the ROLE axis in the
deployment's own brand org. Against the parent, with the state Mount actually
builds — operatorOrgsFromEnv("hanzo"), the default {hanzo} — the brand-org ADMIN
case fails with the exploit verbatim:

    EXPLOIT: brand-org ADMIN bound a bank's login host with NO proof:
    {Host:login.example-bank.com Status:live Verified:true
     URL:https://login.example-bank.com Records:[]}

It holds the SuperAdmin case too, so merely disabling onboarding would not pass.
TestVouchDoesNotTurnOnTheOrg keeps the ORG axis the verbatim pin covered: four org
names spanning the classes that used to matter — the brand org, its case fold, an
unrelated tenant, and the reserved `admin` org's own NAME — each asserted twice,
org-admin never vouches and SuperAdmin always does. The fold collision that pin
existed for is now dead by construction rather than by comparison, and THAT is the
property it now pins.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:57:09 -07:00
hanzo-dev a9d40cf623 o11y: name the three addresses this host takes, and take them on purpose
The table declares every route it owns by name, so there is no wildcard left
for a host's own route to win against, and two declarations at one address is
a refusal to compose rather than a silent shadow. cloud declares three of them
natively — GET /v1/o11y/logs, GET /v1/o11y/metrics, POST /v1/o11y/query_range
— and used to win them only by registering first.

o11y v1.5.56 gives that fact a spelling: Claimed names the addresses the HOST
serves, and the table then declines to declare exactly those. Say it here.

It is worth being explicit about WHY these three are the host's, because the
ordering that used to decide it decided it invisibly. This package's handlers
are tenant-scoped: handleLogs resolves the caller's org and pins the read to
it. The table's relay hands the call to the runtime with no org on it. Both
answered the same address, the first one registered won, and the two could
have drifted apart forever without anyone being told. The claim is that
decision written down where it can be read — and read the same way on both
sides of the seam, since the string that names the conflict in zip's own
diagnostics is the string that resolves it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:56:08 -07:00
hanzo-dev a9303117e2 integrations: gate the bridge by path, so it sits on a node that has routes
/v1/integrations and /v1/connectors answered 503 for the same reason o11y did:
the subsystem exited before it listened, because it declared middleware on two
group nodes that owned no routes.

    app.Group("/v1/integrations").Use(cloud.Bridge(), zip.H(bridgeFacts))
    app.Group("/v1/connectors").Use(cloud.Bridge(), zip.H(bridgeFacts))

Every op below both lines registers on zapp at an ABSOLUTE path, so neither
group ever received a leaf. A group's middleware wraps the routes in its own
subtree, and an empty subtree means the bridge could never run — which zip
refuses to compose rather than serve ungated.

scope.Use is the install that already solves this: once at the root, gated by
`owns`, confined to exactly the prefixes the manifest declares for this
subsystem. The manifest lists /v1/connectors and /v1/integrations both, so one
install covers what two group nodes were reaching for, and connectorRoutes
needs no bridge of its own.

The test fixture had drifted from the thing it reproduces. installV1Flatten
still hung commerce's error envelope on a Group("/v1"), while commerce itself
moved to gating by path (commerceErrorScope) precisely because a shared /v1
node wraps every subsystem mounted after it. Same shape, same empty subtree,
same refusal — it aborted the package on origin/main before this change. It
now installs the way its twin does.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:56:08 -07:00
hanzo-dev 60ac4e7135 o11y: declare the typed ops at the root, so their names survive composition
The subsystem's ops were declared through a.Group(o11yPrefix), and zip
qualifies an op's id by the prefix of the occurrence it is declared under.
That rule exists so one definition included twice cannot publish one
operationId for two operations, and it is right — but o11yPrefix is not that
kind of prefix. It is not a composition point a host chose; it is this
subsystem's own address. Read through a Group it looked like one, and every
published id came out "v1.o11y.get_logs": the OpenAPI operationId, the MCP
tool, the CLI command and the generated SDK method, all renamed, with no
opt-out — an explicit WithOperationID is qualified the same way.

The same Group also cost the ops their origin, which is the app an op is
declared in and the thing that qualifies published TYPES. A Group is not that
app, so 23 schemas went out bare — logsResponse where the weave expects
o11y.logsResponse, and a bare name is one another subsystem can collide with.

Declaring at the root gets both, because an occurrence there is unqualified
and carries the app's own origin. OpScope.Prefix then prepends to the op's
path exactly as a Group's does, so every ADDRESS is byte-identical: same
method, same full path, same middleware. Only the two names change, and they
change back to what the declaration wrote.

hanzoai/o11y's own table reaches this conclusion for the same reason in its
relay.go; this is that shape on the cloud-native half. mountAlerts is left
alone — it registers raw routes, which publish no id and no type.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:56:07 -07:00
hanzo-dev 7a03e7f77f o11y: the bridge hung on a prefix the subsystem does not own
/v1/o11y, /v1/sentry, /v1/event, /v1/errors, /v1/analytics, /v1/insights,
/v1/integrations and /v1/summary all answered 503 with

    mount /v1/o11y: no instance running

while the pod stayed Ready with 0 restarts. The subsystem runs as its own
child process, and it was exiting before it listened: zip refuses a program
that does not compose, and this one declared middleware at two addresses it
had no leaf beneath.

    a.Group(o11yPrefix).Use(cloud.Bridge())   // o11y.go
    a.Group("/v1/summary").Use(cloud.Bridge()) // summary.go

Neither group ever received a route. The o11y leaves register through a
SECOND Group(o11yPrefix) in scope.go and annotation_queues.go, the module's
353 typed ops register on the app at a root prefix, and /v1/summary's own
leaf registers on the app at the group's address rather than beneath it. A
group's middleware wraps the routes in its OWN subtree, so all three sat
outside the thing meant to guard them — which is the defect the walk names,
and it named it correctly.

Install it once, on the app, where serve.go already says it belongs: a
subsystem whose routes are spread across several top-level nouns owns no
single prefix to hang it on. This one owns eight. That also keeps the module's
353 ops wrapped, which a prefix group silently would not have done — the org
a typed op reads off its context has to be parked for every leaf, not for the
fraction that happens to share a prefix.

The tests carried the same shape and are moved with it, so they exercise what
serving installs rather than a composition only the test builds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:56:07 -07:00
antje 7f87403db5 iam: read both wire shapes, because IAM answers in two
CI/CD / image (push) Successful in 19m18s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m34s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / rollout (push) Failing after 7s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
The avatar write failed for the whole session with two different errors, and
both were this one door mis-reading a reply it had assumed the shape of:

  iam non-envelope response (400)   an error body it could not unmarshal
  iam: iam status 200               a SUCCESS it rejected

Some IAM routes answer the {status,msg,data} envelope this client was written
for. Others — /v1/iam/users/get among them — answer the RESOURCE DIRECTLY, and
their errors are {"status":404,"error":"…"} where `status` is a NUMBER, not the
string "ok". Measured against the running service:

  GET users/get?owner=hanzo&name=z
  -> {createdAt,updatedAt,deleted,id,owner,name,…}   no envelope at all

So a raw row parsed with Status "" and was rejected as `iam status 200`, and an
error body failed to unmarshal and became `non-envelope response`. Every read
through this client was one of those two.

The HTTP STATUS now decides and the body is only read for what it carries: a
non-2xx yields its `error` or `msg`, a 2xx envelope keeps its old meaning, and a
2xx that is not an envelope IS the resource.
2026-08-04 09:43:22 -07:00
zeekayandhanzo-dev 6ae10e83f4 build: untrack five committed binaries — the module had outgrown Go's zip limit
`go get github.com/hanzoai/cloud@latest` fails outright:

    module source tree too large (max size is 524288000 bytes)

v1.801.413 is the last consumable version; .420 and everything after cannot be
downloaded by anyone. That is every consumer of this module, not just ours — ai
hit it trying to take the release it had been waiting on.

The cause is build output committed at the repo root. `go build ./apps/gateway`
drops the binary HERE by default, and five landed that way: gateway 53M,
account 33M, authz 30M, smoke 8M, gen-app-cmds 4M. They are ELF x86-64, ELF
aarch64 AND Mach-O arm64 — three different people's machines, over three weeks,
each an accident nobody could see because the repo already had them. `account`
arrived today and is what crossed the line.

Untracked and ignored by exact path. Every one is built from a package whose
source is untouched (apps/gateway, apps/account, plugin/authz, plugin/smoke,
plugin/gen-app-cmds), so nothing is lost and `go build ./...` is unchanged.

Tracked tree: 174MB → 40MB.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:32:20 -07:00
zeekayandhanzo-dev 2173d11ffb deps: the whole zip v1.24.1 set, in one pin
CI/CD / image (push) Successful in 19m7s
CI/CD / gate (push) Successful in 23s
CI/CD / containment (push) Successful in 2m11s
Hanzo CI/CD / cicd (push) Successful in 23s
CI/CD / rollout (push) Failing after 15s
CI/CD / receipt (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
commerce v1.49.62, iam v1.34.18, o11y v1.5.55, ai v1.832.21 — every subsystem now
published on zip v1.24.1, so cloud and everything it mounts agree on one framework
version rather than four.

That agreement is the point. Router is the type a decorator implements, and the
verbs a decorator must satisfy changed in v1.23 (Use is the one composition verb,
taking a Component); a host on one version and a subsystem on another is a
decorator that cannot be written.

Measured against a clean tree on this host: 97 failing packages before, 97 after —
zero new. That is the macOS SQLCipher limit (no tmpfs for the pure-Go codec),
unrelated and unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:27:15 -07:00
hanzo-dev d85454bb8c risk: the scorer seam has no producer to install, whatever the topology
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The seam's doc said every app is its own process and read that as the reason
SetRiskScorer cannot reach the gateway. The premise is false as stated.
plugin/campaign, plugin/integrations and plugin/guide each link three or four
sibling apps/* into ONE process and wire process-global seams across them
(seams.go in each), under no build tag, and the image builds every plugin
directory. Co-residency is a per-plugin composition choice: one import in one
composition root is the whole distance between the two arrangements. As
composed today plugin/risk links risk alone and plugin/gateway links gateway
alone, which is true and is all that should have been claimed.

The remedy stands for a simpler reason that holds whatever the topology is:
apps/risk exports Mount and Shutdown and nothing else. There is no scoring
function to install, so the seam has no producer because none can be spelled,
not because a boundary forbids one. Giving it one means EXPORTING a scorer and
then deciding what it answers for — this global answers for its own process,
while arming asks whether the risk plane can answer for the fleet, which is a
cross-process ask.

The obs event door stays as the precedent it is, in its own clause rather than
as the justification.

Comments only; no behaviour changes and nothing is armed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:20:42 -07:00
hanzo-dev c227d94dcb sites: one name predicate, derived where the question is asked
Two independent holes let a name the platform holds enter the global
site_hosts table.

SHAPE. Store.bindHost asked sites.IsReserved with its argument, but that
argument is a bare slug on the deploy path (deploy.go siteHost) and a full
hostname on the custom-domain path, while the reserved set holds bare LABELS.
So every FQDN compared against a set of labels matched nothing: login.hanzo.ai
sailed past the only guard behind the claim gate and took a first-come row on
our own auth apex. The storage invariant that is supposed to make the
serve-time gate a mere backstop contributed nothing at all for half the table.

sites.Ours splits on shape — a bare label asks the reserved policy, a hostname
asks the self-domain set — so one predicate answers both, and the claim gate
(domains.go ours) and the host table now ask the SAME question. Note what the
split avoids: `www` and `login` are reserved labels AND the two most common
custom domains a customer brings, so a backstop keyed on "the first label of
this FQDN is reserved" would refuse www.example.com. The label policy must
never reach a name a customer owns.

COMPOSITION. The self-domain set was published only by sites.New. Which apps
share a process is a per-plugin choice, and as composed today plugin/projects
links apps/sites for exactly these two predicates and never calls New — the
edge is not in that process. So the set was EMPTY in the very process that
enforces the claim gate and the host table, and full in the one that serves:
IsSelfHost answered false for everything there. Measured on the parent commit,
in a process that constructs no Server:

	IsSelfHost("hanzo.ai")       = false
	IsSelfHost("api.hanzo.ai")   = false
	IsSelfHost("login.hanzo.ai") = false
	IsSelfHost("hanzo.app")      = false

which is the exact defect SetSelfDomains was added to close, defeated by
composition rather than by logic. No test could see it: they publish the set
themselves, in one binary.

The policy is now DERIVED from config wherever it is asked. ConfigFromEnv is
the one reader of that config and every process reads the same environment, so
the answers cannot drift the way a hand-off between processes does, and it
stays right however the plugins are later composed. An explicit publication
still wins and is never re-read over. Production needs no deployment change:
hanzo.ai is derived from CLOUD_DOMAIN's registrable domain, not configured.

selfOf folds the first-party apex in itself, so New's published set is complete
by construction rather than by statement order — the ordering hazard the
previous fix had to hold by hand.

Tests: the Server-less derivation, the shape split over both halves including
the customer hostnames that must stay bindable, and a concurrent first touch
under -race.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:20:42 -07:00
hanzo-dev 26f69224fa projects: the operator vouch is an admin scope, not a membership
Binding a custom domain WITHOUT proving control of it — the bind lands
VERIFIED and routes immediately, and the "a host we operate" refusal does not
apply — is platform authority. The gate read

	vouched := c.IsAdmin() || s.State.operatorOrgs[org]

and that second term is bare MEMBERSHIP. operatorOrgs defaults to the
deployment's own brand org (config.go getenv CLOUD_BRAND, brand.Default
"hanzo"), so the set is {hanzo} in every deployment, and `org` is the
IAM-validated effective org, gated upstream by isMember alone. Every staff
account whatever its role, plus anyone a brand-org admin ever invited, could
bind login.example-bank.com live with no DNS-01 proof: attacker content served
at any custom-domain customer whose DNS already points at our edge, and the
name denied to its rightful owner for good, since a verified row is first-come
and global.

vouches() now names the two grants, both admin-scoped:

	SuperAdmin            platform sudo (owner == the reserved admin org).
	                      Cross-tenant by construction, so it vouches in ANY
	                      org — the operator switched into a customer's org to
	                      bind the domain it manages DNS for. Unchanged.
	operator-org ADMIN    the deployment named this org an operator AND IAM
	                      says the caller administers it.

The second is a conjunction of two independently administered facts: a
capability the deployment grants to an org, and the role IAM grants inside it.
The set names an org; it never names an authority. The org-admin bit is asked
of the EFFECTIVE org — the same value SanitizeIdentity keys X-User-IsOrgAdmin
on — so the pair reads "admin OF this operator org" and never "admin of some
org I switched out of". Both bits are stripped on ingress and re-minted only
from validated claims.

Fail-secure: an issuer that stops signing the org role drops the operator-org
grant to a PENDING claim carrying the DNS challenge, the same self-service path
every other tenant takes. SuperAdmin onboarding never depended on that claim.

TestOperatorVouchNeedsAdminScope drives the real handler over four identities.
On the parent commit it fails with the exploit verbatim — a plain member's bind
of login.example-bank.com comes back {Status: live, Verified: true}. It also
holds the positive cases, so a fix that merely disabled operator onboarding
would not pass. TestOperatorVouchIsVerbatimEndToEnd keeps the verbatim-owner
pin and now has both callers be org admins, leaving the org name as the only
axis; it went from panicking to passing because it no longer builds the whole
surface.

The harness registers the ops it drives rather than calling routes(), which
composes only on the Router production gives it: routes() declares middleware
with Group(prefix, mw) and registers its typed ops on the App with full paths,
so on the bare *zip.App a test holds the prefix node has no routes beneath it
and zip refuses to compose. cloud.Listen mounts on a *scope, whose Use and
Group install at the root and gate by request path, so the same registration
composes there. What is driven is the production chain at the production paths:
cloud.Bridge parking the request, siteOf resolving the tenant, bindDomains
deciding.

The lifted prose and both published specs carry the corrected contract; the old
text told customers that membership of the operator org was the vouch. zipdoc
regenerates; the OpenAPI subsets cannot be projected for this app yet, so those
two files take the identical substitution zipdoc made.

apps/projects: 55 -> 58 tests passing, no test that passed on the parent fails.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:20:42 -07:00
antje e8b208bbea console pin -> sha-a0a4899: four changes that could not reach production
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The pin sat at sha-9da3984 while console main moved four commits ahead. Since
console.hanzo.ai IS this binary, none of them was reachable by any customer:

  a0a4899  onboarding: Continue keeps its place, and no step can strand you
  06e4163  retire the ML Pipelines (Kubeflow) product — its backend is gone
  da26897  auth: a refusal is not always a failure
  2271c29  profile: a photo you can change, instead of one you can only look at

The embed image for sha-a0a4899 is published and was probed before moving the
pin (200, against a known-good positive and a bogus negative control).
2026-08-04 09:18:16 -07:00
hanzo-dev f62dc2ff5b untrack native/flags/target — 360 MB of orphaned cargo output in every release
855 files, 360.7 MB, 192 of them real .rlib/.so/.a binaries: a complete cargo
build tree committed to git. Every published hanzoai/cloud module version has
carried it, so every consumer downloads 360 MB of another project's build output
to compile Go. The local module cache alone holds ten copies.

It is orphaned, not merely misplaced. native/flags/ contains NOTHING ELSE — no
Cargo.toml, no src/, no crate at all — so there is not even a project here to
rebuild it. No .go file, Makefile, Dockerfile or shell script references the
path. It arrived as collateral in 56c1b003 and nothing has needed it since.

.gitignore has listed `native/flags/target/` since before this commit; an ignore
rule does not untrack what is already tracked, which is exactly how 360 MB stays
in a tree everyone believes is ignored. `git rm --cached` is the part that was
missing.

Files stay on disk; only the index changes. History still carries the blobs, so
this shrinks FUTURE module versions rather than past ones.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:16:19 -07:00
antje f28bf6bdf9 scope: gate the subsystem's middleware by path, not by an empty group node
CI/CD / image (push) Successful in 22m36s
CI/CD / gate (push) Successful in 23s
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / containment (push) Successful in 1m10s
CI/CD / rollout (push) Failing after 14s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
Nine plugins failed to start and every route they own answered 503 — account,
catalog, analytics, o11y and the rest — on one panic:

  panic: zip: the group "/v1/avatar" declares middleware at scope.go
         and no routes anywhere beneath it

THE NODE WAS THE BUG, NOT THE PREFIX. scope.Use installed middleware with
s.app.Group(p).Use(...), which creates a node AT p; scope.Get and its siblings
delegate straight to s.app, so the routes land on the ROOT node. Same paths, two
nodes. zip >= 1.23 checks the subtree of the node the middleware is on, finds it
empty, and refuses to compose a program whose middleware could never run. It was
right to.

Declaring the prefixes did not fix it — it moved the panic from /v1/account to
/v1/avatar — and analytics failed identically while already declaring them.

Both gates now install ONCE at the root and test the request path: Use against
the subsystem's prefixes, Group against its own. The root always has routes, so
nothing is empty, and `owns` does on the request what the per-prefix node was
there to do on the tree. `under` is that one meaning of "inside my subtree",
shared by both.

Confinement is unchanged and still proven by the tests that were red:
ScopeConfinesUseToTheSubsystem, ScopeHonoursDeclaredPrefixes and
ScopeAllowsGroupInsideItsPrefixes all pass. The root package now reports ZERO
composition panics; its 43 remaining failures are two macOS-only causes (40 cek
"no RAM-backed scratch", 3 unix-socket path length), identical before and after.

THE SUITE ALREADY REPRODUCED THIS. `go test ./` was red on main throughout the
outage, with the exact production panic, in under a second and with no cluster.
It was read as environmental noise while the fix was hunted in production.
2026-08-04 09:12:59 -07:00
hanzo-dev 5090c37474 risk: the scorer seam names the process boundary it does not cross
CI/CD / image (push) Successful in 19m27s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m51s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / rollout (push) Successful in 7m0s
CI/CD / reach (push) Failing after 1m47s
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
SetRiskScorer has no production caller. The seam's own doc said to follow the
observability plane's event door "(obsevents.go)" — a file deleted in 4a6f3918,
by the commit that established the opposite: "A plugin is a process;
cloud.SetObsEventIngest / SetObsErrorIngest could never have worked across that
boundary." That door read nil in the process that needed it and answered 503 to
every Sentry SDK until it became a plane op (apps/o11y/obs_rpc.go).

SetRiskScorer is the identical shape, and the fleet is one process per app:
cmd/cloud mounts each subsystem as its own binary, plugin/risk lists risk alone
and plugin/gateway lists gateway alone, and no binary links both. So the wire
this seam invites — call SetRiskScorer from the risk app's Mount — would arm the
risk process and nothing else, while apps/gateway's RiskScorerInstalled, running
in the gateway binary, stayed false and PUT /v1/gateway/config went on refusing
every arming request. It would read as wired and change no outcome.

The tests cannot show this: they link cloud and the app into one binary, where
the handoff always works. That is why 43 references pass over a seam with no
producer. Both comments now say so — what the seam reaches, and that the
gateway's refusal is currently unconditional and fail-SAFE, answering "is a
scorer linked here" rather than the question arming asks, which is whether the
risk plane can answer for the fleet.

Comments only; no behaviour changes and nothing is armed. Reaching the fleet
answer is a plane op, and that is a decision to take deliberately, not a wire
to restore.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 07:47:08 -07:00
hanzo-dev bb02688c7a sites: the first-party apex reaches the published self-domain set
New() published the self-domain set and THEN folded the first-party apex into
the slice it had just handed over. SetSelfDomains copies its argument
(reserved.go), so that append could never reach IsSelfHost — the one predicate
that answers "is this host ours", read by both the serve gate and the claim
gate.

Under a config that names the apex only as FirstPartyApex — not also in
CLOUD_SITES_SELF_DOMAINS — every non-allowlisted <label>.<fpApex> was therefore
a custom-domain CANDIDATE on the apex that carries api/login/console: a claim
row a customer could take, and a per-request binding lookup on the hot path the
exclusion exists to keep clear. Production lists hanzo.ai in BOTH, which is why
TestSelfDomainsCoverTheBrandApex passes either way and never saw it.

The publish now runs after the fold, so one set leaves the constructor.

Server.selfDomains went with it: it was written once here and read nowhere,
a second copy of a set whose only reader is the package global in reserved.go.

  TestFirstPartyApexReachesThePublishedSet
    fix reverted, test kept  → RED (IsSelfHost false, customCandidate true
                               for hanzo.ai / api.hanzo.ai / login.hanzo.ai)
    fix restored             → GREEN

apps/sites and cmd/cloud green; apps/crm unchanged from its baseline failure
(TestIntakeRateLimitScope, pre-existing on origin/main).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 07:46:55 -07:00
hanzo-dev 24c2b13474 Merge blue/search-adoptable: a search winner is a shape the organisation that asked for it can run
CI/CD / image (push) Successful in 19m52s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m46s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / rollout (push) Successful in 5m28s
CI/CD / reach (push) Failing after 1m57s
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 07:25:51 -07:00
hanzo-dev 702c5aeaf4 risk: a search winner is a shape the organisation that asked for it can run
POST /v1/risk/search ranked model spaces against a tenant's own history and
answered with the one that fit. Nothing could promote it, and nothing could:
adoption refused a shape change, and a winner is a different shape by
definition. The stated replacement for Katib produced advice nobody could take.

A model value now carries the SPACE its masses describe, not only the masses,
and install REPLANTS — it builds that space, restores into it, and swaps it in.
Every gate the refusal used to carry still holds: the tenant comes from the
validated principal, the geometry seed is checked against the model already
running before anything is rebuilt, and the recorded shape is rebuilt and
compared by digest rather than trusted. A value recording no shape is refused
rather than defaulted, and a failed replant leaves the residency untouched.

The winner arrives as one of the organisation's own published values: the run
fits it once more after the grid — a sixty-fifth pass, gated and metered as one
— under that organisation's OWN geometry, because the grid's reference partition
is a constant and a model an organisation runs must partition the space in a way
an outsider cannot predict. Keeping all sixty-four fitted stores instead would
hold 21 MiB for sixty-three shapes nobody adopts.

Also:
  - the shape is its own value, and the half of a config that belongs to the
    state. The grid's candidate embeds it, the residency records it, a published
    value stores it: one spelling, so adopting a shape cannot restate a policy.
  - the resume row's shape is what the next process plants. Without it an
    adopted shape was silently lost on every rollout and the organisation went
    back to the default, warming, deciding nothing.
  - the fold watermark travels with an adopted value. It is in the address for a
    reason; leaving it behind meant a rollback would never re-read the fold it
    skipped.
  - snapshot+restore collapse to POST and PUT on /v1/risk/state/model: one
    address for one kind of thing, the same collapse GET and PUT on
    /v1/risk/policy already made. openapi/floor.json loses one PATH (the risk
    product keeps all 31 operations).
  - the value bound is sized on the widest shape the grid declares and MEASURED
    at full occupancy (2,214,100 bytes), because a fitted sample understates a
    busy organisation's tree by an order of magnitude.
  - open() no longer reads the state believing the read plants a tenant's trees.
    Measured against the pinned engine: it does not.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 07:24:43 -07:00
hanzo-dev 8aa010aef9 ai v1.832.20 — the accelerator requirement reaches the binary
Hanzo CI/CD / cicd (push) Successful in 54s
CI/CD / gate (push) Successful in 55s
CI/CD / containment (push) Successful in 2m18s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The two Kubernetes submit paths in ai/cluster no longer name a vendor in a
scheduler contract. The TrainJob's resourcesPerNode and the KServe
InferenceService's predictor both take the device-plugin resource name the
CLUSTER advertises, and a cluster that advertises no accelerator is refused at
submit time rather than accepting a workload that can never be scheduled --
which is what the KServe path did, silently, with a live controller
reconciling the result into a permanently-pending predictor and a model
registered on api.hanzo.ai whose every call fails.

Pairs with agents.Need/Spec.Satisfies landed here: one vocabulary for what a
job requires and what a machine advertises, and no vendor field in either.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 07:04:34 -07:00
hanzo-dev ac1bab57e8 Merge feat/accel-capability-match: a job needs accelerators, not one vendor's resource name
CI/CD / image (push) Successful in 17m31s
CI/CD / gate (push) Successful in 58s
CI/CD / containment (push) Successful in 1m42s
Hanzo CI/CD / cicd (push) Successful in 58s
CI/CD / rollout (push) Successful in 6m58s
CI/CD / reach (push) Failing after 1m58s
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 06:59:27 -07:00
hanzo-dev 1bbf38d0c0 agents: a job needs accelerators, not one vendor's resource name
Spec already carried what a machine IS -- os/arch/cpus/memory and each
accelerator's vendor/model/memory. Nothing carried what a job WANTS, so the
only way to ask for a GPU was a scheduler contract that named a vendor:
resourcesPerNode.limits."nvidia.com/gpu". That is not a requirement, it is one
vendor's name for a requirement, and it made a job unroutable to an AMD or
Apple machine that could have run it.

Need is the other half of Spec, in the same vocabulary, and Satisfies is the
one place the two meet -- a pure function of two values, so the dispatch gate,
a scheduler and a UI preview cannot disagree. Need has no vendor field: which
vendor clears an accelerator requirement is the machine's business, and
hanzo-kernel lowers one kernel source to CUDA/ROCm/Vulkan/Metal so a job never
has to care.

Unknown memory does not clear a memory floor. Every unified-memory
accelerator advertises 0 today (nvidia-smi answers "[N/A]" on a GB10,
system_profiler emits no VRAM line on Apple Silicon, lspci carries no memory
at all), so a VRAM floor currently refuses all three -- fail-closed, and the
reason the probe should report the memory an accelerator can actually address.
The three boxes are fixtures here, carrying their measured values, so that
change shows up in one place.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 06:57:40 -07:00
antje 2a836a71de plugins: declare the prefixes, in the eight that would panic the same way
CI/CD / image (push) Successful in 20m15s
CI/CD / gate (push) Successful in 24s
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / containment (push) Successful in 1m13s
CI/CD / rollout (push) Successful in 5m10s
CI/CD / reach (push) Failing after 1m56s
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
account went down because its plugin declared no Prefixes and MountPrefixes
falls back to /v1/<name> — a path it does not serve — so the scope guarded a
subtree with no routes and zip refused to compose. Same fault, same fix, in
every other plugin that carries it.

Found by predicate rather than by waiting for each outage: undeclared Prefixes
AND a manifest row without /v1/<name> AND an app that calls app.Use(). All three
are needed — 25 plugins match the first two and serve fine, because the panic
only fires when a subsystem actually installs middleware at its scope root.

  admission bot dataset do graph knowledge leaderboard treasury

Each now declares what the manifest already says it answers, which is what the
host routes to it either way — so this changes no address, it only stops the
scope guarding one that was never served.
2026-08-04 06:31:12 -07:00
antje 1d287efd01 account: declare the prefixes, or the scope guards a path with no routes
CI/CD / image (push) Failing after 28m52s
CI/CD / gate (push) Successful in 20s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / containment (push) Successful in 2m10s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every account route answered 503 in production — /v1/keys, /v1/csrf, /v1/avatar
— because the plugin panicked at mount:

  panic: zip: the group "/v1/account" declares middleware at scope.go:134
         and no routes anywhere beneath it (via root -> /v1/account)

The plugin declared no Prefixes, and undeclared is not "no prefixes":
MountPrefixes falls back to the /v1/<name> convention (subsystem.go:78). Account
answers at NONE of /v1/account — its routes are /v1/keys, /v1/csrf, /v1/avatar,
/v1/orgs, /v1/embed and /v1/commerce/topup/*. So scope.Use installed the
subsystem's Bridge on a path with nothing beneath it, and zip refuses to compose
a program whose middleware can never run.

The same fallback bit analytics and entitlements before this; both carry the
same one-line fix, and this is it.

WHY THE TESTS DID NOT CATCH IT, which is the part worth keeping: they mount on a
bare zip.App, where Use attaches at the root and the root HAS routes. Production
mounts through a SCOPE. Same code, opposite outcome — so a green suite proved
nothing about the composition that actually ships.
2026-08-04 05:33:08 -07:00
hanzo-dev 9e37171bd7 Merge remote-tracking branch 'origin/fix/billing-routes' into HEAD
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 21s
CI/CD / containment (push) Successful in 1m49s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 02:09:44 -07:00
hanzo-dev 0b0f2599ac billing: give the three unowned billing addresses an owner, or none at all
manifest/clients_test.go's ledger of addresses a first-party client asks for and
the fleet does not answer held three entries. All three are closed; the ledger is
empty.

GET /v1/billing/portal/methods — this is what made "card save is broken" true END
TO END. cloud's billing app serves the saved-card list by proxying here, and
nothing in the fleet served it, so the proxy forwarded a 404 verbatim and the list
rendered as "no cards saved" no matter how many cards were vaulted. Claimed on
commerce's manifest row and registered co-resident.

THE GATE, which is the actual work: PortalPaymentMethods keys tenancy on a
?customerId QUERY PARAM, so the chain has to pin the subject for both principals
that arrive. TokenRequired (not IAMTokenRequired) authenticates and resolves the
ORG from the gateway-pinned X-Org-Id for an IAM member AND for the raw service
token the proxy presents; PinBillingSubject is the IDOR control — it overwrites
every billing-subject key with the validated caller's own account.Payer subject
and drops ?org, passes the query through only for a bearer that constant-time
matches COMMERCE_SERVICE_TOKEN, and fail-closes anyone who is neither. The tenant
is never a caller-supplied field on either path.

DELETE /v1/billing/methods/{id} — 405 at the live edge: a customer could ADD a
card and never REMOVE one. billing registers the sub-resource on the same router
as the collection (the host claims a prefix for ONE app across every method) and
proxies it to commerce's DELETE /v1/billing/portal/methods/{id}, the target that
does not self-dispatch. The org comes from principal.Org — the VALIDATED
principal only, never readerOrg's service-token admission, because this is a
mutation and that is the rule createPaymentMethod and gpuCharge already follow.
The id is escaped into the upstream URL: it names a resource, not a route.
PATCH stays unserved — no client edits a card.

POST /v1/billing/payment — DELETED, not served. No app in either server repo has
ever registered it, in any commit, so the crypto top-up's recording step always
failed and a customer who had already sent USDC to the treasury got a 502; it was
501 besides, since TOPUP_RAILS is configured in no environment. There is nothing
to point it at: money-IN has one door (commerce's mint-gated POST
/v1/billing/deposit) and the fleet routes NO mint address at the edge — the only
two money-in paths manifest.Apps hands to an app are the card ones, both with a
server-authoritative amount. What the browser client sends here is a
client-supplied amount and a client-supplied subject, which is the exact shape the
mint gate exists to refuse. So the surface that existed only to call it goes with
it, and openapi/floor.json records the two-operation reduction next to its reason.

Tenant isolation: TestDeletePaymentMethod_TenantIsolation proves org A cannot aim
a delete at org B through the org, the id, or any subject key; the far side is
proved in commerce (payment_methods_tenant_test.go) for both handlers and both
caller profiles.

Needs hanzoai/commerce fix/billing-methods for the portal detach it proxies to.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 02:02:09 -07:00
hanzo-dev 66c07e46bf Merge remote-tracking branch 'origin/main' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 02:01:50 -07:00
hanzo-dev b68e8897a2 Merge remote-tracking branch 'origin/x402-v2' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:59:13 -07:00
zeekayandhanzo-dev aa416cc6cb Only the host takes the writer lease, never a plugin child
CI/CD / image (push) Failing after 15m4s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m51s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 18s
The lease is a single-holder flock on one inode under DataDir. Between two POD
GENERATIONS rolling over the same PVC that is exactly what is wanted — it is the
thing that stops a surge writer double-opening the exclusive-lock ZapDB and audit
stores. Between the sibling processes of ONE pod it is a deadlock.

cloud is a plugin host: kms, pubsub and kafka are separate processes in one
container, sharing this DataDir by design. Every one of them called
acquireWriterLease on the same path, so the first to start won and the rest
blocked to the 90s fail-closed deadline. Nothing bound :8000, the liveness probe
killed the pod, and the replacement deadlocked identically — api.hanzo.ai served
503 in a restart loop produced entirely by the safety mechanism.

`CLOUD_WRITER_LEASE` was reverted in the values file to stop the bleeding
(universe 47e195f1, "the binary is not one process"). This is the other half: the
variable can be set again without taking the deployment down.

The guard is underRouter(), which already exists and already means this — ZIP_ADDR
is proof of a parent that owns a plugin table. A child is not a second pod; it is
part of the writer that is already holding the lease.

The test pins all three facts: the host takes it, a second host-shaped process is
still refused, and a child never asks.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:58:23 -07:00
hanzo-dev 715db4473c x402: speak protocol version 2, and stop losing the payer's money
The wire was V1-shaped and ours in the places it was not the spec's, so a
compliant @x402/fetch or x402[httpx] client could not pay us at all: it sends
PAYMENT-SIGNATURE and we read X-Payment; it signs `payTo`/`asset`/`amount` and we
quoted `payee`/`token`/`amount`; it speaks CAIP-2 and we published a network label
beside a separate chainId. Every field name, header name and error reason on the
wire is now the one x402-specification-v2.md prints.

  header    X-PAYMENT / X-PAYMENT-RESPONSE  ->  PAYMENT-SIGNATURE / PAYMENT-RESPONSE
            (plus PAYMENT-REQUIRED for the challenge), all base64 JSON
  version   "1" (string)                    ->  2 (number)
  scheme    "erc3009"                       ->  "exact" (+ extra.assetTransferMethod)
  network   "hanzo" + chainId 36963         ->  "eip155:36963", chain id read OUT of it
  challenge bare PaymentRequirements        ->  PaymentRequired{resource, accepts[]}
  payment   flat Proof                      ->  PaymentPayload{accepted, payload{
                                                signature, authorization}}
  answer    Receipt on a header             ->  SettlementResponse, on success AND failure

The V1 shapes are deleted, not aliased. A header a client may send under either
name is two wires, and the one the server forgot to read is the one where a payer
pays and is never served.

Two things the alignment forced, both real bugs:

* The client's echoed `accepted` is now checked field-by-field against what we
  offered (spec 6.1.2 step 5). `extra` IS the EIP-712 domain, so a payload free to
  restate it would sign a message of its own devising and verify against itself.

* settleLedger documented a hole and left it: debit lands, credit fails, nothing
  served, and the ONLY recovery was the client re-presenting an authorization that
  expires in 300s. A client that gave up for five minutes was permanently debited
  with nothing delivered.

  Fixed by ordering, not by compensation (a reversing entry refunds a payer who
  was correctly charged when the failure was a timeout). The settlement row is now
  CLAIMED before any money moves and flipped to settled after both halves land, so
  an interrupted settlement is a durable row naming the payer, the payee subject
  and the amount — keyed on the id both money writes are idempotent on. And the
  time window gates ACCEPTING an authorization, not COMPLETING one already
  accepted: EIP-3009's validBefore bounds when a transfer may be submitted, not how
  long submission takes. Two independent paths now converge — the client is served
  whenever it comes back, and Reconcile finishes it from the claim if it never
  does.

  The payee's ledger SUBJECT is on the claim rather than re-resolved, so completing
  a settlement pays the payout wallet the listing named. Re-resolving credited the
  seller ORG, which no balance assertion could catch (finance aggregates at the
  org) — the test now asks the ledger where the money went.

Also: POST/GET /v1/wallets answered 403 "sign in" to everyone. zip v1.23 scopes a
definition's middleware to its own subtree, and the collection root was declared on
the app, outside the group carrying cloud.Bridge — so it reached its handler with
no validated principal. Declared on the /v1 parent now. This is what blocked all 8
x402 and 4 marketplace tests from running at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:54:27 -07:00
hanzo-dev 75702e82f7 Merge remote-tracking branch 'origin/spec/name-the-weave-target' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:48:11 -07:00
hanzo-dev de662b0129 release: claim the version before building it, not after
A version was allocated by READING — max(registry tags, git tags) + 1 — and a
read reserves nothing. Two lanes publish ghcr.io/hanzoai/cloud (this repo's
.hanzo/workflows/cicd.yml, and apps/platform/release.go behind POST /v1/runner);
both computed the same next number, both built for ~20 minutes, and both pushed.
GHCR TAGS ARE MUTABLE, so the second push silently REPLACED the first's bytes
under a name the first had been told was its own:

  v1.801.361  overwritten at 04:40:53
  v1.801.410  overwritten at 08:17:12, twelve minutes after the real release,
              by an image whose revision label read `unknown`

The loser found out at its tag step — three quarters of the way through, after
it had already corrupted the winner's image, which the winner then smoked,
pinned and shipped. A check twenty minutes downstream of the act it guards is
not a check.

Creating refs/tags/v<N> is the one operation in either lane the server performs
as a COMPARE-AND-SWAP: 201 if absent, 422 if present, decided under its lock.
So the claim IS the allocation, and it moves to the FRONT of both lanes. A
number that cannot be claimed was never ours to build; the loser walks to the
next one in a single HTTP call, before building anything. The tag step that used
to mint now VERIFIES the claim still names this commit before the pin.

Cost: a failed build leaves a hole — a tag with no image. That is the cheap
direction. A hole is inert and visible (pin.sh refuses a tag that does not
resolve); a reused number is invisible and serves the wrong bytes.

Also:

  - resume is decided by the REGISTRY, not the tag. With the claim moved before
    the build, a tag on HEAD no longer implies an image exists, so the old
    `git tag --points-at HEAD` resume would have skipped the build of a release
    that had none. The tag says what we own; the image says how far we got.

  - the pushed image is verified to BE the commit we built, read back off the
    registry rather than trusted from our own build output. This is what makes a
    clobber by any lane — including one that ignores the claim — loud instead of
    a green pin onto foreign bytes.

  - REVISION is passed as a build-arg by both lanes. The Dockerfile declares
    `ARG REVISION=unknown` and stamps the label from it; buildFrontendCmd never
    passed it, so every platform-built image was untraceable to a commit. That
    is why the two v1.801.410 images could not be told apart without diffing
    layers. Branch refs are refused (isCommitSHA) — a label that says "main" is
    populated and useless.

  - .hanzo/scripts/image-revision.sh reads an image's commit, descending an
    index to its amd64 child so multi-arch images are not silently unchecked.

  - the two 422s are distinguished. "Reference already exists" is the collision
    this loop is for; "Object does not exist" means our own commit is not on
    github.com — reachable, since these lanes run on git.hanzo.ai and claim
    against GitHub — and no amount of walking forward fixes it.

Tests: TestClaimReleaseVersion_IsExclusive reproduces the race (a number another
commit holds is skipped and never overwritten; our own claim is a resume that
mints nothing), and TestTagRelease_VerifiesTheClaim asserts the tag step only
READS. claimFrom is split from computeReleaseVersion so the exclusion property
is testable without the registry.
2026-08-04 01:46:25 -07:00
hanzo-dev a6988db97e spec: name the target that writes the document
`make openapi` was renamed to `make describe` in e247e255 — correctly, since
the target now projects every app rather than only the spec — and 27 places
were left naming the old one. Two of them are FAILURE MESSAGES: a developer
whose golden is stale is told "run `make openapi`", which prints "No rule to
make target". A gate that says how to fix it, and names a command that does
not exist, is a gate that reads as broken tooling.

Comment-only apart from those two t.Fatalf strings; no target, no behaviour and
no artifact changes. `go build ./openapi/` and the four apps with the most
edits build with the tags hanzo.yml's own go-unit gate uses.

openapi/fleet.go also gains the projection it was missing. It lists four
projections of this API compared against each other by test; there is a fifth,
downstream and in another repo — hanzoai/openapi's hanzo.yaml, which every
published SDK is generated from — and it refutes itself against the LIVE
endpoint in that list, because that is the only one of the four a repo with no
checkout of this one can read.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:45:57 -07:00
zeekayandhanzo-dev f6c9605bd7 zip v1.24.1: tests stop reaching through fiber, so they see what serving installs
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m38s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Upstream landed the v1.23 verb migration (Graft/Add/Mount folded into Use). This
is the half that was missing, and it is the half that made tests lie.

App.Test used to skip prepare, which installs the deferred projections — /mcp, the
OpenAPI document, the op-call plane, the plugin route. So those four addresses
answered 404 under test and 200 in production, and the papering-over was an
exported Prepare each caller had to remember. zip v1.24.1 makes Test prepare;
apps/ai's MCP door test passes because of that, not because of anything here.

414 call sites move from app.Fiber().Test(...) to app.Test(...) with
zip.TestConfig. That is the point of the escape hatch living on the concrete type:
reaching through it bypasses what App.Test does, so the tests most wanting to
exercise the real program were the ones that did not. Sites whose receiver is a
raw fiber app keep fiber's type — the two are not interchangeable and pretending
otherwise is how the first sweep broke things.

Also: the multi-line `Use(func(c *zip.Ctx) error {…})` literals in tests, which
the verb migration missed because they fail vet rather than build; and the last
`.Prepare()` calls, now that it is implicit.

iam v1.34.11 → v1.34.12.

Measured against upstream on the same host: 103 failing packages before, 97 after
— ZERO new, 6 fixed. The remainder is the macOS SQLCipher limit (no tmpfs for the
pure-Go codec), unrelated and unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 01:33:56 -07:00
antje ec1e9a69aa iam: resolve a UUID subject to the name IAM addresses rows by
CI/CD / reach (push) Failing after 1m56s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m41s
CI/CD / image (push) Successful in 17m44s
CI/CD / rollout (push) Successful in 4m48s
CI/CD / fanout (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / receipt (push) Failing after 2s
With the read shape fixed, the avatar write moved one step and stopped: the
lookup now reaches IAM and asks for user `hanzo/2d4d67ab-…`, which does not
exist under that spelling. IAM addresses a row by its NAME — the row whose id is
2d4d67ab-… is named `z` — and on the direct-Bearer path the only user handle a
token carries is the UUID `sub`, because X-User-Name is not stamped there.

So a failed direct lookup now resolves the id against the org's roster
(get-users?owner=…, which carries both id and name) and retries once. Measured:
that roster returns 268 rows for hanzo, each with both fields, so the mapping is
available exactly where it was needed.

It runs ONLY after the direct read has already failed — the gateway path, where
username == name, never pays for it.
2026-08-04 01:31:19 -07:00
hanzo-dev c6fddafc15 zip v1.23: Graft is dead too — Use is the only verb left
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m39s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
zip v1.23 deletes graft.go outright. Graft(children ...*App) error was the third
way to attach something to an app, alongside Add and Use; v1.23 keeps exactly
one, and an *App IS a Component, so a child is included by reference through the
same verb as a middleware.

Two call sites, both the "include a whole subsystem's app" case Graft existed
for: apps/iam grafting iamserver.NewApp(db), apps/o11y grafting its assembled
app. Both now Use it.

ONE SEMANTIC CHANGE, recorded because it is not visible at the call site: Graft
refused an address conflict EAGERLY and returned the error to the caller, having
checked every child address against the parent's router and its siblings before
mutating anything. Use appends and defers that verdict to Build, where the whole
program is known. Same refusal, later and with more information — but a mount
that used to fail at its own line now fails at seal, so read Build's error, not
the mount's.

Verified: `go build -tags sqlite_math_functions ./...` returns ZERO across the
whole tree with iam v1.34.11, o11y v1.5.54 and commerce v1.49.61 — the three
dependencies that had to publish first. Graft appears in no .go file in any repo
in the estate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:59:53 -07:00
hanzo-dev 8767e9378d zip v1.23: Use is the ONE composition verb
Cloud pinned zip v1.18.23 while latest was v1.23.0 — five minor versions of
drift on the framework every subsystem composes through. v1.23 removes the
second and third ways to attach something to an app and leaves exactly one:
Use(cs ...Component), where a Component is a Handler or an *App included by
reference.

Cloud's own four adaptations:

- Router.Use widens from ...zip.Handler to ...zip.Component, mirroring
  zip.Router exactly. That mirroring is load-bearing: ZipApp's type switch asks
  whether a *zip.App satisfies this interface, and a narrower Use made that case
  IMPOSSIBLE — the compiler rejected the switch outright rather than silently
  taking the wrong branch.
- scope.Use forwards Components unchanged, still once per declared prefix.
- (*zip.App).Add is gone. zip.Load already returns the leaf *App and an *App IS
  a Component, so both call sites capture the leaf and Use it. The eager and
  lazy rungs of the plugin ladder keep their existing error handling; only the
  attach verb changed.
- Five bare closures passed to Use now go through zip.H. Go will not implicitly
  convert func(c *zip.Ctx) error to an interface that only the named Handler
  type implements. Route methods still take ...Handler, so no route registration
  changed — only Use sites.

Verified: with zip at v1.23.0, `go build -tags sqlite_math_functions ./...`
reports ZERO errors from apps/, cmd/, clients/ or internal/. The only remaining
failures are in three dependencies that must publish first — commerce
(mintRouter implements the old Use, and reaches Fiber() through the Router
interface, which no longer exposes it), iam and o11y (both call the now-
unexported app.Prepare; the public replacement is app.Build, which returns an
error Prepare did not). Those are in flight; this commit is the cloud half and
does not build green until they land.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:59:53 -07:00
hanzo-dev c0bd605409 sites: one resolution of the edge config, and drop four dead exports
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The site edge is mounted in two processes — the light router that owns the
public port (cmd/cloud) and cloud.Listen in every per-app child — and each
resolved sites.Config for itself, from two spellings of the first-party keys
(CLOUD_SITES_FIRSTPARTY_* against CLOUD_SITES_FIRST_PARTY_*) with two sets of
defaults. Neither spelling is set in production, so the two processes booted
different policy off their DEFAULTS alone: the router resolved no first-party
apex, so hanzo.ai never entered its self-domain set, so every hanzo.ai host —
api.hanzo.ai included — was a custom-domain candidate and took the per-request
binding lookup the self-domain exclusion exists to keep off that path.

The reserved denylist was NOT affected. Both spellings read the same
CLOUD_SITES_RESERVED key, the operator value only ever ADDS via
SetReservedExtra, and the labels an attacker wants are baked into reserved.go —
so an empty value cannot un-reserve anything. Measured at the live edge:
www/api/app/admin/stg/login/wallet.hanzo.app all fall through with no
X-Hanzo-Site, against quest.hanzo.app which answers with one.

The env keys and their defaults now live in apps/sites, the package that owns
the type, and both call sites read that one function. Config keeps only Domain,
which feeds the self-domain set.

Also removes exports with no caller in this repo or any that import it:
OKList/OKRaw (envelope.go, whose note about a clients/admin/core delegator was
stale — that package is gone), BrandInfo, DegradedNames/IsDegraded (the release
smoke reads /v1/health over HTTP; the in-process accessors had only tests, whose
coverage of the live Degraded/Degradations pair is kept), and the four inert
Stage-0 control-plane fields with their NODE_ID/PEERS/ROLE/CONTROL_PLANE_QUORUM
reads.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:57:04 -07:00
hanzo-dev b8c4212485 engine: dial the port the engine deployment actually exposes
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Every op on /v1/engine was dialling a refused connection in production. The
upstream default named :1234; svc/engine in namespace hanzo exposes ONE port and
it is 36900 (name http, port 36900, target 36900), and the cloud Deployment sets
no ENGINE_UPSTREAM — so the wrong default WAS the production value. status
answered reachable:false, and models, model and system answered 503. The
subsystem was honest about being unreachable, which is why nothing alarmed: a
truthful report of a broken configuration reads exactly like a runtime that is
down.

1234 is standalone `hanzo serve`'s default. 36900 is the port the engine binds as
the node's engine, and 36900 is what is deployed — so the comment claiming 1234
was "the in-cluster Service of the engine deployment" described a process this
cluster does not run. The 1234-vs-36900 confusion is already on record from the
desktop build, where a frontend discovered models at one port while the engine
that answers ran at the other; this is the same mistake on the cloud side.

MEASURED against the deployed engine over a port-forward to svc/engine:36900,
which answers exactly the three endpoints this plane calls, all 200 —
GET /health ("OK"), GET /v1/models (the model list, with a loaded model carrying
"status":"loaded") and GET /v1/system/info (the SystemInfo document: os, kernel,
cpu, memory). So the upstream is present and correct and only the port was wrong;
this is one token, not a redesign.

ENGINE_UPSTREAM stays the override, and 1234 remains right where it is right — a
dev box running `hanzo serve`. Behaviour only: no route, no schema, no operation
id and no published byte changes, and regenerating the subset produces no diff.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:56:31 -07:00
hanzo-dev a361b88677 engine: an operation is named for its product, and its summary is for the caller
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
An operation id is the generated SDK METHOD NAME and the CLI COMMAND, and a
summary is what the MCP tool list shows a model choosing between tools. This
plane stated neither, so both were defaults, and both defaults were wrong in a
way only a caller sees.

zip derives an unstated id from the path, so the four ops published
`get_v1_engine_status`, `get_v1_engine_models`, `get_v1_engine_model`,
`get_v1_engine_system` — path mangling where the rest of the fleet publishes the
product and the noun. The risk product's thirty-one operations are `riskScore`,
`riskState`, `riskDatasets`, `riskLabelCoverage`; these are now `engineStatus`,
`engineModels`, `engineModel`, `engineSystem`, which is also the rule this
package already applied to its own SCHEMA names and only to those.

A summary defaults to the first sentence of the Go doc comment, and a Go doc
comment opens with the Go IDENTIFIER — so the published summaries read "Status
reports whether the engine deployment is reachable", "Models lists the models
the engine serves", "Model reads one model's load state". A Go symbol name was
the first word a CLI user, an SDK reader and a model picking a tool saw. Each op
now states a summary written in the imperative for the person calling it, and
the doc comment stays a Go doc comment that zipdoc still lifts as the
description: two audiences, two sentences, one declaration.

FORWARDS-ONLY, and it costs nothing: this plane has no customers on it. No
alias, no redirect, no compat shim.

Regenerating from source changes exactly four operation ids. No path is added or
removed, no (path, method) pair moves, no schema changes, and openapi/floor.json
is byte-identical because the operation count did not move.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:48:16 -07:00
hanzo-dev f6defc1d5b openapi: restore the per-product floor ratchet a merge resolution dropped
openapi/floor.json is the ratchet: "a published surface may grow and may not
quietly shrink". It carries a global path and operation count AND a per-PRODUCT
count, and the per-product half is the half that binds — floor.go compares
`f.Products[p]` against the regenerated count and refuses a product that lost
operations. The file's own code states the failure mode out loud: "an absent floor
makes every shrink legal".

The merge 94df22e1 resolved this generated file to
`{"paths":1684,"operations":2336}`: the entire `products` map — 180 entries —
GONE, and both global counts rolled backwards. Every commit before it carries the
map. So on main right now every product may shrink without the gate saying
anything, and the global floor sits five paths and five operations below the
surface actually published.

IT ALREADY COST SOMETHING, within hours. Deleting the /v1/train facade removed ten
operations, and a ten-operation shrink is exactly what this ratchet exists to make
an author state deliberately — the dataset move had to lower `ml: 14 -> 7` in the
same commit that moved the routes, and said so. The train deletion needed no such
line, because there was no per-product floor left to lower. The gate was not
merely stale; it was switched off, and a shrink walked through it unremarked.

Restoring it is `make describe` and nothing else: 1689 paths, 2341 operations, 178
products. `train` is simply absent from the restored map rather than floored at
zero, because the map is regenerated from the document that exists — the ratchet
resumes from the current truth, which is the only honest baseline available once
the old one has been discarded.

Committed on its own because it belongs to no surface change: these counts are
identical with or without the engine renaming beside it, since an operation id
does not move a count.

The lesson is the fleet's own about derived artifacts, with a sharper edge: two
derived files can agree with each other while both are wrong, and a MERGE is a
third way for a derived file to become wrong — resolved by hand toward whichever
side the conflict presented. A ratchet resolved toward the weaker side does not
look stale. It looks fine, and it is off.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:48:07 -07:00
hanzo-dev 8b34a70399 manifest: the live serving prefix has one owner, and that is now a gate
apps/label addressed /v1/ml/labels, apps/reference addressed /v1/ml/reference and
apps/dataset addressed /v1/ml/datasets. All three are the RISK product — the
labels a decision is adjudicated with, the lookup data it consults, and the
snapshot its model was fitted on — and all three were corrected to /v1/risk/*.
The reason is the same each time: openapi.Product takes a product from the FIRST
/v1 segment and a per-op tag cannot override it, so an address IS a published
product membership, and /v1/ml is the live serving product. Filing a second
product there makes /v1/ml/models mean two things at once.

apps/dataset's address_test.go says the third time "stops being a recollection
and becomes this gate" — and builds it, for apps/dataset. A per-app gate cannot
catch the fourth time, because the fourth time happens in a fifth app that does
not have one. So the invariant is stated once, on the side every app must pass
through: the ROUTING GRANT. A plane cannot publish under /v1/ml without a
manifest row saying so, which makes the row the one place the mistake is always
visible.

It refuses a second OWNER, not a second app, and that boundary is measured
rather than assumed: fourteen products here are answered by more than one app and
27 apps publish into more than one product, so neither "one app per product" nor
"one product per app" is a fleet invariant, and asserting either would invent a
rule the fleet does not keep. What all three mistakes actually broke is narrower
and true — the serving prefix has one owner.

The second test is the half the ai incident argues for: a gate that only refuses
intruders stays green when the owner itself vanishes. Narrowing ai's row to
/v1/ai once took the entire inference surface off the wire with every probe
green, so the owner's own leaves are asserted individually — a count cannot say
WHICH address stopped routing.

BOTH DIRECTIONS ARE MUTATION-PROVEN, because a gate nobody made fail is a gate
nobody knows works. Re-addressing dataset to /v1/ml/datasets fails with the
ROUTING GRANT message naming the app, the prefix and the product it would join;
narrowing ml's row to /v1/mlops fails BOTH tests — the non-vacuity check (which
also proves `under` is segment-aware, since /v1/mlops is correctly not under
/v1/ml) and each unrouted leaf by name.

/v1/train is deliberately NOT fenced: it was just deleted because the Kubeflow
CRDs behind it are not served. A gate must fence prefixes that exist, and naming
a deleted one would trip the non-vacuity check while saying nothing true.

It also does not judge the NAME. `ml` being the vague word is why this keeps
happening, but renaming a prefix that is published is a wire change with its own
cost; until that is worth paying the ambiguity is fenced, not resolved.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:47:25 -07:00
hanzo-dev 1b8b76ed26 ml: delete the /v1/train facade — the CRDs behind it are not served
CI/CD / reach (push) Failing after 54s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m10s
CI/CD / image (push) Successful in 19m4s
CI/CD / rollout (push) Successful in 6m20s
Hanzo CI/CD / cicd (push) Successful in 21s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/train/* was a thin proxy onto two Kubeflow CRDs that this cluster does not
serve: trainer.kubeflow.org/trainjobs and kubeflow.org/{experiments,trials}.
GET /v1/train/health answers 503 degraded in production right now
({"crds":{"experiments":false,"trainjobs":false},"status":"degraded"}) with
nothing in cloud changed — the CRDs were retired underneath it. Ten operations
go, and with them the degraded door.

Measured before deleting, cluster-wide: zero TrainJobs, zero Experiments, zero
Trials, and no ClusterTrainingRuntime for a TrainJob to reference. The katib
half could not have worked at all — katib's admission webhook requires the
namespace label katib.kubeflow.org/metrics-collector-injection=enabled,
ensureNamespace writes only {managed-by, hanzo.ai/org}, and no namespace in the
cluster carries it. So POST /v1/train/experiments took the billing gate, created
an Experiment, and katib never admitted a Trial.

There were also TWO doors onto one TrainJob CRD: this one and the hanzoai/ai
broker at /v1/finetune/*, which has the product around it (presets, HF pickers,
status polling, deploy-to-serving). One door survives.

KServe STAYS. /v1/ml/models is the only path in the estate that serves a
classical artifact end to end, and it is proven: POST /v1/ml/models with an
sklearn joblib -> 201, the storage-initializer pulls the model, then POST
/v1/ml/models/{name}/predict -> 200 with correct predictions on the
kserve-mlserver runtime. GET /v1/ml/health is 200 today. Its runtime-capacity
clause and every serving test are untouched.

openapi/floor.json needs NO edit on this base: the shrink guard is a FLOOR, and
main's committed floor (1684 paths / 2336 operations) is already below the
post-deletion document, measured at 1689 / 2341. The guard still bites — raising
the floor above the real count fails TestFleetIsTheWeaveOfItsApps with the same
"THE PUBLISHED SURFACE SHRANK" report, which is how these numbers were read.
(An earlier pass here lowered a floor that ALSO carried a per-product map; main
has since dropped that map, so the rebase takes main's shape unchanged.)

The billing-gate integration tests keep
their coverage by exercising the surviving create (POST /v1/ml/models) — the
gate is the shared create() body, not a per-kind one.

Also repointed every pointer that named the deleted route, so none dangles:
apps/engine's intentRefused reason and LLM.md (now /v1/finetune/jobs), spend.go's
routing-union example, apps/platform/drift.go's analogy, and the mutation in
scripts/mutate.py whose anchor line and target test are both gone (it would have
reported ANCHOR-MISS).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:39:24 -07:00
antje 239b0680f2 iam: read a user the way IAM reads one — owner and name, not a composite id
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
getUser sent the `<owner>/<name>` composite as `id`, and IAM wants the two as
separate fields. Measured against the running service with the console client:

  ?id=hanzo/2d4d67ab-…   400 field "owner" is required
  ?id=hanzo/z            400 field "owner" is required
  ?owner=hanzo&name=z    200

So NO caller of this ever read a user row. The avatar write is where it became
visible — "photo stored but the profile could not be updated", with
`iam non-envelope response (400)` behind it — but moveUserToOrg carries the same
fault silently, and its failure mode is worse: the whole-row re-submit it feeds
would have nothing to re-submit.

`name` is the USERNAME (the row's own `name`, "z"), never the UUID that `sub`
carries — the row this now reads has id 2d4d67ab-… and name z, which is why
addressing it by the uuid found nothing.

The composite is KEPT for the one caller that has no owner to send: a first-run,
org-less user, where resolving their authoritative (owner, name) is the entire
purpose of the read. Refusing that here would break onboarding to fix the avatar.

The fake IAM now insists on the same shape, so a client that regresses to `id`
for a caller that HAS an owner fails in the suite instead of in production.
2026-08-04 00:33:38 -07:00
hanzo-dev e16e60e522 Merge remote-tracking branch 'origin/main' into HEAD
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
# Conflicts:
#	openapi/floor.json

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:30:51 -07:00
hanzo-dev 94df22e154 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	openapi/floor.json

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:30:35 -07:00
hanzo-dev b0b66440a0 Merge remote-tracking branch 'origin/main' into blue/model-value
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:21:29 -07:00
hanzo-dev b6199b0c55 Merge origin/main into blue/model-value
A sibling dissolved PUT /v1/risk/state/appetite while this branch was open (the
decision regime has one address now), so typed.go conflicted on the op that used to
sit between `state` and `snapshot`. Resolved by taking MAIN'S typed.go whole and
re-applying this branch's five changes onto it, rather than by editing the conflict
markers: the incoming change deletes an op, and a hunk-level resolution is how a
deletion gets silently un-deleted.

zipdoc_gen.go is generated — resolved by regenerating from the merged source, never
by merging the artifact. Same for openapi.yaml and plugin/risk/openapi.json.

Verified after the merge: 11 operations before and after, floor.json identical to
main (risk 31, paths 1695, operations 2351), and main's own new gates — verb_test
and learn_cost_test — pass beside this branch's.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:20:31 -07:00
hanzo-dev 019582727d spec: the published document catches up to two money-mint removals it never recorded
CI/CD / containment (push) Successful in 1m7s
Hanzo CI/CD / cicd (push) Successful in 2m6s
CI/CD / gate (push) Successful in 2m7s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
Regenerating every subset from source is the drift gate, and it turns up drift in two
apps beyond the ones whose prose this branch fixes. Both are real, and both are in the
direction of the published document being AHEAD of the code: it advertises money fields
the runtime stopped returning.

7b4ddd9e removed the mint from the referral read and 3a8be85b removed it from the promo
redemption. Both regenerated their zipdoc_gen.go, so the PROSE moved; neither
regenerated plugin/<app>/openapi.json, so the SCHEMAS did not. The result is 15
referral fields and 4 promo fields published as response properties no handler can
populate -- refereeGrantCents, referrerGrantCents, creditsEarnedCents, refereeBonusCents,
referrerBonusCents, grantedCents, creditsCents, the credited counters and the txn ids on
the referral side; creditCents and creditEntryId, plus the plan and seats request fields,
on the promo side. apps/referrals' assertNoMoneyKeys already fails the runtime response
if any of them reappears, so source and test agreed with each other and only the artifact
disagreed. Two fields the code does return -- Redemption.discountCents and
sweepResult.qualified -- were missing for the same reason.

Every SDK, the MCP tool list and the CLI are projections of this file, so those were dead
money fields in every generated client's types.

The operation-count ratchet did not and could not catch it: openapi/floor.json counts
paths, operations and per-product operations, and none of those move when a response
schema loses a field. It ratchets UP here -- billing 26 -> 27, operations 2351 -> 2352 --
for the newly described GET /v1/billing/tier.

Proved structurally rather than by diff, over the parsed documents with description and
summary elided at every depth: across openapi.yaml the only identity change is that one
operation GAINED, no operation was lost, no operationId moved, and all 22 contract
differences are the marketing and referrals schema fields named above. The commerce prose
entry contributes none of them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:20:16 -07:00
hanzo-dev ff386e9c87 spec: three operations that existed and said nothing, and one that said something and had gone
The repo's own gates could not pass on main. Both failures are the same shape --
prose that drifted from the routes it describes -- and both are fixed at the one
place the prose is written.

GET /v1/billing/tier published an operationId and nothing else, so openapi.Complete
refused commerce's document outright and `make -f mk/fleet.mk surface-check` died
there. Its handler is commerce's, in another module, and its path reaches the router
through a table rather than a literal, so there is no doc comment here for zipdoc to
lift -- which is what describe.go exists for, and what every sibling on that same
registration loop already uses. The entry states what the caller gets, that the
subject keys are pinned to the validated caller before the handler runs, that tier
is derived from active and trialing subscriptions, and the two rules a reader
otherwise gets wrong: gate on effectiveAvailable rather than prepaidAvailable,
because granted credits spend too and an account funded only by a grant reads zero
prepaid while holding real spendable credit; and a subscription-store error answers
500 rather than downgrading a paid subscriber to free.

POST /finance/starter was the inverse -- prose describing an operation that no
longer exists. The op, its middleware and its tests went with the automatic
money-mint (41b23f12) and the lift was never rerun, so commerce's registry still
explained a route the router does not carry. Regenerating drops it; nothing here
re-adds it.

POST /sites/resolve and /sites/resolve-org are live typed ops in
apps/projects/sites_rpc.go whose doc comments had never been lifted at all.

Verified per package, the way the generator loads: zipdoc -check is clean across all
100 directories that declare it, where commerce and projects were stale.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:20:16 -07:00
hanzo-dev 89396e35f3 risk: a model is a value, named by its content, and the caller stops carrying it
THE MODEL WAS A PLACE. The `model` table is keyed on the tenant and written ON
CONFLICT DO UPDATE, so it is ONE CELL per organisation and every write destroys
the state before it. That single fact was three separate open problems: there was
nothing to roll back TO, so rollback needed an op that shipped the masses out to
the caller and an op that took them back in; two fitted models could not both be
named, so champion-and-challenger had nowhere to live; and no decision could say
which model produced it.

A content address ends all three. The value's name is a pure function of what
makes two models answer the same event differently — the shape, the geometry seed,
the position in the window, the threshold, the masses as IEEE-754 bits, and the
FOLD WATERMARK, which is not redundant with the mass count: two models with
identical masses reached by different routes disagree about what is left to fold,
and one will re-teach history the other will not.

THE IN-PROCESS MODEL STAYS MUTABLE, and that is the design. Measured: one encoded
value is 466 KiB at the reachable shape — 25 trees of 511 nodes over two windows,
every mass carrying a full mantissa because folding blends them. The sweep writes
every 500 events or every 30 seconds, so "a value per write" is 56 MiB an hour per
active organisation to record a counter going up. So identity is a SUCCESSION OF
STATES: the `model` row stays a place and its whole job is resuming a killed
process, and `published` is append-only, addressed by content, retained under a
budget stated in BYTES (10 values, following policyBudget's reasoning).

WHAT THIS DELETES. riskSnapshotBody is gone from the wire in BOTH directions, with
its two conversions. Publishing answers with a NAME; adopting takes one. The
masses never leave the organisation's own store, so the caller stops being the
custodian of a tenant's model — the engine's own Restore asks for exactly this
("it belongs where the tenant's own data belongs, and sealed if it travels"). Two
tests changed from refusing a threat to proving it unreachable: a caller can no
longer describe a model instead of naming one, so there is no body to compose and
no seed to choose. Rollback is naming a prior address; what the working state
descends from is DERIVED from its mass count, so rolling backward is right for
free where a stored head pointer is exactly what would fall out of step.

A SCORE NOW NAMES THE MODEL SPACE IT RAN IN. The shape is cached on the residency
and read in the same critical section as the verdict, so it costs nothing and
cannot cite a space the score did not run in. It is deliberately NOT an address:
the masses at the instant of a score are counters between two published values, so
citing one would claim that value produced this score. The shape, the policy
version and the event's own time are what IS true, and the value history's clock
brackets the decision from there.

ISOLATION, MEASURED RATHER THAN ASSERTED. The address deliberately omits the
organisation — a name that must be unguessable is obscurity, not isolation. Two
mutations were run and two drafts of the test comment were wrong before they were:
the per-organisation FILE and the `tenant = ?` row predicate are two layers and
EITHER ONE ALONE HOLDS, so a foreign org handed a real address resolves nothing
through all three doors with either layer removed. The single-layer regression is
caught where the file layer is already absent by design — two brands' identically
named organisations share one file — and that test fails on that one mutation
alone. The finding underneath inverts the obvious reading: the per-org file is not
what makes this isolated; the row predicate holds on its own.

11 operations before, 11 after: no path moved and the floor is untouched. The two
paths SHOULD collapse into one (/v1/risk/state/model, POST to publish and PUT to
adopt) — that is the prefix plane's call, not this commit's.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:18:05 -07:00
hanzo-dev 9e5da9e269 merge blue/learn-is-not-a-query: learning is a transformation, a verdict is a query, and learn no longer does both
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m43s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:16:43 -07:00
hanzo-dev 1035d47970 risk: learning is a transformation, a verdict is a query, and learn no longer does both
POST /v1/risk/learn recorded a batch, trained on it AND answered the model's
verdict on every event. Three things under one name: you could not observe
without training, and could not train without being answered.

An observation is a VALUE the plane records; learning is a TRANSFORMATION over
observations; a verdict is a QUERY against the result. POST /v1/risk/score is
already the query and is already pure. So learn drops the verdict and answers
`learned` — how many events the model actually learned from.

WHAT IT COST TO CARRY. plane.learn called the engine twice per event, Inspect for
the response's verdict and Assess for the counters, and both enter the engine's
judge: two projections of the point at three aggregate reads each, and two walks
of the forest. Above the cut both also ran the counterfactual attribution, a
further walk per dimension over nine dimensions. Assess's own return was
discarded, so the attribution was computed twice and thrown away once.

MEASURED, and stated as measured (learn_cost_test.go, BenchmarkLearn):

                  before            after
  batch 8    31.5 µs/event    27.8 µs/event   -12%
  batch 128  20.0 µs/event    17.5 µs/event   -12%
  batch 128   6717 allocs      6169 allocs     -8%

A tenth, not a half: the durable record and the aggregates are the larger part of
what a caller waits for, and the attribution is reached only by the share of the
stream the appetite admits — one per cent by default.

NOTHING DEPENDED ON THE SYNCHRONOUS VERDICT. No CLI command references risk, no
SDK carries a risk client, and the one in-process consumer of a verdict is
cloud.Decide, whose scorer is never installed outside tests (SetRiskScorer has no
non-test caller), so every question it asks answers {allow, scorer-absent}. The
live plane reports one resident model built once. Observe-and-judge in one round
trip is now a COMPOSITION and the published prose says which order: score first,
then learn, so the verdict is the model's opinion of an event it has not yet
learned from.

A DUPLICATE IS NOW WHOLLY INERT. It moved nothing before and was still judged;
now it costs no model work at all, is not counted in `learned`, and is not
metered — the meter runs on what was DONE, which is this app's own stated rule
for the gate/meter pair. The gate still bounds the batch the caller stated,
because how much of it is new is unknowable until the record is written.

TestPlane_TheVerbsStaySeparate (verb_test.go) walks the package and fails if
learn calls Inspect or score calls Assess. It is structural because the braid is
invisible behaviourally: with Inspect put back, every other test in this package
still passes.

zipdoc regenerated; plugin/risk/openapi.json and openapi.yaml rewoven
mechanically (make -f mk/fleet.mk openapi-weave, exit 0). riskScoreOut, riskCause
and riskValue stay in the document — score still answers them; they are simply no
longer reachable from riskLearnOut. Package green under -race.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:15:27 -07:00
hanzo-dev 745e956e7d merge blue/dissolve-state-policy: the decision regime has one address, and a write there answers the policy it wrote
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m14s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
GET /v1/risk/state answered seven kinds of thing at once and PUT
/v1/risk/state/appetite answered all of them back to a call that changes three
numbers. This lands the half whose home already exists: the decision regime is
read and written at ONE address, /v1/risk/policy, and both verbs answer
riskPolicyOut. The published surface loses a path and keeps its operation count.

plane.appetite no longer computes the model value — r.mod.State and r.vel.strain
are gone from the policy write. What the model IS stays for the model-value track;
the telemetry kinds (refusals by reason, blind by feature, the realised share,
saturation, aggregate strain) stay on GET /v1/risk/state until they are emitted on
the fleet's one metric road, because deleting them before that would remove an
organisation's only view of its own refusals.

Per-organisation isolation is proven through the new address by the two policy
tenancy tests. Mutation table in the commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:12:20 -07:00
hanzo-dev 523f98667a risk: the decision regime has one address, and a write there answers the policy it wrote
The regime was written at PUT /v1/risk/state/appetite and read at GET
/v1/risk/policy — two addresses for one plane, the writing one named after the
mutable spot the regime happened to sit in. `state` is literally the word for
that spot; a regime is not a spot. It is three numbers an organisation adopted,
at a time, by a named identity, kept under a version forever after.

Worse than the second address is what the write ANSWERED: riskModelState, fifteen
fields describing the whole MODEL — its shape, what it had learned, whether it
was warm, the threshold in force, the realised share beside the stated one, every
refusal by reason, every feature that read blind, the fold coverage of the event
surface, and the aggregate strain. A call that changes three numbers knows none
of that. It was reporting a model it happened to be holding a lock on, which is
the same braid, one level up, as the regime living on the learned state's row —
the defect the versioned policy record was cut to fix.

So the plane has ONE address and both verbs answer riskPolicyOut. plane.appetite
returns the VERSION it left in force and nothing else: the two calls to
r.mod.State and r.vel.strain are gone from the policy write, so a policy write no
longer computes the model value at all. policyOut is the one projection both verbs
render through, and the version in force is a PARAMETER to it — a write knows what
it enacted from inside the lock it enacted under, and re-reading it there would let
two concurrent restatements each report the other's version.

There is no `minted` flag. plane.enact is idempotent on the regime, so a
restatement answers the version already in force and equality of the VALUE is the
signal. A boolean about the operation is a second thing to keep true beside the
value that already says it.

The published surface SHRINKS by one path and holds its operation count: the write
joined the address that already existed instead of keeping a second one. Mechanically:

  paths      1696 → 1695   (-/v1/risk/state/appetite)
  operations 2351 → 2351   (PUT moved onto /v1/risk/policy)

openapi/floor.json is lowered in this commit because the weave refuses a shrink
otherwise, which is the gate working: a reduction is reviewed next to its reason.
The op's published schema description loses 27 lines of riskModelState /
riskSurface / riskAggregates prose it had no business publishing.

Nothing consumed the old address. Swept every checkout under ~/work: the only
references outside apps/risk's own tests were the derived specs. Live, PUT
/v1/risk/state/appetite is routed and GET /v1/risk/policy is 404 — the policy
plane is merged but not yet deployed — so this is the one moment the consolidation
costs a deployed client nothing.

Per-organisation isolation is untouched and proven through the NEW address:
TestPolicy_HistoryIsPerTenantOverTheWire and
TestPolicy_TwoBrandsShareAFileAndNotAHistory both state their regimes at PUT
/v1/risk/policy and both still see nothing of the other organisation.

Mutation table — each named test RED under the defect, GREEN after revert:

  M1 re-register PUT /v1/risk/state/appetite  TestPolicy_HasOneAddress                          RED
  M2 put a model field back on the answer     TestPolicy_AWriteAnswersThePolicyAndNotTheModel   RED

Verified: go build -tags "sqlite_purego sqlite_math_functions sqlite_fts5" ./... =
0; go vet apps/risk = 0; go test apps/risk = ok; -race = ok (23s); zipdoc -check
./apps/risk/ = 0. Fleet-wide zipdoc -check reports the same 16 stale/missing
generated files before and after this change (measured by stashing it), so this
adds no drift: apps/risk is not among them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:11:46 -07:00
hanzo-dev 92c3372517 iam: identity serves its own routes, so cloud stops proxying them
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/iam/* was forwarded to a separate identity origin by an edge in cloud:
a tenant gate, an org pin, a read/write allowlist and a sign-in passthrough,
all re-deciding what IAM already decides for itself.

It mounted on `!cfg.Enabled("iam") && iamHost() != ""`. An empty enable list
mounts everything (Config.Enabled), and no deployment in the fleet sets one,
so Enabled("iam") is true everywhere and the condition never held: the iam
app owns /v1/iam through its graft, on every deployment, and has since the
graft landed. The edge answered no request anywhere.

Deleting it removes the second reader of the identity address and the second
place a tenant scope is decided. The prefixes are unchanged — manifest's iam
row already routes them — and the org pin the edge applied is IAM's own
authorize, in the process that owns the store.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:11:38 -07:00
hanzo-dev 6c6dc5ee65 stop passing --enable; the binary rejected it and make run died
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
Removing CLOUD_ENABLE/--enable (ecafb31c) deleted the flag from the binary but
left three launchers passing it. Go's default flag.CommandLine is ExitOnError,
so this is not a warning:

  $ ./bin/cloud --enable=iam
  flag provided but not defined: -enable

`make run` and the documented compose quickstart both died before boot, and the
four README brand examples were copy-paste instructions to do the same. The
removal was deliberate and test-enforced (cmd/cloud/mount_test.go asserts the
flag's absence) — the launchers were simply never updated with it. My miss.

RUN_ENABLE is renamed RUN_PLUGINS because it never mounted anything: it is the
list of plugin BINARIES to build so local dev does not build all 106. The host
mounts what manifest.Apps lists and resolves each plugin as a file beside
itself; a lazy one with no binary never starts, a Required one fails loudly.
Keeping the name would have said the binary takes a mount list, which is the
thing that took devnet down twice.

compose.yml's HANZO_ENABLE goes with it. Its environment: block is inert
anyway — HANZO_BRAND/DOMAIN/DATA_DIR have no Go read sites and work only
because the same file passes the flags, which main.go forward() republishes as
CLOUD_*. That is a separate cleanup, not this one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:10:41 -07:00
hanzo-dev 578d7b657c risk: one refusal for an unidentified caller, in the fleet's one envelope
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/risk answered ONE refusal in TWO shapes, with the same status and the same
sentence in both. Measured on this package's priced ops:

  this app's own copy of the rule  403 {"status":403,"error":"no validated principal"}
  the fleet's money door          403 {"error":{"code":"forbidden","message":"no validated principal"}}

The nested one is canonical — it is what the edge gate and every other Hanzo
surface emit, so a client that reads `error.code` reads it everywhere. The flat one
was zip rendering a returned error, which meant `error.code` was absent from
exactly one product's refusals.

[ops.gate] held its own `if ledger == ""` copy of the rule from before the fleet
door had one. [cloud.ResourceMeter.Gate] now refuses an empty org above both of its
branches as [cloud.ErrNoLedger], and [cloud.denial] renders that as the 403 with
this exact sentence, so the copy decided nothing except the shape. Deleted; the
twenty-four lines of prose explaining a defect that is fixed elsewhere are replaced
by a citation of where.

Deleting it exposed a residual the report did not name, because a status assertion
cannot see a shape: POST /v1/risk/search reaches [ops.admit] BEFORE it prices
anything, so its refusal came from [tenantOf] — still flat. Eight ops nested and
one flat is worse than nine flat, so tenantOf's no-principal branch answers with
the same fleet value. A malformed org is a different fact and keeps its own
sentence.

And the hole the deletion could have opened, closed: ResourceMeter.Gate returns
EARLY at zero cost, before its own empty-org refusal, and the price is an operator
knob where 0 is legal. So "the money door refuses an unidentified caller" holds
only while somebody is charged; the app's deleted copy had covered that by
accident, running before the price was computed. tenantOf covers it on purpose, and
a test sets the price to zero and requires the same 403.

  E1  risk holds its own copy again      TestPricedOps_RefuseAnUnidentifiedCallerInTheFleetsOwnEnvelope        RED
  E2  tenantOf answers flat again        TestPricedOps_RefuseAnUnidentifiedCallerInTheFleetsOwnEnvelope        RED
  E3  tenantOf answers flat again        TestPricedOps_RefuseAnUnidentifiedCallerEvenWhenTheOperatorPricesThemAtZero  RED
  E4  the fleet door stops refusing      TestPricedOps_ResolveTheTenantBeforeTheyAskForMoney                   RED
  E5  identity takes a money status      TestPricedOps_ResolveTheTenantBeforeTheyAskForMoney                   RED

All GREEN after revert. E4 and E5 mutate cloud's own money door, which is where
gate_order_test.go's mutation note now points — the note said "delete the
empty-ledger guard from ops.gate and every op reports 503", and that had silently
stopped being true.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 23:59:55 -07:00
hanzo-dev b8103391b4 manifest: nested prefixes resolve by specificity, not by mount order
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The label row said a specific prefix MUST be registered before the bare /v1/risk
or "every label op lands on the decision plane", and cited
TestSpecificPrefixesPrecede as the gate that pinned it. Neither half was true.
No test of that name exists anywhere in the tree, and the rule is not the
router's: registering the bare /v1/risk FIRST and all three specific prefixes
after it, the live router still delivers /v1/risk/labels to label,
/v1/risk/reference to reference and /v1/risk/datasets to dataset, and the router
oracle stays green. zip resolves nested static prefixes by specificity; mount
order decides only between EQUAL patterns, which is what the ai-before-zen note
is actually about.

Says that, and names the gate that does hold —
TestEveryServedPathReachesTheAppThatServesIt builds the real router from these
rows and asks it, per published path, which app receives the request. An oracle
over every row at once is why no row needs a rule of its own to remember.

Prose only; no row moves and no behaviour changes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 23:57:27 -07:00
hanzo-dev caa1a81f07 dataset: the plane publishes under /v1/risk, the product its rows feed
The dataset plane addressed /v1/ml/datasets, beside the KServe model-SERVING
plane's own /v1/ml/health and /v1/ml/models — one prefix, two products.
openapi.Product reads an operation's product off the FIRST /v1 segment and
nothing else, so `ml` published 14 operations that were seven serving ops with
customers on them and seven dataset ops whose rows feed the risk model, which
learns in-process from the org's own events and is never served by KServe.

Moves the five paths to /v1/risk/datasets and renames the seven operation ids
and nine schema names to the risk face, so the SDK method, the CLI command and
the generated type each name the product they belong to. Fourteen of the fifteen
Go types take the bare risk<Noun> name; the dispose pair carries the noun it
disposes of, because apps/label already publishes riskDisposeIn for LABEL
disposal and the fleet's schema namespace is flat.

The tag was never the product: openapi.Fold assigns op.Tags from the router
projection, so zip.WithTags("ml") had been inert. It now reads "risk" too, so a
declaration and a projection do not disagree.

/v1/ml is untouched. Its four serving paths (7300 bytes) and its three schemas
(1533 bytes) are byte-identical, and regenerating the fleet document from source
moves exactly the seven dataset operations and the nine dataset schemas: 2350
operations, 1695 paths and 2060 schemas before and after, with every other
triple unchanged.

No alias and no redirect from the old prefix, because nothing calls it: a sweep
of every git checkout under work/hanzo finds /v1/ml/datasets named only in this
repo's own generated artifacts and prose. No client, SDK, CLI or MCP consumer
names it.

The floor ratchet refuses a shrink, so `ml: 14 -> 7` is lowered here in the same
commit that moves the routes; `risk` rises 23 -> 30 on the same regeneration.

address_test.go makes the address a gate rather than a third recollection after
label and reference: the product, the operation-id prefix and the schema prefix
are each asserted off the published projection, and each assertion fails when
the projection is empty, so none of the three can pass by examining nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 23:57:26 -07:00
hanzoandhanzo-dev d0a7a08db5 ml: the serving probe reports whether there is a runtime to run a model on
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
kserve admits an InferenceService whose model format no ClusterServingRuntime
supports and then never schedules it, so /v1/ml/health answered 200 while every
deploy hung. A served CRD is not capacity, and the probe read only the CRD.

health now takes the cluster-scoped coordinate the plane needs at least one of
(the zero GVR for a plane with no such fact — training carries its own images)
and reports the count as its own field. An unreadable list reports the read
error instead, because a missing grant is a broken probe and not an empty
cluster, and the two call for different acts.

scripts/mutate.py carries four rows: dropping the clause, folding the read error
into the count as zero, asking capacity of training, and reading the runtime kind
at the InferenceService's v1beta1 instead of v1alpha1.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 23:54:56 -07:00
hanzo-dev c7ec4d70f5 risk: the one door the appetite bounds live behind has a test that fails when it opens
CI/CD / containment (push) Successful in 1m34s
CI/CD / gate (push) Successful in 20s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The bounds on `review` and `sample` were spelled twice — once at [ops.appetite]
and once in [admitRegime]. Collapsing them to one spelling is right. But the
SURVIVING spelling had no test: admitRegime was made to `return nil`
unconditionally and the WHOLE package stayed green, so the published contract
(`review ∈ (0, 0.5]`, `sample ∈ [0, 1]`) was held by code that could be deleted
without one failure. Same shape as a control switching itself off, one layer down:
the bound is present and nothing measures it.

Both halves are asserted, because a refusal that half-applies is worse than no
bound: six out-of-contract appetites are refused 400 with the field named, AND the
regime in force is untouched afterwards — no version minted, no history row, the
model still deciding under 0.02/0.10. Over the WIRE, because that is where the
contract is published and where the op's deleted copy used to answer.

  A1  admitRegime returns nil for every regime        named test RED, whole package RED
  A2  plane.enact stops calling admitRegime           named test RED, whole package RED

Both GREEN after revert.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 23:50:07 -07:00
hanzo-dev abc030e2f1 merge blue/risk-policy: the decision regime is durable on its own terms, versioned, and cited by every score
An organisation that took its model out of shadow BEFORE the model had learned
anything was told live=true and had nothing written down. The regime lived on the
same row as the learned state, and that row's writer declines to write while the
snapshot holds no learned mass — correctly, because there is no state to lose. So
PUT /v1/risk/state/appetite answered 200, reported live, and persisted nothing;
this binary deploys Recreate at one replica, so the next rollout rebuilt from
defaultConfig — shadow — and the model decided nothing. No error, no log, nothing
to alert on: a model silently disarmed, on a routed door.

Two conflicts, both resolved by REGENERATING rather than choosing a side:
openapi.yaml and openapi/floor.json are derived, and the branch was cut when the
fleet had 1684 paths. `make -C apps/risk describe` + the weave produce 1696 paths
and risk 24 operations — main's 1695/23 plus this branch's one op, GET
/v1/risk/policy. apps/risk/zipdoc_gen.go and plugin/risk/openapi.json regenerate
byte-identical to the branch's, so the projection did not drift.

One semantic conflict: `plane.close` gained the shutdown window on main
(cda5a031), so the branch's four `close()` calls in policy_test.go take
context.Background() like the other eighteen in the package.

Mutation table re-run against THIS merge, since main moved under the branch —
each named test RED under the defect, GREEN after revert:

  M1 plane.enact writes no durable row     TestPolicy_GoingLiveSurvivesTheRollout                RED
  M2 verdict drops the version citation    TestPolicy_EveryScoreCitesTheRegimeItWasDecidedUnder  RED
  M3 no value-equality short circuit       TestPolicy_ARestatementOfTheSameRegimeMintsNoVersion  RED
  M4 the 24-per-24h bound deleted          TestPolicy_TheRateBoundBindsAndIsNamed                RED
  M5 a pre-existing regime is not adopted  TestPolicy_ARegimePredatingTheRecordIsAdopted         RED
  M6 the history read loses its predicate  TestPolicy_TwoBrandsShareAFileAndNotAHistory          RED
  M7 retention stops disposing             TestPolicy_RetentionIsCountedAndNotSilent             RED

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 23:49:45 -07:00
hanzo-dev 0ce694688d merge blue/risk-core-land: the risk plane's bounds bind on the dimension that costs, and a rollout writes every model down
CI/CD / reach (push) Failing after 58s
CI/CD / gate (push) Successful in 28s
CI/CD / containment (push) Successful in 1m9s
CI/CD / image (push) Successful in 20m56s
CI/CD / rollout (push) Successful in 6m22s
CI/CD / fanout (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 28s
CI/CD / receipt (push) Failing after 2s
Four defects on the LIVE /v1/risk plane, each closed so the wrong thing is
unrepresentable rather than merely unlikely, plus the harness rows that hold
them.

  THE ORG STORE. CloseAll emptied its maps, which made "closed" indistinguishable
  from "nothing opened yet" — so a request still in flight during a rollout
  reached For() after shutdown and opened the file again, re-hydrating it and
  re-claiming the fence lease the SUCCESSOR pod was claiming. Closing is now
  terminal and every door answers ErrStoreClosed.

  THE ROLLOUT. close() was `stop(); wg.Wait()` with every save queued BEHIND it.
  The wait was unbounded inside a 30-second window while ONE search's durable
  Sync is bounded at 30 seconds on its own, so the process was killed with not
  one tenant's model written down — every tenant back to warming, and a warming
  model refuses to score, which reads as clean. The saves now run
  unconditionally; the drain gets what is left of the caller's own window; a
  drain that did not finish is ErrDrainIncomplete and not a silence.

  THE SEARCH BOUND. "ONE RUN PER TENANT" checked the slot and set it two
  warehouse operations later, so sixteen concurrent callers each rolled the
  tenant's source planes and read its whole history before any of them claimed
  anything — and the ledger gate was the LAST thing in the sequence, so all of it
  was free. The slot is claimed atomically with the check, and a search is priced
  in TWO halves, each before the half it prices: the surface on the window the
  caller stated, the grid on the measured history.

  THE STRAIN REPORT. velocity caps PER SHARD, so the store drops a tenant's
  subjects long before the flat census notices: 200 subjects in, the store holds
  198, and the report said forgotten=0, saturated=false. Two of that
  organisation's own subjects read as "has done nothing" and nothing said so.
  reconcile now measures the store/census shortfall as a high-water mark, and the
  report describes the bound that actually binds.

  THE RESIDENT BOUND had no test at all: evict() could be made to return nil —
  the bound fully disarmed, an OOM on a one-replica Recreate deployment — with
  the whole suite green. Its operating point is now four properties held
  together: SERVED past the bound, BOUNDED at it, LOUD on the probe, LOSSLESS on
  the way out.

Mutation-proved: scripts/mutate.py "risk:" — eighteen rows, each reintroducing
one defect and going red on the named test.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:58:24 -07:00
hanzo-dev 837a952805 reference: the per-organisation bound is bytes, and the count is its quotient
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The ceiling was the wrong way round. maxOverrides was a chosen number (10,000)
and the byte figure was computed beside it from `row` — the SUM OF THE WIRE
TERMS, 1,152 bytes. A row does not cost what it carries: measured on a real
per-org store, the widest row this door admits costs 1,952 bytes once the
implicit index over the primary key, page slack and the encryption are counted.
So the published 120 MiB per-organisation ceiling was understating the truth by
1.69x, on the ONE volume every organisation's store shares.

Inverted, so the bound is in the dimension that binds:

  ownBudget  = 128 MiB   the primary figure — what one org may occupy
  rowBytes   = 2048      MEASURED (1,952) plus a page of room
  maxOverrides()         = ownBudget / (sets x rowBytes) = 5,957 per set

The count is now the division rather than a number standing next to one, so
count x rowBytes IS the byte bound. Adding a set lowers the count instead of
quietly multiplying the volume.

`row` is renamed `stated` and keeps its old meaning — the WIRE width — because
the two figures are different quantities and conflating them is what caused
this. TestTheVolumeOneOrgMayOccupyIsStated now checks the quotient (one more
entry per set must not fit) instead of pinning a constant, and
TestOneOrgsOverridesCostWhatTheyArePublishedToCost fills a real store with
worst-case rows and fails if one costs more than the figure the count is divided
from — so rowBytes can never drift from a store again.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:54:47 -07:00
hanzo-dev 748333e665 risk: say what the operating-point test adds, since the pair beside it already exists
The header claimed "nothing held the mechanism at all — the whole suite stayed
green". That was true when it was written and is not true now: hold_test.go
landed the cross-tenant reclaim tests on main, and MEASURED with
scripts/mutate.py, TestEviction_IsCountedOnTheProbe and
TestEviction_WritesTheVictimsStateDownFirst kill all three mutations that disarm
this bound. A test whose stated reason for existing is false is a test the next
reader deletes for the wrong reason, or keeps for one.

What it actually adds is two things the pair cannot see: the bound asserted at
every one of maxResident+8 arrivals rather than once just past it, and a lossless
leg with no t.Skip exit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:53:56 -07:00
hanzo-dev 4fec68d4c7 mutate: register the two halves of a search's price
The surface read — rolling up to four source planes into the organisation's own
feature surface and reading the window back — ran BEFORE ANY GATE AT ALL. A
caller with no balance drove the whole warehouse cost, was refused at the very
end, and paid for none of it, as often as it cared to ask. Two rows: the gate
moved back below the work it prices, and the surface half deleted so only the
grid is paid for. Each goes red on the test that names the property.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:53:56 -07:00
hanzo-dev ca90275df3 mutate: register this cycle's guarded properties, and refuse a corrupted tree
Sixteen rows for the org-store lifecycle, the rollout, the search bound, the
strain report and the resident bound — so every assertion this cycle added is
held by a mutation that breaks the thing it guards, in the harness rather than in
a transcript.

AND THE HARNESS COULD DESTROY ITS OWN EVIDENCE. The restore is in a finally,
which does not run when the process is killed — a CI timeout, a ^C — so an
interrupted run leaves the mutant in the tree and the original in .mutbak. The
next run then copied over that backup, destroying the only clean copy, and
reported ANCHOR-MISS on a tree it had silently corrupted. That happened here and
cost a file. A leftover backup is now a hard refusal that says how to recover.

One row is written against the CONDITION rather than as an early return, because
`return nil` after the guard is unreachable code: go vet rejects it, the row
scores NO-COMPILE, and a mutant that never ran proves nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:53:56 -07:00
hanzo-dev b51bdb0a89 risk: a search pays for the surface it reads, before it reads it
The run slot now bounds one tenant's CONCURRENCY. Nothing bounded its RATE: the
setup ran before any gate at all — the ledger check was the LAST step of begin —
so a caller with no balance drove a roll of up to four source planes and a
full-window read on the one warehouse every tenant shares, was refused at the
end, and paid for none of it, as often as it cared to ask.

Pricing the grid on its upper bound would close the same hole and leave no
viable operating point: the upper bound is maxHistory x the whole grid whatever
that organisation's history holds, so a tenant with two hundred events would be
refused unless it could cover twenty thousand.

So a search is priced TWICE, each half before the half it prices. The surface
from the WINDOW, which the caller states and which is therefore a number before
anything runs — the same unit and the same price ops.features already pays for
the same work, now spelled once in windowScreens. The grid from the MEASURED
history, where it already was. Each meters on what it actually did, so a run
cancelled by a rollout is still billed for the trials that ran.

The plane takes ONE money seam (charge: gate n, get the meter for what was done)
instead of a gate parameter and a book parameter, which is what makes "gate
before, meter after" the same shape at both halves rather than a convention.

And the debit test now waits for BOTH debits. With a synchronous surface debit
landing first, waiting for one was satisfied without the background meter ever
running — the fixture would have made the property it exists for unobservable.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:53:17 -07:00
hanzo-dev 19ed025e11 risk: the strain report describes the bound that actually binds
velocity applies its cap PER SHARD — MaxKeys/shards+1, which is FIVE keys against
a published census ceiling of 320 — and subjects hash unevenly, so a shard fills
long before the total does and the store drops that shard's least-recently-used
key. The census is a flat count and saw none of it. Measured:

  subjects=200   storeKeys=198   censusLen=200   forgotten=0   SATURATED=false
  subjects=320   storeKeys=290   censusLen=320   forgotten=0   SATURATED=true

At 200 subjects two of that organisation's own subjects are gone and the model
reports itself healthy; each one reads as "has done nothing", scores as
unremarkable, and raises nothing. At 320 the state is right by luck and the COUNT
an operator acts on still says zero while thirty subjects are gone. The published
8 MiB budget buys 320 subjects and the bound that binds starts biting near 200 —
a bound stated in one dimension and enforced in another.

reconcile already read the store's own key count and threw the comparison away.
It now measures the shortfall against the census under ONE lock, so the two
numbers describe one moment, and keeps it as a HIGH-WATER MARK: a dropped subject
is re-admitted the moment it is active again, which closes the shortfall but does
not un-blind the window in which that subject read as inactive. A gauge would
report that a control which switched itself off never did.

ALSO: the resident bound had no guard at all. `evict` could be made never to trip
and the whole suite stayed green — the bound fully disarmed, residents growing
without limit, which is 64 x (8 MiB of rings + its model) against a 9 GiB
GOMEMLIMIT on a ONE-replica Recreate deployment. A bigger constant was never the
fix; the operating point is four properties at once, and each is a way to be
wrong: SERVED past the bound (a refusal is a cliff, not an operating point),
BOUNDED at every step, LOUD on the probe (eviction is lossless, so the count is
the only sign), LOSSLESS (otherwise one tenant's arrival costs another its model,
which is the cross-tenant defect wearing a capacity hat).

Mutation-proved: scripts/mutate.py "strain"/"bound" — seven rows. Dropping the
store's half of Saturated reports "the store holds 142 of this organisation's 144
subjects and strain reports saturated=false". Refusing past the bound reports
"organisation 64 of 72 was refused a residency". Dropping the evicted tenant's
save reports "an evicted organisation came back having learned 0 of 120".

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:53:17 -07:00
hanzo-dev cda5a03107 risk: a rollout writes every model down, and the search bound binds on the cost
TWO DEFECTS, both "a bound that is not on the dimension that matters".

THE ROLLOUT LOST EVERY TENANT'S MODEL. close() was `p.stop(); p.wg.Wait()` with
every save BEHIND that wait, and the wait had no bound at all — while Shutdown
was handed the shutdown window as a context and threw it away. The process gets
30 seconds (serve.go) inside a 60-second grace period, and ONE search finishing
after cancellation writes its result to a shelf whose durable Sync is bounded at
durableOpTimeout — thirty seconds, the whole window, on its own. So the wait
outlived the window, the pod was killed, and not one resident model had been
written down. Every tenant returned to warming, from one tenant's search, once
per deploy — and a warming model REFUSES to score, which reads as clean to
anything that does not check the refusal.

The saves now run unconditionally and never behind the drain; background work
gets whatever is left of the caller's own window, bounded by it and by
drainBudget; and a drain that did not finish is ErrDrainIncomplete — a named
state joined into the error, with the count of models written down anyway, rather
than a silence.

THE SEARCH BOUND BOUNDED NOTHING EXPENSIVE. begin() checked "is a search already
running for this organisation?" and set the flag that answers it two warehouse
operations later — check-then-act with the entire cost of a search setup in the
window. Measured: 16 concurrent callers for ONE organisation were all accepted,
all rolled that tenant's source planes, and all read its whole history, against
the one warehouse every tenant shares; 16 background grids then raced for one
shelf row. The slot is now claimed atomically with the check that grants it,
before any of that work, and released on every path that does not reach the run.
claim/settle/unclaim are one fact in one place — the background goroutine's
hand-rolled delete is gone with them.

Mutation-proved: scripts/mutate.py "rollout"/"search" — six rows. The original
close() ordering does not merely fail, it HANGS to the test timeout, which is the
production SIGKILL. Making the saves conditional on a clean drain reports "tenant
A came back having learned 0 of 300". Restoring the check-then-act reports "16 of
16 concurrent searches were accepted".

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:53:17 -07:00
hanzo-dev cf26d92d26 cloud: a closed org store stays closed, so a rollout cannot resurrect one
OrgStore.CloseAll closed every handle and then installed fresh empty maps, which
made "closed" indistinguishable from "nothing opened yet". Every one of the
fifteen callers is a Shutdown path, so a request still in flight during a rollout
reached For() after it and opened the file again — on a durable deployment
re-hydrating it and re-claiming the fence lease the SUCCESSOR pod was claiming at
that moment. Two live writers for one org, through a handle nothing thought was
reachable.

Closing is now terminal: the flag is set before the maps are cleared, so there is
no window in which the store is empty and still openable, and every door that can
open a file answers ErrStoreClosed. An arrival after shutdown is a fact worth
surfacing, so it is an error rather than a silent no-op.

The risk plane reaches this path by construction — its own close() empties the
resident map, so an in-flight score rebuilds a residency and asks for the shelf.

Mutation-proved: scripts/mutate.py "orgstore" — three rows, one per half of the
mechanism (the flag, the For guard, the Each guard). Each goes red on
TestOrgStoreCloseAllIsTerminal at a distinct assertion.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:53:17 -07:00
hanzo-dev 5d87120055 Merge remote-tracking branch 'origin/main' into blue/reference-land
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m14s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:49:34 -07:00
hanzo-dev cddf01f7c5 reference: bound the writer, so the published per-organisation ceiling is one
ownVolume() is what one org may occupy on the volume every org's SQLite file
shares: maxOverrides x len(Catalog()) x row, where row = maxKey + maxNote + 128.
The key and the note were bounded at the wire door. The third term was not: the
writer is actor()'s reading of the X-User-Id the request carries, so it was a
caller-sized value stored on up to 10,000 rows in every set the catalog
publishes. A count over caller-sized values is not a byte bound, and the stated
120 MiB ceiling was a figure nothing held to.

maxActor = 128 (three times the UUID IAM mints), enforced at the store door
rather than the wire op — the row is what the budget is about, so bounding it
where a row is written covers every path that reaches the store and not only the
one a reviewer read. row now DERIVES from it, so raising the bound moves the
ceiling instead of silently detaching it.

Refused, never shortened: the writer is who an adverse action is attributed to,
and a truncated identity names someone who does not exist. errActor separates
the two refusals put() can produce — the row cap is a 409 (the org already holds
what it holds), an over-long writer is a 400 (a value that arrived on this call).

TestTheWriterOnARowIsBoundedLikeEveryOtherTerm rides the wire, because the
defect was in what the wire hands the store: a unit test on put() would have
passed against a handler that never bounded the header. call() is now callAs()
with the default principal, so there is one request builder and not two.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:49:25 -07:00
hanzo-dev 76abf3fbd8 refactor: money is a library, not an app — move out of apps/
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
apps/ is where mounted, billable subsystems live: 116 of its 139 directories
declare `func Mount(`. apps/money declares none, serves zero routes, and is 216
non-test lines that already wrap github.com/hanzoai/money (imported as `hz`) to
pin ONE unit — creditUSD, dollars at 18 decimals. That is a library, and filing
it under apps/ said it was a product.

Pure path move: apps/money -> money. No API change, 44 import lines rewritten,
0 residual references. Full tree builds (`go build -tags sqlite_math_functions
./...` rc=0).

Not deleted and not folded upstream: hanzoai/money is the general library
(multi-currency, rates, min/max/sum) and this is the platform's credit-asset
facade over it. creditUSD is Hanzo-specific and does not belong in a public
money library. One library, one facade, each in the right place.

Unrelated, found while verifying: `go build ./...` fails on
hanzoai/base@v1.5.11/core/sqlite_math_required.go (undefined
cgoBuildNeedsSQLiteMathFunctions) on a CLEAN tree too — it needs
-tags sqlite_math_functions. The default build command does not work in this
repo and that is worth fixing separately.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:46:15 -07:00
hanzo-dev 5a8229c8f9 Merge blue/gate-identity-order: resolve the caller before the money plane is asked to price a spend
Hanzo CI/CD / cicd (push) Successful in 32s
CI/CD / gate (push) Successful in 33s
CI/CD / containment (push) Successful in 1m55s
CI/CD / image (push) Failing after 29m9s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:45:33 -07:00
hanzo-dev 92441c6a98 billing: resolve the caller before the money plane is asked to price a spend
ResourceMeter.Gate is the one pre-create gate all 23 priced surfaces call, and
handed an empty org it asked the money plane to price a spend for a nameless
subject. The money plane then answered a question about IDENTITY in the
vocabulary of MONEY, in two measured shapes:

  co-resident ledger — metering refuses an empty org fail-closed, which is not a
    4xx, so the wire's fallback renders 503 "Billing temporarily unavailable":
    the caller is told the biller is broken.
  peer ledger — gatePeer ships AuthorizeIn{Subject:""}, commerce's own
    validate:"required" rejects it, and because that refusal IS a 4xx the wire
    preserves it verbatim: 400 `field "subject" is required` — a field that
    appears in no published request schema on any of these surfaces, so no
    caller can ever satisfy it.

apps/risk already carried this guard at its own door; the same disagreement is
reachable wherever a handler's tenant check and principal.Ledger differ.
provisioning is the live instance: create() admits an admin with no org, and an
org over MaxOrgLen, through tenant() — and Ledger answers "" for both. Its seven
routes serve on api.hanzo.ai today.

An empty org is now refused as ErrNoLedger before either branch runs, and denial
— the one decision both renderings read — renders it as the tenant gate's own
403 rather than as a fault of the biller. This changes no allow/deny outcome on
a named caller: an empty org already failed on both branches. It does close a
worse case than the wrong status, proven by the mutation below: under fail-OPEN
with the biller down, an unidentified caller was allowed, so the surface was
free rather than gated.

Mutation-proven via scripts/mutate.py:

  gate: ask the money plane to price a spend for a nameless subject   KILLED
  gate: render an identity refusal as a fault of the biller           KILLED
  risk: widen the empty-ledger guard until it refuses every caller    KILLED

Two rows are deliberately absent and say so in place: "guard below fail-open"
is a semantic no-op (fail-open lives inside Authorize/gatePeer, so no placement
in Gate can follow it), and "delete ops.gate's guard" now SURVIVES — with Gate
refusing, the risk-level guard is no longer load-bearing for the status or the
sentence, only for the envelope.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:44:23 -07:00
hanzo-dev 801b402b15 dataset: drop the last stale MCP catalogue
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
plugin/<app>/mcp.json stopped being a generated artifact when the door started
asking each subsystem for its tools at the moment it is asked (package fleet,
mk/plugin.mk describe). The committed copies were the exact hazard that change
removed: plugin/o11y/mcp.json held 12 tools while the o11y binary served 365,
and nothing compared them.

plugin/dataset/mcp.json was the last one left — no go:embed names it, no Go
source opens it, and every remaining mention in the tree is prose recording that
the mechanism was retired. Removing it leaves 0 of 124 app dirs carrying one, so
"the tool catalogue is not an artifact" is a property of the tree rather than a
sentence in a comment.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:41:57 -07:00
hanzo-dev 6e21a92096 a sibling reaches ai over its socket, not through the internet
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The last attempt at this served a plane op from deps.AI, which in the `ai`
process is the HTTP gateway to api.hanzo.ai — so it added a hop instead of
removing one, and was reverted. The premise was right and the mechanism was
wrong. zip states the mechanism itself:

  "Because it is an ordinary route on the app, it rides EVERY transport the
   app Listens on with no extra wiring — ZAP over a unix socket is simply the
   address the caller dialed."

`ai` already answers its whole /v1 surface on $ZIP_RUNTIME_DIR/ai.sock. Nothing
in hanzoai/ai has to change and no second inference API has to exist: a sibling
speaks the SAME OpenAI-compatible wire to the SAME routes, over the socket.

  before  sibling --HTTPS--> Cloudflare --> ingress --> api.hanzo.ai --> ai
  after   sibling --unix----> ai

AIHTTPOn / AIHTTPM2MOn state the TRANSPORT separately from the address, so
there is still ONE inference client; only the route it travels differs. The
M2M token exchange keeps the default transport — IAM is a different peer and
naming it is a separate question, still open.

aiRoute is the one decision both pickers share, so completions and embeddings
cannot disagree about where the peer is. It is answered by WHAT THE PROCESS IS:
!Enabled(ai) means this process does not carry the app, which is exactly when
`ai` is a sibling. The ai process and the host keep the configured address —
routing inference back through the picker there would be a self-call, and
TestTheAIProcessDoesNotDialItself pins it.

The socket transport WAKES the peer through the same reach() every plane call
uses, so a cold lazy app is "not started yet" rather than "not deployed here".

CLOUD_AI_ZAP_ADDR is deleted with its field: it made a deployment state where
its own code lives, which is the thing being removed. CLOUD_AI_BASE_URL now
only describes inference that is genuinely elsewhere — and on the Hanzo
deployment it is no longer consulted, because that pod carries `ai`.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:38:07 -07:00
hanzo-dev 53ea52d795 reference: the projection and the document say the address the router serves
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The app was renamed to /v1/risk/reference — openapi.Product reads the product
off the first /v1 segment, and these six operations are the risk product's —
but the committed projection and the woven document were generated before the
rename and still published /v1/ml/reference. manifest's
TestEveryServedPathReachesTheAppThatServesIt is the gate that caught it: four
paths the fleet published and routed to the /v1 catch-all instead.

Regenerated plugin/reference/openapi.json from the app's own live router and
re-wove openapi.yaml from the subsets. The delta is exactly the four reference
paths; no neighbour moved.

plugin/reference/mcp.json goes with it. The tool catalogue stopped being a
generated artifact when the door started asking each subsystem for its tools at
the moment it is asked (package fleet) — 122 of 124 apps ship no such file, and
a stale one beside a regenerated projection is a second answer to a question
that has one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:30:43 -07:00
hanzo-dev b98ac4f877 Merge branch 'blue/reference' into blue/reference-land
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:26:31 -07:00
zeekayandhanzo-dev 8e8b8c8826 test(kms): prove a pasted provider key seals and resolves through the SAME door
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The coordinates are the part worth proving, and ai's own tests cannot reach them.
ai declares the store structurally (object.SecretStore) precisely so it needs no
KMS dependency — which means its tests can only use a fake, and a fake proves the
logic while proving nothing about where the bytes land.

A bare ref resolves to path "/", which fileOrg treats as the FACADE and lands in
the deployment's system partition. The REST surface folds the caller's org and
lands the same name at /orgs/{org}. Those are different databases. Write through
one door and read through the other and the secret is simply not there: no error,
no warning, just a key the gateway cannot find. This asserts the admin seal and
the completion-path resolve use one door.

Second test pins the migration's direction: env serves until a key moves into KMS,
and KMS wins after. Without it "migrate the key" could be a no-op that looks
complete.

Verified on Linux, where libsqlcipher is linked and the store actually opens.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:25:26 -07:00
hanzo-dev aab1c7607f Revert "reach ai by name" — it added a hop instead of removing one
Hanzo CI/CD / cicd (push) Successful in 21s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m19s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The commit claimed a sibling would stop reaching `ai` through Cloudflare. It
does not. In the `ai` process cfg.Enabled("ai") is true, so deps.AI is the HTTP
gateway pointed at https://api.hanzo.ai/v1 — the pod's own public address — and
the plane handler I added served inference THROUGH it. The path became

  sibling --UDS--> ai process --HTTPS--> api.hanzo.ai --> airouters

where before it was one HTTPS call. Strictly worse, and the opposite of what
the message said.

The premise was right and the wiring was wrong: `ai` exposes its inference as
HTTP ROUTES (airouters), with no in-process ChatCompletion to call, so serving
the plane op from deps.AI just re-entered the transport. Closing this for real
means an in-process entry point in hanzoai/ai that the plane op can call
without a socket — a change in that module, not a rewiring in this one.

Reverted whole rather than patched so main does not carry a half-measure that
reads, from the commit log, as if the round trip were gone.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 22:22:23 -07:00
hanzo-dev 30fdc23528 Merge remote-tracking branch 'origin/main' into blue/reference
# Conflicts:
#	manifest/order_test.go
#	openapi/floor.json

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:37:54 -07:00
hanzo-dev 93a578e4ab reach ai by name, so a pod stops calling itself through Cloudflare
Hanzo CI/CD / cicd (push) Successful in 1m36s
CI/CD / gate (push) Successful in 1m36s
CI/CD / containment (push) Successful in 1m52s
CI/CD / image (push) Successful in 17m54s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
`ai` is a plugin of this same binary running as its own process, and the only
way a sibling could get a completion was its PUBLIC address. So the pod left
through Cloudflare, performed a client_credentials exchange to authenticate to
its own deployment, and came back — to reach code one socket away. Its own
startup line said so:

  deps.AI (completions) -> HTTP gateway (IAM M2M)
    base_url  https://api.hanzo.ai/v1     <- the pod's OWN public address
    token_url http://iam.hanzo.svc/...    <- a token to call itself

Every part of that was a consequence of addressing a peer by URL. plane.AIChat
and plane.AIEmbed are addressed by APP NAME — zip.SocketPath resolves it and
reach() starts the app if it is not listening — which is how the meter already
debits commerce and how the gate already reads the ledger. There is no address
to configure, no credential to mint, and no second answer to where `ai` is.

Which client a process gets is decided by WHAT IT IS, not by an env:
!cfg.Enabled("ai") means this process does not carry the app, which is exactly
when `ai` is a sibling. The process that IS `ai` (and the host, which carries
everything) falls through to the real transport — asking the plane there would
be this process calling itself, and the test pins that.

CLOUD_AI_ZAP_ADDR is deleted with its field: it was the previous attempt at
this, and it still made a deployment state where its own code lives. The
gateway stays for inference that is genuinely REMOTE — a different fact, not a
second way to reach the same thing.

Billing is deliberately not on the servant side: the caller reserves before it
asks and settles on the reported usage, so pricing it again there would bill
one completion twice. MaxTokens rides the request so the ceiling the caller
reserved against is the ceiling the servant honors.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:37:38 -07:00
hanzo-dev 93fde354e0 Merge remote-tracking branch 'origin/main' into blue/label
Hanzo CI/CD / cicd (push) Successful in 26s
CI/CD / gate (push) Successful in 26s
CI/CD / containment (push) Successful in 1m24s
CI/CD / image (push) Failing after 26m44s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:35:51 -07:00
antje 9cdfb9d8c4 avatar: address the user the way IAM's user ops can resolve
CI/CD / rollout (push) Successful in 6m19s
CI/CD / gate (push) Successful in 21s
Hanzo CI/CD / cicd (push) Successful in 21s
CI/CD / containment (push) Successful in 1m16s
CI/CD / image (push) Successful in 20m19s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The upload reached S3 and the profile did not move: 502 "photo stored but the
profile could not be updated", and the log said why — `iam non-envelope
response (400)` for id hanzo/2d4d67ab-….

IAM parses a user id as `<owner>/<name>` through GetOwnerAndNameFromId, and on
the direct-Bearer path X-User-Id is a UUID subject, so `<owner>/<uuid>` names no
user. keyID() is the composite that resolves — the key ops (mint/revoke) already
use it for exactly this reason, and the comment explaining it sits in the file I
edited. On the gateway path username == name, so the two are the same value and
nothing changes there.

Found by uploading a real PNG to production rather than by reading the route
back. The honest failure is what made it findable in one step: the handler
reported that the bytes had landed and the record had not, instead of a 500 or a
success the user could not see.
2026-08-03 21:35:03 -07:00
hanzo-dev 8ffa9cfd2a label: one mint for the tenant key, and it is apps/tenant
The branch carried a second one: a root `cloud.Qualify` returning `type Tenant
string`. Two spellings of the key the dataset plane writes, the risk plane reads
and this plane joins on — and the two did not agree.

  It did not canonicalise the brand. A deployment started with CLOUD_BRAND=Hanzo
  filed `Hanzo/acme` while apps/dataset asked for `hanzo/acme`. Silent,
  permanent, no error: one business, two key spaces.

  It did not ask the registry. A brand nothing vouches for minted a key the
  writers of these surfaces refuse to produce.

  `Tenant` is a STRING type, so it can be written as a literal in any package and
  decoded straight out of a request body. `tenant.Key` is a struct with one
  unexported field: no literal, no JSON, no caller-asserted tenancy. Only
  tenant.Mint and tenant.Of produce one.

  tenant.Of additionally compares the token's verified issuer brand against the
  deployment's — a token minted by lux.id cannot become `hanzo/acme`.

So the root file goes and apps/label takes tenant.Key/tenant.Of throughout. The
door shrinks: tenantOf no longer re-checks the shape it just minted, because the
mint is the check.

Mutation-tested at the ONE place the property now lives: drop the case fold in
tenant.canon and TestTheMintAgreesWithTheWriterOfTheSourceTable reports
`Mint("Hanzo","acme") = "Hanzo/acme"` against a writer that writes `hanzo/acme`,
RED; restored, green. 67 label tests pass on the ported key.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:34:09 -07:00
hanzo-dev 0077aeb4d9 Merge remote-tracking branch 'origin/money/no-automatic-issuance' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:33:11 -07:00
hanzo-dev 22f215dd89 Merge remote-tracking branch 'origin/main' into blue/label
# Conflicts:
#	openapi/floor.json

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:30:15 -07:00
hanzo-dev 2322f9123e risk: resolve the tenant before the money plane is asked to price a spend
CI/CD / containment (push) Successful in 1m37s
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Eight of the ten declared /v1/risk paths answered a 400 that named a field the
caller cannot send. Measured on api.hanzo.ai with each operation's own declared
body: score, learn, features, state, state/appetite, state/snapshot,
state/restore and search/{id} all returned

    {"error":{"code":"","message":"field \"subject\" is required"}}

`subject` is plane.AuthorizeIn's field, not any risk operation's. It appears in
no published request schema on this surface, so no caller could ever satisfy it
— a door that answers and cannot be opened.

ops.gate re-derived the caller's ledger with principal.Ledger, which answers ""
for exactly the requests the tenant gate refuses (it composes the same Validated
check), and then handed that empty ledger to the money plane. Asked to price a
spend for a nameless subject, the money plane answered in the vocabulary of
money about a question of identity, in two shapes:

  co-resident ledger — metering refuses an empty org fail-closed, which is not a
    4xx, so the money wire's fallback renders 503 "Billing temporarily
    unavailable": the caller is told the biller is broken.
  peer ledger (what deploys) — the gate ships AuthorizeIn{Subject:""} over the
    internal plane, commerce's own `validate:"required"` rejects it, and because
    that refusal IS a 4xx the money wire preserves it verbatim as the 400 above.

ops.search never had the defect, for the one reason that it reaches ops.admit
before it prices anything. This makes that ordering general: an empty ledger is
an identity refusal, answered with the tenant gate's own sentence from the one
function that owns it, before the money plane is asked anything.

The route itself was never missing. GET /v1/risk/score is a 405 here — the route
is declared, the verb is not — and the report of a 404 traces to the deployed
edge flattening that to plain-text "not found", byte-identical to an unregistered
path. Named in the report; not reachable from this suite.

TestTypedOpsRefuseAnUnvalidatedPrincipal asserted this exact property and passed
throughout, because it mounts deps with no metering client: with no client the
money gate is a no-op and the op fell through to the honest 403 the test wanted.
The new tests mount through mountBilled, the only fixture in which the ordering
is observable. A companion assertion counting calls to the ledger fake was
written, found unfalsifiable — an empty org is refused inside AuthorizeVerdict
before any HTTP request, so the counter reads zero either way — and deleted
rather than shipped; the reason is recorded where it would have gone.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:29:32 -07:00
hanzo-dev ecd5bdd874 dataset: regenerate the subset and the fleet spec for the stated degradation
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
`oversize` is on the wire, so it is in the document: the app's own subset, the
prose map and the woven openapi.yaml every SDK repo pulls. Generated by
`make -C apps/dataset describe` + `make -f mk/fleet.mk openapi-weave OUT=openapi.yaml`
— no hand edit. No new operation, so the floor is unmoved.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:28:37 -07:00
hanzo-dev 63962d79d6 dataset: bound the bytes, not the count, and say what the bound excluded
A COUNT OVER CALLER-SIZED VALUES IS NOT A BOUND. maxRows capped how many rows a
materialisation holds and `page` capped how many an export returns, and neither
bounded a single byte. A row's coordinates are float64s this plane counts, but its
SUBJECT is a string this plane does not write: the rollup lifts it from
`distinct_id`, `session_id` and `user_id`, which arrive on /v1/event from the
caller. distinct_id is capped at 256 bytes on the anonymous lane and REPLACED by
the token's own subject on the signed one — but session_id, which the `session`
rollup files as a subject verbatim, is capped nowhere. So "200k rows of ten
float64s plus a subject key is tens of megabytes" and "eight jobs is a few hundred
megabytes" were arithmetic over an unknown, and one tenant's traffic decided the
real number for the whole process.

maxSubjectBytes bounds the one caller-sized value a row carries, at 256 — what the
identified lane already states for a subject, for the reason that carries over
unchanged: a minted id is a uuid, the value is KEYED, and something longer is not
an id. With it, count times max IS the byte bound, and every byte figure the
package states is now DERIVED from it rather than written down beside it:
maxRowBytes, maxResidentBytes, maxProcessBytes, maxPageBytes. The stale prose
claims are gone rather than corrected — that was the other spelling.

ONE ENFORCEMENT POINT. `representable` is the predicate and the only place it is
written. It is applied on the way OUT of the source, which is the way IN to this
process and to the rows table, so no read path needs a second check: an export
page is bounded because every row it can return already came through it.

AND THE DEGRADATION IS NAMED. A bound that quietly drops rows is worse than no
bound — the dataset that comes back looks complete, and a model fitted on it is
blind to a population nobody can see was missing. The census measures both halves
on ONE pass (conditional aggregates, not a filter), the excluded subject count
rides on the version, the manifest, the lineage and the wire, and it is in the
source fingerprint, so `reproducible` is measured over it too: a window that grew
a subject too large to carry is a window that MOVED, and lineage now says so.

census and facts cannot disagree about the population, because the predicate is
one expression used by both — measured against, then read with. Two spellings
would sample the share from rows the read never returned.

Three gates, each mutation-tested (defect reintroduced, named test RED, reverted,
green):

  TestEveryReadOfTheSourceIsBoundedInBytes         the package's own AST — a
    function that reads the source without the bound fails, the same shape as the
    admission gate beside it, because it is the same failure: a new op skipping a
    property nobody checks. Carries the same anti-vacuity floor.
  TestAnUnrepresentableSubjectIsExcludedAndCounted end to end: the bound binds,
    the representable rows all survive, the count is on the version AND the
    lineage, and it is falsifiable.
  TestTheByteBoundIsDerivedFromTheValueBound       the arithmetic, so no byte
    figure can be asserted independently again.

The fake store EVALUATES the bound rather than ignoring it — a fixture that
ignored it would let every test above pass with the predicate deleted. It also now
binds `?` positionally across the WHOLE statement, as a driver does, which is what
the census's conditional aggregates require.

TestTheTenantLeadsEveryPredicate was reading args[0] and calling it the tenant.
That was a positional coincidence, true only while no statement carried
placeholders before its WHERE; it now reads the argument bound to the leading
`org = ?` itself, which is the property it always meant.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit b851446a2a)
2026-08-03 21:26:31 -07:00
hanzo-dev ecafb31c50 the app set is a property of the binary, not of a values file
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CLOUD_ENABLE / --enable named the subsystems to mount. The binary already
knows: manifest.Apps is the host's set, and a plugin IS its app (Listen sets
cfg.Enable from what plugin/<app>/main.go was built as — that stays; it is
the binary stating itself, not a deployment restating it).

A second source of truth can only add disagreement, and it did. Both devnet
outages on 2026-08-02 were this list: one named "plans", an app the manifest
does not have, and one omitted "kms", the credential broker every other child
pulls its data-plane key from — so every child failed closed at its first
store open. Production has never set it.

Removing the input removes the failure class and five branches with it: the
broker precondition, the unknown-name guard, and three `on != nil` selections
that only existed to police a list nobody should have been writing. Empty
already meant all, which is what production runs.

Values move in the same change — devnet and testnet drop the list, so no
deployment is left naming a variable the binary no longer reads. Docs and
cloud-probe.sh follow; the probe had been STRIPPING the variable, so it
already agreed.

TestTheAppSetIsNotNamedTwice replaces TestAnAllowlistWithoutTheBrokerIsRefused
— that test policed the list, and the guard is now that no source states the
set again.

Also fixes a pre-existing red on main, unrelated to this: apps/catalog's
cloud.Request call site was never added to allowedRequestUses, so the escape
hatch ratchet failed on clean origin/main. It is a legitimate use (the
published corpus reads as PublicOrg for everyone, so the tenant is re-pointed
while the caller's authority travels whole) and is now recorded with that
reason.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:24:43 -07:00
hanzo-dev 077fce140b money: credit is issued by a human, so every automatic path goes
An hourly goroutine in authors deposited credit with nobody in the loop, and a
GET on three surfaces accrued-and-paid on read. Both are gone, along with the
capability that made them one line each.

  - apps/authors/scheduler.go, sweepAndPayout, autoPayoutAuthor — the unattended
    hourly accrue+pay loop, default ON, no env var, no route, no human.
  - the lazy sweep on GET /v1/authors, /v1/affiliates, /v1/affiliates/me and
    /me/earnings. Reads read; the admin POST sweep still accrues.
  - payout settlement in both programs. A payout RECORDS what is owed, for every
    method including credits; a human settles it. Accrual — the product — stays.
  - treasury.Reserve/Credit returned backed=true when unmounted, and `mounted` is
    a package global, so in one-binary-per-app it was ALWAYS nil in callers: every
    "reserve-backed" payout was an unbacked mint that logged itself as reserved.
    With settlement gone it has no callers, so it is deleted rather than fixed.
  - POST /v1/admin/credits — a second admin mint with no cap and no positivity
    check, whose audit did not fail closed. core.ApplyGrant is the one door: it
    caps, rejects non-positive amounts, checks the org, and refuses without a
    durable audit store. The relay and its wire client are deleted.
  - payout.Client.Deposit, the ONE money-in primitive all three programs shared,
    and the deposit method on each program's seam. The seams now carry a single
    read, matching referrals: reviving a mint has to start by re-declaring the
    capability, in front of a test that says no.
  - the published POST /finance/starter op, advertising a grant deleted in
    41b23f12.

openapi.yaml, plugin/admin/openapi.json and openapi/floor.json drop the deleted
route in this commit, so the reduction is reviewed next to its reason.

Tests assert the guarantee rather than the old behaviour: a GET grants nothing
and the ledger receives zero deposits, proven at the wire against a commerce stub
that fails on any write.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:24:40 -07:00
hanzo-dev 0a6e71539a o11y: route the websocket the document already publishes
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/ws/query_progress is in openapi.yaml and in plugin/o11y/openapi.json, so it is a
method in every generated SDK — and no manifest row named it, so the composed
binary routed it nowhere. Its own prose says the address "was unreachable from the
composed binary until the route table named it, because the old wildcard covered
only the o11y prefix"; the wildcard went and the row never grew the prefix back.

TestEveryServedPathReachesTheAppThatServesIt has been RED on main for this one
path. It reads 1673-of-1684 now against 1672-of-1684 before, and the recorded
ledger is unchanged — nothing else changed hands.

Mutation-tested: prefix removed, the named test reports UNREACHABLE and fails;
restored, green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 21:23:45 -07:00
antje b374238204 account: name /v1/avatar in the manifest, or the host never routes it
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m50s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Both avatar routes answered 404 in production. The routes were registered and
the handlers were fine; the HOST routes by manifest prefix, and account's row
did not name /v1/avatar — so `ai`, which owns the /v1 remainder, won it.

manifest's own router test said so in as many words on the tree that shipped:

  UNREACHABLE: account /v1/avatar -> ai
  UNREACHABLE: account /v1/avatar/{org}/{user}/{digest} -> ai

I did not run it. Targeted package tests passed and I shipped on those, which
is how a route can be complete, tested, deployed and unreachable at once.

With the prefix named, both paths mount to account (zip mounting … addr=account)
and the ledger drops from three unreachable paths to one — o11y
/ws/query_progress, which is not this change's and stays as it was.
2026-08-03 21:06:39 -07:00
hanzo-dev 323a68367d one reader for the IAM address, and the peer beats the public URL
Hanzo CI/CD / cicd (push) Successful in 21s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m46s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
iamurl.go opens by saying the IAM-address policy "existed as three inlined
copies ... so there is exactly one now". There were four, and they had grown
three different fallbacks:

  iamurl.go            IAM_URL, else the public issuer   (the policy)
  auth_apikey.go       IAM_URL, else IAM_INTERNAL_URL, else NOTHING
  apps/account/iam.go  IAM_URL, else a hardcoded cluster address
  apps/platform        re-read IAM_URL to answer a different question

A copy of a policy does not disagree until one is edited. These had already
diverged: auth_apikey read a fifth env name no cloud deployment sets
(IAM_INTERNAL_URL is on admin-guard and chat only), so a single-process deploy
that knew only its issuer resolved "" and API-key auth stayed silently
unconfigured; apps/account reached a cluster address that a non-cluster deploy
does not have.

iamurl.go now answers both questions the estate actually asks, over ONE env
read: IAMBase() for the address, IAMExternal() for whether a separate IAM is
named. They are separate because conflating them IS the bug — a deployment
with only a public issuer resolves a real address while naming no external
IAM, and a caller inferring one from the other reaches for a store that is
not there. TestIAMAddressHasOneReader walks the tree and fails on a second
reader, so this cannot drift back.

Also: pickCompletionsClient preferred the PUBLIC gateway over the in-cluster
peer. `ai` is a plugin of this same binary running as its own process, so that
ordering sent the pod out through Cloudflare and back, minting an OAuth token
to authenticate to its own deployment, to reach code one socket away. The peer
is now first. Ordered, not gated: nothing sets CLOUD_AI_ZAP_ADDR today, so
this is inert until an address is named and the gateway keeps answering.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 20:53:13 -07:00
hanzo-dev ccdf001ea0 Merge remote-tracking branch 'origin/main' into HEAD
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m39s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
# Conflicts:
#	apps/referrals/commerce.go
#	apps/referrals/referrals.go
#	apps/referrals/referrals_test.go

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 20:13:32 -07:00
zeekayandhanzo-dev f62a85264d ai v1.832.19 — off the retracted .18, and give the referral payout its ref
main was pinned to hanzoai/ai v1.832.18, which is RETRACTED. That version seals
the secret MASK as a provider key: the admin API returns "***" for a stored
secret, so saving a provider form without touching the key field seals the literal
"***" into KMS under the provider's own name — and because ai resolves KMS-first,
the store then answers "***" for every read, outranking the env var that was
serving the real key. The provider stops authenticating while its row still looks
correct. v1.832.19 carries the guard.

.19 also brings: secrets resolved from the EMBEDDED in-process KMS (this binary's
own apps/kms) instead of over HTTP to the standalone deployment, which had never
worked — 404 on the path it used, 401 on the correct one; /v1/provider-flags
renamed to /v1/models/providers and derived from the served catalog, so the
provider set and the model list cannot disagree; and a model family is now
controlled from admin.hanzo.ai rather than only from deployment env.

apps/referrals did not compile on main: payout.Deposit gained a required `ref`
(commerce guards on it so a retried payout credits AT MOST ONCE) and this caller
was not updated with it. A referral pays TWO wallets, so the referral id alone
would name both and commerce would dedupe the second against the first — the
referee's bonus would silently never land. bonusRef(id, side) makes each credit
its own event while staying stable across retries.

The published spec is regenerated: /v1/provider-flags is gone from openapi.yaml
and plugin/ai/openapi.json (the .18 pin never regenerated it, so the drift gate
was red), and the retired provider-flags product is dropped from the floor.

Verified on Linux (macOS has no tmpfs for the pure-Go SQLCipher codec, so the
store-backed suites cannot run there): apps/referrals, apps/ai, apps/kms and
openapi all green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 20:03:31 -07:00
hanzo-dev 02c40e04b4 Merge remote-tracking branch 'origin/main' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 20:03:28 -07:00
antje 94392ff908 catalog: state the tenant on a context zip will actually read
CI/CD / image (push) Failing after 6m42s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m48s
The plane fix shipped and /v1/catalog answered 500 "index: no org on the call"
— the public browse reaching the index as nobody.

cloud.For() states a caller, but zip's forwardIdentity prefers an INBOUND
request over a stated one ("an inbound request always wins over what it said"),
and a typed handler's ctx carries the in-flight request. So For() was silently
overridden and the call went out as whoever asked. For a signed-out visitor
that is nobody, and the index refuses a call with no org — correctly.

cloud.As() is the form for this: it re-points the tenant on a context with no
request behind it, which is the one place zip reads what we stated. The caller's
authority still travels whole; only the tenant moves — which is the point, since
the published corpus is read as PublicOrg by everyone, signed in or not.

Caught by asking production rather than by trusting the deploy: the previous
commit was verified live and answered 500, not rows.
2026-08-03 20:00:40 -07:00
hanzo-dev a1509a8fb9 Merge remote-tracking branch 'origin/main' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:55:13 -07:00
hanzo-dev b6ec37f001 name the payout a credit pays out, so a retry cannot fund it twice
CI/CD / image (push) Failing after 7m50s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 21s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m47s
payout.Deposit sent no idempotency key at all, so every retry of an author
royalty or affiliate commission credited the wallet again. Commerce now
requires the key (it is the reference to the money event), and both callers
already hold the right value: the payout row's own id.

  - Deposit takes ref and sends it as X-Idempotency-Key, via a post helper
    kept beside do because only a WRITE carries a reference — a read has no
    event to name.
  - ErrNoRef refuses an unnamed deposit HERE rather than at commerce, so the
    failure names the caller's missing value instead of arriving as a status,
    and a new payout path cannot quietly ship without one.
  - authors and affiliates pass "payout:"+payoutID through their commerce
    seams.

Tests: an unnamed payout never reaches commerce (the fake server records
that it was not called), and the ref travels as the key commerce guards on.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:50:11 -07:00
hanzo-dev 7b4ddd9e2b money: a referral is attribution, so the mint on the read goes
GET /v1/referrals ran a "lazy qualify sweep" before listing, and that sweep
reached commerce.Deposit. Loading your own referrals page minted platform
credit — $10 to you, $5 to your referee — on a read. The middleware in front of
the prefix gated writes by asking `method != POST`, so it waved the GET through;
the two safeties behind it did not hold either. treasury.Reserve returns
backed=true when treasury is unmounted, so the "backed by the reserve fund"
claim was a passthrough in any deploy without it, and the at-most-once latch
bounds the mint per referral, not in total.

The precedent is set twice on main: 41b23f12 deleted the $5 starter grant and
45b3b5cf deleted finance.Deposit along with its imports. Same here. The deposit
path is DELETED, not disabled — a flag-disabled money mint is one flag from an
enabled one.

What goes: the two bonus constants, the ledger currency + grant:referral tag,
grant(), the treasury reservation and its import, LatchCredit, SetTxns, the
grant/txn/credited_at columns, the `credited` status, and every cents field on
the wire. A field that can only ever report zero is a lie about what the surface
does, so creditsEarnedCents and the two bonus amounts go rather than freeze at 0.
The commerce seam keeps ONE method, spendCents — it is a question, not an
instruction — and TestCommerceSeamIsReadOnly fails if it grows a write.

What stays, because it is the actual product: who referred whom, the stable
code, the share link, and qualification. Qualification is a WRITE, so it now
happens only on POST /v1/admin/referrals/sweep. A GET reports; it does not
transition.

The gate is fixed at the defect class, not the instance: it waves through GET
and HEAD by name and requires an org for every other verb, including ones this
package does not serve. "Not POST" meaning "harmless" is the reasoning that let
a read reach a deposit.

What a qualified referral is WORTH is not this package's question. That is an
affiliate payable in hanzoai/commerce, settled by wire or to a connected wallet
— never minted as platform credit.

Tests: a GET in the exact state that used to pay leaves the row byte-identical
and touches the money plane zero times; the real payout client against a stub
commerce that fails on any write proves zero deposits at the wire.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:42:48 -07:00
hanzo-dev 45b3b5cfde Merge remote-tracking branch 'origin/main' into HEAD
Hanzo CI/CD / cicd (push) Successful in 27s
CI/CD / gate (push) Successful in 27s
CI/CD / containment (push) Successful in 1m46s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:21:00 -07:00
hanzo-dev 637e151a9c take the completion ceiling from the model catalog, not a constant
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The reservation landed with a 32768 constant standing in for "the most a
completion can be". That number is wrong per model by construction: it caps a
1M-context model at whatever was typed, and it is one release out of date the
moment a model ships. This estate has already paid for that shape twice —
ai/model's name-matching table gave deepseek-v4-pro 16384 and 402'd every long
prompt, and glm-5.2 dead-ended /compact on a stale 16K fallback. Both were
fixed by moving the number into models.yaml. This does the same for billing.

  - completionCeiling(model) resolves through SetCompletionCeiling, installed
    in apps/ai from ModelConfig.MaxOutput (ai v1.832.18), falling back to the
    model's context window — still a true bound, since prompt + completion can
    never exceed it. The constant survives only as a FLOOR for a model the
    catalog does not declare, and is documented as never a per-model answer.
  - the seam exists because hanzoai/ai/controllers imports hanzoai/cloud, so
    the catalog is a CYCLE from cloud's root, not merely weight. apps/ai links
    both, which is where every other cross-module hook is installed.
  - atMost no longer writes the ceiling onto the request. What we reserve is a
    billing fact; req.MaxTokens is the CALLER's, and forwarding a limit they
    never asked for silently truncates their answer. The reservation is sound
    regardless — a model cannot exceed its own max output — so the bound holds
    whether or not we restate it on the wire. Only a caller-set MaxTokens now
    reaches the provider.

TestCeilingComesFromTheModelNotAConstant pins all four: a 1M model reserves
>=1M, an undeclared model takes the floor, a caller's MaxTokens wins, and
atMost never mutates it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:20:50 -07:00
antje 85128ed712 catalog: ask the index, because it is no longer in this process
CI/CD / gate (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
hanzo.app's Community page rendered "ERROR: CATALOG: 503" under an otherwise
fully-drawn page. api.hanzo.ai/v1/catalog answered
{"status":503,"error":"catalog: index not mounted"} on EVERY request, and
nothing was down.

browse() guarded on index.Ready(), and Ready() reports whether the index is
mounted IN THIS BINARY — its own doc says so. That was true and harmless while
cloud was one fused process. It stopped being harmless when the fleet became
one process per app: `index` and `catalog` are two manifest rows, index.Mount
is only ever called by plugin/index, so inside the catalog process that global
is nil and always will be. An in-process dependency survived a process split,
and the only symptom was a status code.

The index is now ASKED, on the internal plane, exactly as visor asks tasks for
its activities and as commerce serves its ledger. Not opened: the index store is
one encrypted SQLite with a single writer (MaxOpenConns(1), keyed through cek),
so a second process opening the same file to read it is the collision, not the
cure.

Both legs are kept and neither is dead: a fused binary that mounted both apps
still reads in-process, because a wire hop to something in the same address
space is pointless; the deployed fleet takes the plane. The write side does not
move — Reconcile stays in the process that owns the file.

apps/search carries the same dependency and is mounted by no plugin at all, so
it is unreachable rather than broken. Left alone here; naming it so the next
reader does not mistake this fix for having covered it.
2026-08-03 19:19:51 -07:00
hanzo-dev d969674fac Merge remote-tracking branch 'origin/p0/promo-credit-lockdown' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 19:16:50 -07:00
antje e259e3f566 team: drop the import that moved out with imageType
Hanzo CI/CD / cicd (push) Successful in 1m32s
CI/CD / containment (push) Successful in 1m35s
CI/CD / gate (push) Successful in 1m32s
CI/CD / image (push) Failing after 26m0s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Moving imageType into internal/magic took the only use of "bytes" in files.go
with it and left the import behind — which does not compile, and it is what
turned the release train red twice (the image job exited 1 at ~6m18s and no
cloud image published, while v1.801.399 had built fine from the commit before).

It was invisible locally for a reason worth writing down: `go build ./apps/team`
on macOS fails first on hanzoai/base's `cgo && !sqlite_math_functions` guard, so
the package was never type-checked and my error hid behind someone else's. The
build CI and the Dockerfile use is CGO_ENABLED=0 — under that, the package
compiles and the mistake is immediate.

Verified the way CI does: CGO_ENABLED=0 go build ./... and go vet ./... both
clean. The apps/team test failures that remain on this machine are all one
environmental cause (75 of 75: "cek: no RAM-backed scratch", macOS has no
/dev/shm) and fail at store setup before any assertion.
2026-08-03 19:06:36 -07:00
antje 7783c19139 avatar: regenerate the API surface the new routes belong to
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m5s
CI/CD / image (push) Failing after 6m47s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The drift gate regenerates every app's spec subset FROM SOURCE and fails on any
diff, so adding /v1/avatar without re-running it turned CI red — the release
train's image job exited 1 after 6m29s and no cloud image was published.

Regenerated, not hand-edited: zipdoc lifts the doc comments into zipdoc_gen.go,
`describe` projects account's own subset, and the weave proves the fleet spec
equals the sum of the subsets. floor.json moves by exactly what was added —
+2 paths, +2 operations, one new `avatar` product with 2.
2026-08-03 18:37:51 -07:00
antje 9a637f8b03 plugin wire: a 30s response deadline was truncating every long completion
Hanzo CI/CD / cicd (push) Successful in 26s
CI/CD / gate (push) Successful in 26s
CI/CD / containment (push) Successful in 1m8s
CI/CD / image (push) Failing after 7m0s
CI/CD / rollout (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / reach (push) Skipped
zip dials every plugin with zaphttp's DEFAULT transport, whose readTimeout is
30 seconds, and nothing overrode it. That deadline is armed ONCE, just before
the response head is read, and for a STREAMED response it is never re-armed:
the head arrives, SetBodyStream returns, and the whole body must then land
within what is left of the original 30 seconds.

So it is not an idle timeout. It is a hard cap on the TOTAL DURATION of a
response — applied to the plane whose responses are model completions, because
`ai` owns the /v1 remainder (/v1/chat/completions and the rest of the
OpenAI-compatible surface) and runs as a plugin behind this wire.

Every completion longer than 30 seconds was cut:
  - streamed: the body stops mid-token at ~30.1s with NO finish_reason, which
    every layer downstream reads as a complete answer. Measured against the
    live gateway, a long-page request to claude-opus-4.8 returned 7056 bytes,
    no </html>, total 30.1s — while the identical request straight to the
    upstream streamed 389s and finished properly at 15661 bytes. Reproduced
    in-cluster too, so it was never the edge.
  - unstreamed: 502 `zaphttp: read response: read unix …: i/o timeout`.

This is why hanzo.app's builder could produce a small landing page but not a
real app: an app is just a longer generation, and the truncation was silent. It
also made a slow-to-first-token model look broken rather than slow — enso
spends ~19s thinking, so most of its budget was gone before it emitted a token.

The ZAP scheme is re-registered before any plugin mounts (the transport is
resolved at dial time from a process-global registry, so a later registration
would leave already-dialed plugins on the default). BOTH halves are supplied: a
Dial-only Transport would silently remove the host's ability to LISTEN on its
own default scheme.

15 minutes, not zero: zero is no deadline at all and a wedged plugin would hold
the connection forever. The honest shape is an idle timeout — time BETWEEN
frames — which this transport does not offer, so it stays a total-duration cap
set past anything a real completion reaches.
2026-08-03 18:26:54 -07:00
antje 02b545fd81 avatar: a profile photo you can actually set, stored in S3
There was no way to set one. IAM carries an `avatar` on every user row and the
console renders it, but the only writers were federation (a GitHub avatar_url,
an OIDC `picture` claim) and SCIM — so a user who signed up with a password had
a monogram and no way to replace it, and the console's Profile card answered the
attempt with "Edit in IAM", which links to an IAM that cannot do it either.
Production agreed: /v1/avatar was a 404 while /v1/keys was a 403.

POST /v1/avatar stores the image and records its URL on the caller's IAM row, so
every surface that already reads `avatar` picks it up with no further call.
GET /v1/avatar/:org/:user/:digest serves it.

Storage is deps.VFS — the existing S3 seam (SeaweedFS via clients/s3vfs), which
was chosen for exactly this: "an adapter+crypto is needless complexity for small
avatars". No new store.

Three properties make it safe to serve an uploaded file back from an API origin:

  - THE FORMAT IS DECIDED BY THE BYTES. png/jpeg/gif/webp by magic number;
    anything else is 415 on the way in and 404 on the way out. A filename and a
    part Content-Type are the client's to choose, so neither may decide what
    this origin later serves — an SVG is a program, not a picture.
  - THE ADDRESS IS THE CONTENT. The key ends in the sha256 of the bytes, so a
    new photo is a new URL rather than a stale cache of the old face, and the
    read caches for a year. A replaced photo is deliberately NOT deleted: the
    old URL is already inside issued tokens and rendered pages, and an object
    store costs bytes where a broken face costs a person their profile.
  - THE READ TAKES NO CREDENTIALS, AND MUST NOT. Its whole job is to be an <img>
    from console.hanzo.ai, a different origin that sends no cookies and cannot
    set a header. So the 64 hex of sha256 IS the capability — producible only by
    someone who already has the image. The org and user segments are REFUSED
    unless they are plain identifiers rather than sanitized: apps/team's seg()
    folds "a/b" and "a_b" onto one key, and a fold in a tenancy key is two
    identities sharing an address.

internal/magic is the one magic-byte allow-list, now shared with apps/team's
files plane instead of copied. (The three `seg` functions are NOT duplicates —
same name, three different concepts — so they stay where they are.)

The oversize test mounts the app at production's 16 MiB edge body limit. Left at
zip's 4 MiB default the framework refuses the request first and the handler's
own 413 is unreachable and untested — the shape of the bug where studio's 4K
sources could not enqueue.
2026-08-03 18:26:54 -07:00
hanzo-dev 3a8be85b52 money: the promo mints no credit, and the campaign nobody authorized goes
POST /v1/marketing/promos/:code/redeem was a self-service money mint. The only
gate was tenant(ctx) -- ANY validated principal. `plan` and `seats` came off the
REQUEST BODY unvalidated and were multiplied into a finance.Deposit, so
{"plan":"team","seats":10} deposited 10 x $179.10 = $1,791.00 of real spendable
credit into the caller's own org. Nothing ever collected the charge the discount
was supposedly against; the charge was computed, returned to the caller, and
discarded. `instrument` was the anti-farming key, but instrumentUsed("")
returned false, so OMITTING the field skipped the guard entirely. With the
1,000-org cap, open signup and a personal org per account, that is ~$1.79M of
self-serve credit. The seed shipped active=1 in v1.801.398, which is live.

THE CAMPAIGN WAS NEVER AUTHORIZED. It became live because a schema migration
INSERTed it on every boot -- a business decision arriving as a side effect of a
code change. The seed is deleted, and because deleting an INSERT does nothing
for a database that already ran it, migratePromos now DELETEs the row on every
boot. Redemption history is deliberately KEPT: it is the evidence of what
happened while the campaign was live, and destroying it would destroy the audit
trail exactly when it matters.

CREDIT INTO AN ORG IS AN ADMIN DECISION -- deliberate, through the admin
surface, against an auditable ledger. So the deposit is not fixed, it is GONE,
along with the finance/money/types imports that made it reachable: reviving a
mint here would have to start by reviving an import. This follows 41b23f12,
which deleted the automatic $5 starter grant rather than switching it off, for
the same reason -- a money-mint left disabled is one flag away from enabled.

The subsystem now ships OFF (campaignsLive=false, read from no env var, no
platform switch, no column; TestCampaignsShipOff asserts the shipped value).
The guards are hardened anyway, so a REVIVED campaign cannot resurrect the hole:

  - Plan is DERIVED from the org's live ACTIVE/TRIALING paid subscription via
    cloud.PlanChecker -- the same seam SpendGate resolves -- and RedeemInput no
    longer HAS plan/seats fields. A field that does not exist cannot be trusted
    by the next reader. No qualifying subscription means no redemption.
  - FAIL CLOSED on an unreadable plan authority. SpendGate deliberately fails
    OPEN on this same read, because refusing on an outage 402s every paying
    customer at once. Here the asymmetry inverts: an outage must not be able to
    manufacture a claim that money is later granted against.
  - instrumentUsed("") now returns TRUE. An absent instrument is not evidence of
    a fresh card, it is the absence of evidence, and the partial unique index
    (WHERE instrument <> '') means the database will not catch it either.
  - maxClaimCents bounds every recorded claim, checked under the same lock as
    every other guard, and REFUSES rather than clamps -- a silent clamp would
    record a wrong figure and hide the bug that produced it.
  - Seats is the single-seat floor, never a caller-supplied multiplier.

Redemption.CreditCents/CreditEntryID become DiscountCents: the row records a
claim, not a balance, and a field named for credit that credits nothing is how
the next bug gets written.

Tests drive the shipped routes through the real router, because the hole was a
handler that trusted its input -- a store-level test would have proved the store
fine and missed it. The hardening tests force a campaign live (revivedPromoRoutes)
to answer the question that matters if the decision is ever reversed. Proven: the
exploit body cannot move the recorded figure; a live ledger receives ZERO
deposits; a redemption while closed is refused; nothing is seeded; migrate purges
an already-seeded row while preserving its redemptions.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 18:04:12 -07:00
hanzo-dev 5d58b78941 reserve the completion, not just the prompt, before serving inference
Hanzo CI/CD / cicd (push) Successful in 35s
CI/CD / gate (push) Successful in 40s
CI/CD / containment (push) Successful in 1m20s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
A prepaid gate reads a SETTLED balance and a completion's cost is not known
until it finishes. Two ways an org spent money it did not have sat between
those facts:

  - the gate priced only EstTokens(prompt), so the completion was never
    covered. A 1c org asking for a million-token completion was allowed and
    settled at $2.00, ending at -$1.99.
  - N calls in flight each read the balance before any of them debited, so
    each was authorized for the whole of it. Twenty simultaneous callers
    against 5c spent 20x the balance.

meteredAI now commits a call's worst case before weighing it and releases
that commitment when the debit reaches the ledger:

  - types.ChatRequest gains MaxTokens, and atMost() resolves the ceiling ONTO
    the request so the transport forwards the very number the gate priced.
    Reserving a ceiling nobody enforces leaves the completion just as unfunded,
    only less visibly, so clients/aihttp sends it on both the buffered and the
    streamed path.
  - commitments tracks per-org committed-but-unsettled cents. commit() returns
    the RUNNING TOTAL, which is what the balance must cover, so a second
    concurrent caller must clear the first one's commitment. Nothing else has
    to know reservations exist.
  - the release runs inside the recording goroutine (meterUsage's new posted
    hook, threaded through the peer path too). Releasing when the call returns
    would let the next gate read a balance that still contains money already
    being spent — the very window this closes. It runs on every exit: a hold
    that leaks is a paying customer locked out of their own balance.

Holds stay per-pod. apps/finance owns the settled truth and says so
("transient holds are the caller's in-pod concern, never persisted here"),
so this never becomes a second ledger.

MeterUsage keeps its signature — one debit verb, twelve callers untouched.

Tests drive the real meteredAI against a wallet whose balance MOVES; the
existing fixtures answer every gate from a fixed body, which is why neither
gap showed up before. A barrier holds the concurrent callers at the balance
read so the TOCTOU is deterministic rather than a race won by luck.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:42:00 -07:00
hanzo-dev 41b23f124a money: credit is an admin decision, so the automatic grant goes
The starter grant minted $5 into a wallet from middleware, on first credential
contact, with no human in the loop. Credit into an org is an ADMIN decision --
made deliberately, through the admin surface, against an auditable ledger --
so an automatic path that creates money is not a feature to fix but a mechanism
to remove.

DELETED RATHER THAN SWITCHED OFF. A disabled money-mint is one flag away from
an enabled one, and the flag is the kind of thing a later reader flips to
"unblock" something. There is no starter code left to re-enable: the middleware,
its mount in serve.go, the cross-process plane op (finance_starter / StarterIn /
Granted) that let a non-ledger binary ask for it, and their tests are gone.

Note this also removes the shared-signup-org exclusion that lived in the gate.
It was sound anti-abuse for a grant that no longer exists, and keeping half a
mechanism to guard the other half is how dead code survives.

The paywall consequence is deliberate and is NOT taken here: SpendGate stays
behind its kill switch. With no automatic funding, enforcing it 402s every new
account from its first request -- an honest paywall, and a product decision that
deserves its own change rather than arriving as a side effect of this one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:30:05 -07:00
antjeandhanzo-dev be8e99b079 console: pin the embed that shares the session across tabs
CI/CD / containment (push) Successful in 2m47s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Hanzo CI/CD / cicd (push) Successful in 32s
CI/CD / gate (push) Successful in 31s
sha-846069c predates the fix. The console SPA is baked into this binary by
//go:embed, so nothing about a console release reaches production until this
line moves — which is exactly what the pin is for, and why it is a sha and not
`:latest`.

sha-9da3984 carries three commits: the token store moved off sessionStorage
(a second tab started signed OUT while the first was still signed in), the
landing CTA starts the sign-in instead of routing to a page that asks again,
and the @hanzo/iam bump to 0.21.6 without which the static export dies on
`ReferenceError: sessionStorage is not defined` while prerendering
/auth/callback.

Verified before pinning: the console CI run for 9da3984 is green and pushed
console-embed:sha-9da3984-amd64. The previous run, on b9d31aa, was RED — so the
image for the storage fix alone never existed, and pinning to it would have
failed the CONSOLE-GATE here rather than shipping anything.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:26:37 -07:00
antje 5cececddc6 billing: tier needs an org, and Minor() rounds up — both comments now match reality
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / gate (push) Successful in 23s
CI/CD / containment (push) Successful in 1m12s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Two corrections to what shipped in v1.801.397.

TIER. It was registered on the bare public chain plans uses, but GetTier opens
with middleware.GetOrganization, so it panicked on a nil interface conversion and
answered 500 — the route went from unreachable to reachable-and-broken. A tier is
org state; the org has to be resolved first. Moved onto the billingRead loop,
which supplies the IAM leg its six siblings already rely on.

ROUNDING. The balance now reads (502 -> 200, $149,913.08), but it rounds UP, not
down as the comment claimed. hanzoai/decimal's Rescale rounds half-away-from-zero
(decimal.go:145) and Minor() is a Rescale — measured live, …078983985999994361
served 14991308 cents, a tenth of a cent above the true balance. The comment is
corrected rather than the behaviour: this number is a display, nothing is billed
from it, and the spend gate reads the exact decimal itself. A debit that must not
overstate has to round down deliberately instead of reusing this.

apps/ai carries the same "truncated toward zero" claim on the same call and is
wrong the same way. Noted in place; its gate compares > 0, so a sub-cent rounding
cannot change its verdict.
2026-08-03 16:57:17 -07:00
antje ce8b2fda69 billing: round the displayed balance down, so a funded account can read its own money
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / gate (push) Successful in 23s
CI/CD / containment (push) Successful in 1m13s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
GET /v1/billing/balance answered 502 "billing upstream unreachable" on every
call and the console rendered "Unavailable" while the ledger held the money.

Nothing upstream was involved — that label is wrong and it is why this looked
like a connectivity problem for days. plane.Money.Minor() REFUSES a value finer
than a cent rather than round behind the caller, and the ledger keeps eighteen
decimals because per-token charges are routinely finer than a cent. So a REAL
balance broke the read: live the org held $149,913.078983985999994361 and the
error was "is finer than its minor unit; round explicitly". It got worse as
usage accumulated, since a longer history makes a sub-cent tail likelier.

Minor()'s own doc says a caller that wants a rounded figure — "a display, a
summary" — should round explicitly, where the choice is visible. This view is
exactly that and never did. It now rounds DOWN, the same choice apps/ai
documents for the same value: a displayed balance must never exceed what the
account can actually spend, and truncation understates by under a cent. Nothing
is billed from this number.

Also reverts a wrong fix from earlier in this session: registering balance
co-resident in apps/commerce. The manifest gives /v1/billing/balance to the
`billing` app, not commerce, so that registration was in a subtree it does not
own and could never have run.

Two things this exposed, both left alone deliberately:
  - The 502's message names an upstream that is not in the path. Renaming it is
    a behaviour change to an error contract callers may match on.
  - apps/billing's test binary needs libsqlcipher and cannot compile on a
    laptop, so `go build` passing there proves nothing about the test file. That
    masked a first version of this patch which called a Minor() that does not
    exist on the local money type; caught by type-checking with go vet instead.
2026-08-03 15:38:19 -07:00
antje 220c01c196 billing: serve the balance co-resident, so a funded account can read its own money
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m14s
CI/CD / image (push) Failing after 22m42s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
GET /v1/billing/balance answered 502 "billing upstream unreachable" on every
call, and the console renders that as "Unavailable" while the ledger holds real
money.

The cause is the one apps/account/billing_coresident.go already documents in its
header: co-resident there is NO standalone commerce — the in-cluster service
selects the cloud pods themselves — so the /v1/billing/* bridge forwards to a
default base that is the public edge, and the read re-enters the same bridge in
an unbounded self-dispatch loop. Six sibling reads (invoices, subscriptions,
alerts, payouts, settings, credits) were already registered co-resident to
shadow that wildcard. Balance was not one of them.

It now registers through the identical chain — RequestContext, IAMTokenRequired,
PinBillingSubject — so the subject pin is byte-for-byte what the bridge applied
and a read scopes to exactly the account the spend gate debits, never wider.
balance/all rides the same prefix, since a prefix owns its whole subtree.

The apps/commerce test binary does not compile on a laptop (hanzoai/base needs
libsqlcipher, which the build image links and macOS does not); verified by
stashing that the identical failure predates this change.
2026-08-03 14:56:16 -07:00
antje 3b177b7577 manifest: /v1/billing/tier reaches commerce, so a paying customer gets their rate limit
CI/CD / rollout (push) Failing after 20m27s
CI/CD / gate (push) Successful in 1m56s
CI/CD / containment (push) Successful in 2m5s
CI/CD / image (push) Successful in 18m1s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 1m56s
CI/CD / receipt (push) Failing after 1s
Every AI request logged
  tier_cache: Commerce lookup failed for key=...: commerce returned 404
  (defaulting to zen-free)
and that default is 60 rpm against 500 for pro, 2000 for team, 50000 for
enterprise. So the failure did not give anything away — it silently served every
PAYING customer the most restrictive tier in the table.

Cause is the exclusive-subtree rule again: account-bridge owns /v1/billing, and
commerce's row named seventeen sibling paths but not tier, so
GET /v1/billing/tier never reached the handler that answers it
(commerce api/billing/handlers.go:43 registers it; live it 404s).

The UNREACHABLE ledger in manifest/router_test.go did not catch this and is not
wrong to have missed it: it fires on paths the fleet PUBLISHES, and cloud does
not publish tier — commerce serves it and only the ai router calls it. A
cross-binary caller is outside what that gate can see from here.
2026-08-03 14:35:21 -07:00
antje 502c1ec78a console: move the embed pin 63 commits forward, to sha-846069c
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m54s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The pinned console-embed was sha-147ecd3, built 2026-07-27. console main is 63
commits ahead of it, so a week of console work — including the onboarding fix
below — has never reached production: the pin does not track main BY DESIGN (a
floating tag made a release silently bake the PREVIOUS console), so it only moves
when someone moves it, and nobody had.

sha-846069c is console main HEAD. Verified published before pinning:
ghcr.io/hanzoai/console-embed:sha-846069c-amd64 resolves to
sha256:f2849a31b082da0d690bc5403a41ad995624fdfd6595089008821c07718a9255, and
console-embed:latest now points at the same digest. Probed with all four Accept
types against a bogus-tag control — a two-header probe returns a FALSE 404 on an
image that is present, which is how a healthy registry can be misread as an
outage.

What this carries to production, beyond 62 other commits: a refused org create no
longer reads as a complaint about the NAME. /v1/iam/onboard answers 409 for two
opposite reasons — the first-run gate (this account already admins an org;
founding a second would orphan it) and a name genuinely held by another tenant —
and the console showed the server's organization-level message immediately after
the customer typed a name. It now reads the account to tell the two apart, and
offers the way into the org the identity is actually in instead of dead-ending on
a form that can never submit.
2026-08-03 14:17:26 -07:00
antje 7a9f650b53 deps: ai v1.832.17 — the key refusal that names its cause could not reach anyone
cloud pinned ai v1.832.16 while five commits sat on ai's main untagged, so a fix
merged hours ago was in no release and no binary. That is the third instance
today of merged-and-unshipped, each with a different cause: an image published
before its own fix landed, a build job silently skipped by a stale generated-doc
gate, and now a module change in no tag at all. The symptom is identical from
outside — the code is right and production is wrong — which is what makes it
expensive to notice.

Carries: the ok-with-no-user key refusal (IAM answering status=ok with a null
user used to fall through to a bare "invalid API key" that named no cause), the
openrouter seed URL carrying a /v1 the family appends itself, a family's kms://
key resolved before it becomes an Authorization header, family control from
admin.hanzo.ai rather than env alone, and /v1/provider-flags becoming
/v1/models/providers derived from the served catalog.

No release cut here. The next cloud build carries it: cloud is one replica with
strategy Recreate, so every release is a total outage of inference, billing and
auth, and a dependency bump does not justify one on its own.
2026-08-03 14:17:02 -07:00
antje 1648cf3000 cloud: mount the published-site edge in the process that owns the public port
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m22s
CI/CD / image (push) Failing after 26m21s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The site edge has never run in production. It was mounted in serve.go — the
FUSED composition root — but the image runs cmd/cloud (the light router) on the
public port with one process per app beside it, and none of those is serve.go.
So every <slug>.hanzo.app fell through to the console SPA, and the whole /v1
surface answered on the customer's own hostname.

Proven at the pod rather than inferred: the process holding :8000 is /cloud (a
separate 23MB binary from /plugins), and `grep -c sites_resolve /cloud` is 0 —
the code was not in the running binary at all, even at the tag that contains it.
The two earlier fixes this session were both real and both invisible for this
reason: the host resolution fix (8b729f8ef) and the plane resolver (5f0b74fe9)
were compiled into a composition root nothing runs.

The router "deliberately links none of" the fleet's package graph, and that
holds: `go list -deps ./cmd/cloud` still contains ZERO of the root package.
apps/sites is a leaf, and the cross-app call is made here with zip.DialApp —
the same door wake.go already uses to publish one op without cloud.Plane().
apps/sites exports the wire types so the caller restates no mapping.

Mounted BEFORE webui, which owns "/" for every unclaimed path and would
otherwise answer first for every site host.

The test reads run()'s own source for the call site, because the defect was
never a logic error — the package was always correct — it was a middleware that
ran nowhere. My first version called mountSites directly and PASSED with the
call site deleted, which is exactly the mistake this file is about. Both failure
modes are now negative-controlled: no call at all, and mounted after the console.
2026-08-03 13:41:35 -07:00
antje 5f0b74fe9a sites: the edge asks the app that owns the store, because it is never in this process
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m56s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every published site served the console SPA. The host fix in 8b729f8ef was
necessary and not sufficient: with the host resolving correctly the edge still
found no site, because sites.SetResolver writes a PACKAGE-LEVEL registry inside
`projects` and the edge middleware reads it inside whichever process fronts
:8000. The pod boots ~25 single-app processes ("enabled":["<one>"], 25 distinct,
zero multi-app — measured on the live pod), so those are never the same process
and the registry is always nil where it is consulted.

A nil registry is a clean MISS, not a fault. So every lookup failed silently,
every request fell through to the API pipeline, and no error was logged anywhere
because nothing had failed. Proven at the pod with the ingress bypassed:

  wget --header="Host: app.maxpower.hanzo.app" http://127.0.0.1:8000/
    -> <title>Hanzo Cloud Console

for a site that is genuinely published and has its own Ingress.

The fix is the seam this repo already uses for exactly this shape:
FinanceScopeRules is on the plane, in its own words, because "the READER is a
cloud EDGE middleware" and the fact belongs to another app. projects now
publishes sites_resolve / sites_resolve_org from the one process that owns the
store, and the edge falls back to them. Co-residence still wins with no hop —
currentResolver prefers the in-process registry and only then asks.

Not-found stays a clean 404; a failure to ASK stays an error, so the edge can
render 503. Collapsing those would serve 404s for live customer sites during any
transient failure of the owning app, which is indistinguishable from deletion.

The comment on SetResolver said "until it is set, every site request is an honest
404 (the projects subsystem is not mounted)". That premise was the bug: projects
IS mounted, just elsewhere, and 404 is not honest when the site exists.

Negative-controlled: removing the fallback fails the new test with the
fall-through this commit is named for. The second test pins that a co-resident
store is still used without the hop.
2026-08-03 12:36:14 -07:00
hanzo-dev d02eb45d6e deps: orm v0.6.21, which carries xorm v1.4.5 and its identifier escape
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m51s
CI/CD / image (push) Failing after 1m1s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The relational engine wrapped an identifier in the dialect delimiter but wrote
the body verbatim, so a delimiter inside the identifier closed the quote the
writer opened and the rest executed — Desc("name`,(subquery)--") emitted two
quoted identifiers plus bare SQL rather than one identifier. xorm v1.4.5 doubles
the delimiter, the standard SQL identifier escape, so a name that smuggled one
becomes a single identifier that does not exist and fails closed.

orm is the only place that version is chosen — consumers name hanzoai/orm and
the engine arrives underneath — so this is the hop that makes it live here.
go list -m confirms xorm resolves to v1.4.5 in this build.

Bump only. The four failing packages are the standing pre-existing set
(apps/iam, apps/plan, apps/pricing, manifest); the bump adds none.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:03:08 -07:00
antje c587fcd07a dataset: cloud.Listen, not cloud.Serve — main could not build an image
Hanzo CI/CD / cicd (push) Successful in 1m7s
CI/CD / gate (push) Successful in 1m36s
CI/CD / containment (push) Successful in 2m4s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
plugin/dataset/main.go called cloud.Serve, which does not exist. Every other
plugin calls cloud.Listen. The image build enumerates the manifest and compiles
each plugin in turn, so this failed the whole build at the dataset step:

  plugin/dataset/main.go:29:18: undefined: cloud.Serve

That means no cloud image has been buildable since ae3a30994 landed. `go build
./...` at the repo root does not catch it — the plugin mains are only reached by
the Dockerfile's per-plugin loop, so the gap between "compiles locally" and
"produces an image" is exactly one word wide.
2026-08-03 11:51:46 -07:00
antje 8b729f8ef1 sites: resolve the request host at the point of use, so a published site serves the site
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 2m29s
CI/CD / image (push) Failing after 11m52s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every published site served the CONSOLE, and mounted the whole cloud API under
the customer's own hostname. Measured live 2026-08-03:

  quest.hanzo.app/                    -> <title>Hanzo Cloud Console
  quest.hanzo.app/v1/billing/plans    -> 200

fiber parses the request URI once, and behind the ingress the parsed host is
empty — so siteSlug("") failed, customCandidate("") failed, and every request
fell through to c.Continue() into the API pipeline. The site edge was mounted and
configured correctly the whole time; it just never learned which host was asked
for. Same accessor and same failure as commerce's tenant resolver, in a second
codebase, the same night.

The parsed host still WINS whenever it names something this server serves — that
ordering is the security property, not a detail, because the host picks the ORG
here and a client able to override a real host could serve itself another
tenant's site. X-Forwarded-Host is consulted only when the parsed host is not a
host we can serve, which is the ingress case and never a direct request.
TestMiddlewareTenantKeyedByHostNotPath (pre-existing, unchanged) still passes.

Negative-controlled: reverting to Hostname() alone fails the new test with the
fall-through this commit is named for. One correction on the way: the first
version of the new test used req.Host="" to express "no parsed host" — httptest
synthesizes "localhost" for that, so it proved nothing until measured.
2026-08-03 11:00:54 -07:00
antje b66c9a2576 fix: a machine authenticating as itself has an org, and it is its owner
studio could not enqueue a single render. `POST /v1/tasks/.../activities` answered
403 "identity required", so thirteen jobs sat `queued` in its worklog for up to
nineteen hours while both GPUs polled an empty namespace every two seconds and
reported themselves healthy. Nothing in the queue, the worker logs, or the studio
UI said why.

The cause is one branch. homeOrg already knows that a machine cannot mis-attribute
its org the way a human choosing an app can — that is why the KMS sync identity
reads `owner`. But KMS is recognised by audience, which works only because its
client id is DERIVED from its org ("<org>-platform-kms"). No other app's id is,
so every other client_credentials principal fell through to the estate rule, found
the empty `orgs` a machine correctly carries, and resolved nothing. SanitizeIdentity
then minted X-User-Id with no X-Org-Id, and each org gate refused it.

So the recognition comes from the token's SHAPE, which a human token cannot wear: in
a client_credentials token the client IS the subject — IAM sets sub to "<org>/<app>"
— and azp equals the sole audience, because the app asked for a token for itself. A
human's subject is the user and azp is whichever app they signed in through, which is
the exact mis-attribution homeOrg exists to prevent. Every field read is IAM-signed;
none is a header a caller sets.

This does not widen who may cross tenants. It resolves an org for a principal that
has exactly one and can no more choose it than an sk- key can: `owner` is the
application's own organization, and getting the token requires that application's
client secret. A human with no `orgs` still resolves nothing and still fails closed —
tested, along with each half of the shape, because a partial match is a human token
that merely resembles a machine.
2026-08-03 10:36:32 -07:00
hanzo-dev d753e6c73d risk: the decision regime is durable on its own terms, versioned, and cited by every score
An organisation that took its model out of shadow BEFORE the model had learned
anything was told live=true and had nothing written down. The regime lived on the
same row as the learned state, and that row's writer declines to write while the
snapshot holds no learned mass — correctly, because there is no state to lose. So
PUT /v1/risk/state/appetite answered 200, reported live, and persisted nothing;
this binary deploys Recreate at one replica, so the next rollout rebuilt from
defaultConfig — shadow — and the model decided nothing. No error, no log, nothing
to alert on. A model silently disarmed, on a routed door.

The two facts are decomplected. The regime is now its own append-only versioned
record on the tenant's own shelf, written BEFORE anything in memory moves, so a
policy that cannot be written down is refused rather than answered from state the
next rollout will undo.

A regime is a VALUE: a version is minted only when the numbers CHANGE, so a
version means "the Nth distinct policy this organisation adopted" rather than "the
Nth time somebody pressed save", and a client that restates its config on every
deploy is free rather than the cheapest way to fill a disk.

Every score now cites the version it was decided under. Cut is derived from the
appetite that version states, so without the citation a restated appetite made
every earlier decision unreconstructible — the threshold it was measured against
no longer existed anywhere. GET /v1/risk/policy reads the history back.

Bounds, both per tenant and both on the tenant's own table:
  RATE   at most 24 distinct regimes per rolling 24h, refused past it with the
         organisation's own bound named and the regime in force untouched.
  TOTAL  381 versions, which IS 256 KiB divided by a measured worst-case row.
         At the ceiling the oldest is disposed of and the number disposed of is
         REPORTED, derived from the lowest surviving version so it cannot drift.

A regime that predates this record is ADOPTED as version 1 on first residency.
Without that, resolving the regime only from the new record would have returned
every already-live organisation to shadow on the first rollout after this ships —
causing the very defect being fixed, to every tenant at once.

The bounds on review and sample had two spellings, one at the op and one in the
plane. The op's copy is deleted; admitRegime is the one door, at the same
strictness the published contract always had.

Two test gates widened, because both could have been passed by looking at
nothing:
  - TestOps_EveryOpIsAdmittedAndPriced parsed typed.go alone, so an op declared in
    any other file was admitted and priced by nobody's assertion. It now parses
    the package, matches on the RECEIVER TYPE (the plane carries score/learn/
    state/appetite too), and fails when a registered op is declared nowhere.
  - the per-tenant history assertion over the wire cannot observe the query's
    tenant predicate: two orgs are two FILES. The predicate is load-bearing where
    two brands share one file, and TestPolicy_TwoBrandsShareAFileAndNotAHistory is
    the test that fails when it is dropped.

Eleven mutations applied, each named test red under the defect and green after
revert.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:26:20 -07:00
hanzo-dev df3bf6891f openapi: the floor ratchet takes the label plane's seven operations on the risk product, and ml keeps the fourteen it already had
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:26:10 -07:00
hanzo-dev e068e72ac9 merge main: the dataset plane landed beside ml; label stays under the risk product and keeps its place before the bare /v1/risk prefix
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:25:38 -07:00
hanzo-dev d94c0510a4 label: the ground-truth plane addresses under the product it belongs to, and its counts become byte bounds
THE ADDRESS IS THE PRODUCT. openapi.Fold takes an operation's product tag from
the first /v1 segment of its path and nothing else (openapi.Product); a per-op
zip.WithTags names a different axis and cannot override it. The seven ops were
addressed /v1/ml/labels, so seven compliance operations — their own writers
(commerce adjudicates the dispute, the compliance face closes the case, an
analyst files the review), their own five-year retention floor, their own
per-tenant file — would have been published as part of the KServe model-SERVING
product, which is four paths and live with customers on it. Nothing in the fleet
would have said so: the floor ratchet reads `ml: 7 -> 14` as growth, because it
refuses a shrink and only a shrink. It is the same mistake apps/risk's own
manifest row already records having made and corrected once, one layer up.

So: /v1/risk/labels, tag risk, every operation id and schema name risk-prefixed
(riskLabelEvent and not riskEvent — apps/risk publishes a riskEvent already, and
it is a scored decision rather than a judged one). floor.json returns ml to 7 and
raises risk to 17. The manifest row precedes risk, whose prefix is the bare
/v1/risk, and TestEveryServedPathReachesTheAppThatServesIt proves all six paths
reach label over the real fleet router rather than a comment claiming they do.
address_test.go walks the live projection — the same openapi.FleetSpec that writes
the committed subset — so a route re-addressed into somebody else's product fails
at the plane.

A BOUND ON COUNT OVER CALLER-SIZED VALUES IS NOT A BOUND. maxResolve capped a
resolve at 500 named events and nothing capped a subject: the rows were bounded
and the bytes were bounded only by the edge's BodyLimit, which is a fact about the
deployment. Each subject is then amplified below the door — a dedupe key, a
grouping key, one bound parameter per event in a statement against a single-writer
file. The write door had the ceiling all along (admit, subjectMax); the read doors,
added after, did not, and nothing compared them.

There is now ONE spelling of each ceiling — admitSubject, admitKind, admitSource,
admitEvidence, and instantMax inside stamp(), which is the one parser every time
field passes — and every door asks it. So `count × ceiling` IS the byte bound of
everything this plane binds, holds and stores. An unknown kind or source is
refused on the READ path too: it can only ever match zero rows, so refusing says
so instead of charging for the scan. bound_test.go proves it twice: reflect walks
every In type and fails on a caller-sized field with no declared ceiling (the
structural half — a new field cannot arrive unbounded), and every declared ceiling
is refused over the wire with a refusal that does not carry the value back.

A LITIGATION HOLD THAT ARRIVES MID-SWEEP KEEPS THE RECORD IN BOTH PLANES OR IN
NEITHER. dispose sweeps the derived copy FIRST so nothing is orphaned in the
warehouse, then deletes from the record re-asserting `hold = 0`. That protected
the record and silently corrupted the copy: a record the delete declines to remove
has already been swept, its seq is behind the delivery cursor, and deliver() asks
the cursor rather than the world — so no retry re-sends it, pending() answers zero,
and the row is present in the compliance record and permanently absent from the
answer key a training join reads. A missing fraud label reads as an honest
customer, and the row is the one somebody is litigating. remove() now reports what
it kept, the sweep writes those back from the record, a repair that fails refuses
the request rather than acknowledging a short copy, and `restored` is a NAMED state
on the response. `disposed` counts what was disposed of rather than what was
identified: a compliance report that says it deleted a record it is still holding
is the wrong answer to the only question the report is asked.

THE PUBLISHED PRECEDENCE RULE NAMES THE FIELD THE RESOLVER READS. The op exists so
a caller holding a contested resolution can reproduce it, and its second term said
`seen` while stronger() compares `knowable`. The two are equal for a live pipeline
and differ for exactly the backfilled history the derivation exists to hold back,
so a caller reproducing the rule got a different winner and no way to see why. The
test counted the terms, which made the only property that matters unobservable; it
now pins each term to the field at its position.

Also: plugin/label/mcp.json is deleted. It is the only mcp.json in the tree, no
app on main has one, the generator that wrote them was retired with its gate
(manifest/mcp_test.go says so), and it declared seven tools under the old
operation ids with nothing left to regenerate or compare it.

25 mutants in scripts/mutate.py, 11 of them new, 25 KILLED: each reintroduces one
of these defects and the named test goes RED.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:24:09 -07:00
hanzo-dev 0cf342ce78 merge main: the wove openapi.yaml for the merged surface
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:17:54 -07:00
hanzo-dev 6986d025a7 merge main: the ground-truth plane addresses under the product it belongs to
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:06:07 -07:00
hanzo-dev 3581e8c6c3 wip: address move
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:03:59 -07:00
hanzo-dev beefb70009 reference: bound one request, bound one tenant's bytes, keep the version a decision cites
resolve could spend the whole process on one authenticated request. Two
amplifiers composed. A key had a count bound and no BYTE bound, and the domain
matcher split a host into labels and re-joined every tail, so an L-label host
allocated a copy of each of its L suffixes: one 8 KB dotted key materialised
16.8 MB and 100 of them 1.7 GB, measured over the router. And `sets` had no
bound and no dedupe, so naming one set N times ran N times the answers.

  - maxKey bounds one key in bytes at every door it crosses — looked up,
    written, removed — and REFUSES rather than truncating, because a shortened
    key is a different key.
  - a domain suffix is now a slice of the host, not a join of its labels: the
    same answers in O(L) headers over one backing array.
  - a call may name each published set once, and a set named twice is consulted
    once.

The override write had the same hole from the other side: maxOverrides bounded
rows and nothing bounded a row, so 10,000 entries x 11 sets was gigabytes of
attacker-chosen bytes on the one volume every other organisation's store lives
on. The same maxKey closes it; an over-long note is refused rather than trimmed;
and what one organisation may occupy on that volume is now a figure the code
computes and a test pins, so raising rows, a key, a note or the catalog is an act
with its consequence next to it.

An override is a record, so it is now shipped before it is acknowledged, the way
apps/research and apps/books do it — this deployment is one replica with a
recreate rollout, and an unshipped write is a control an operator believes is in
force and is not.

prune spared ONE version: the call site passed the current version for both of
the statement's two placeholders, deleting the rows behind every citation taken
in the window before a refresh. What a take supersedes is now decided by
sweepOld over what the plane held before it, and proved against a warehouse
rather than against the text of the statement.

The publisher's end of the same amplifier is closed with the same door. A take
is refused whole if it carries a member longer than maxKey — one no lookup could
ever reach, so it is only weight in the warehouse, in every hydrate and in the
snapshot every request reads — or more members than a published set holds: swing
measures GROWTH against the version a take replaces, and a first take has nothing
to measure against, which after a cold start is every take. maxBody comes down to
six times the largest source in the catalog (measured: 2.6 MB), because the parse
allocates before any later gate can look at what it made. A publisher's redirect
must keep the two properties its origin already had, TLS and a destination
outside this network: this process runs in the cluster, where "wherever the
publisher says" reaches the pod network and the metadata address.

Also: a take whose size swings past 4x is refused and the previous version
stands (force is the operator lever); the disposable list is refused whole if it
names a mailbox provider, which is the one-row attack the size gate cannot see
on the one unpinned source; an attest receipt with no version or no designations
is recorded as a refusal instead of a current, fresh list; the plane sweeps at
cold start instead of refusing every set for six hours after a deploy; the one
cross-fleet aggregation states its own memory and time budget; every source
states a typed redistribution Basis from a closed vocabulary, on the wire, so
the licence position is an audit rather than a sentence; refresh reads the ONE
SuperAdmin predicate rather than restating it, and an admin of their own org is
refused there, because this route writes the baseline every org reads; and the
bridge sits on this app's own leaf, not on the /v1/ml parent two other products
answer under.

65 tests green under -race (one skipped: it dials the real publishers). Every fix
has a regression test that fails when that fix alone is reverted: 24 mutants, 24
killed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:45:33 -07:00
hanzo-dev 144be507b0 reference: the lookup data a decision consults, versioned and named
/v1/ml/reference publishes ten sets — disposable email domains, hosting and
Tor address ranges, crawler user-agent patterns, delegated autonomous system
numbers, card-scheme prefixes, browsers the fleet sees everywhere, and the
freshness of the designation lists the screening engine holds. Six typed ops.

The unit of version and freshness is the SOURCE, not the set, so one publisher's
outage neither blocks the others' updates nor silently shrinks the set. A
version IS the content digest of the sorted entries, which makes a re-take of an
unchanged publisher a no-op that says so, and a half-landed version resumable
from its cursor without depending on it — the primary key already deduplicates.

Two planes, two stores. The baseline tables carry no tenant column, so a
cross-tenant write is unrepresentable rather than refused; a tenant's own
allow and deny entries live in that organisation's own store. Resolution is
override then baseline, both through one candidate function.

The baseline carries only published data under terms we hold — every source
states its licence — and aggregates above a k-anonymity floor no single
organisation can reach. Sources we may not redistribute are declared seams that
refuse, because an absent set and an unlicensed one look identical from outside.

A set that never loaded refuses rather than answering "not listed", and a stale
set answers and says so: every answer carries the version, its as-of, its age
and whether it is past the bound.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:45:33 -07:00
hanzo-dev fa2c3f23cc label: ground truth, and the three properties it is worth nothing without
/v1/ml/labels is the answer key the model plane cannot build for itself: what
turned out to be fraud, who said so, and when they could first have said it.
Chargeoff, dispute, case, refund, review and the below-the-line sample all file
into one append-only record per tenant — its own encrypted SQLite file, no org
column, so a cross-tenant read is not forbidden but inexpressible — with a
derived ClickHouse copy for joining at training scale.

Seven typed zip ops, so the REST route, the OpenAPI operation, the MCP tool, the
CLI command and every SDK method are projections of one declaration: mlLabel,
mlLabels, mlResolveLabels, mlLabelCoverage, mlLabelVocabulary, mlDisposeLabels,
mlHoldLabels.

THREE PROPERTIES, EACH THE POINT OF THE PLANE, NONE OF WHICH THE FIRST CUT HAD.

DURABILITY. Nothing in the package called OrgStore.Sync, so the only ship was
CloseAll on a graceful shutdown. cloud deploys strategy Recreate at one replica:
an ungraceful termination lost every acknowledged record since process start,
and the successor hydrated the older durable snapshot OVER the local file — an
acknowledged compliance record was not merely at risk, it was overwritten by an
older copy of the tenant's own history. state.ship is now the ship-before-ack
step every write path calls before it answers, and an unacked ship fails the
request rather than acknowledging a divergent local copy. That covers BOTH
shapes: a replica that never held the lease (ErrNotOwner) and one deposed
between the write and the ship, whose fenced Put is refused at a stale round
with no error at all. The two sibling durable planes hold the same contract
(apps/research shipFor, apps/books shipLedger).

DELIVERY. The cursor was the pair (wrote, id) over a write clock truncated to
the second and a content digest — an order the writer never took. A write that
commits after a concurrent delivery has read, whose digest sorts lower inside
the same second, was already behind the mark: never mirrored, unreachable by any
retry, and pending() answered zero because it asked the same predicate. A hole
in the answer key reads as an honest customer. The cursor is now the store's own
AUTOINCREMENT position, allocated inside the insert on the single connection
every statement for a tenant runs on (sqlpool.Single), so cursor order is commit
order by construction.

LEAKAGE. `seen` is whatever the caller sent, bounded only by At <= Seen <=
now+skew, and nothing tied it to any fact the server observed — a dispute filed
today with seen == at was knowable a year before the record existed, and a
backtest standing two days after the event resolved it. Fact.Knowable is derived
server-side as the later of `seen` and the clock at the write, it is the only
time visible() and stronger() read, and it is a column in the derived copy so
the warehouse applies the same predicate the record plane does. A live pipeline
is unaffected: Wrote is within minutes of Seen and the derivation changes
nothing.

Also: the coverage window now ends where maturity begins, so the gate on
training no longer answers zero on its own defaults; Group returns the whole
matured cohort so `matured` counts what matured and `unlabelled` says why
`judged` is low; a litigation hold is a fact about the record with its own op
that can also release one, instead of a digest-excluded flag silently dropped on
any record that already existed; the warehouse partitions by month like every
other table in the fleet rather than by tenant, whose cardinality is the
customer count; and resolve answers one row per distinct event, so an event
named twice is no longer its own conflict.

Every one of those carries a mutant in scripts/mutate.py that reverts it and a
test that goes RED when it does: 14 rows, 14 killed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:27:36 -07:00
hanzo-dev 986fb553a2 cloud: one mint for the brand-qualified tenant key
An org name is unique within an issuer and not across issuers: acme on
hanzo.id and acme on zoo.ngo are two unrelated businesses. A per-org SQLite
file never confuses them, because DataDir is per deployment and the brand is
the directory the file is in. A COLUMN in the shared columnar warehouse has no
such directory — org = 'acme' there is a predicate over both — so every row a
brand-shared table carries has to be keyed on <brand>/<org> and every read of
it has to bind the same form.

Qualify joins SanitizeOrg and OrgNamespace as the third org-naming door and the
only one for a shared plane. Qualified is derived from Qualify rather than
restated, so a change to the shape cannot leave a validator behind. Tenant
carries no JSON tags and has no constructor but Qualify, so no In struct can
decode one and no caller can assert a tenant for itself.

The brand half comes from Deps.Brand, never a header: a caller that can choose
its brand has chosen which tenant space its org lands in.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:27:31 -07:00
1449 changed files with 41653 additions and 12468 deletions
+15
View File
@@ -60,3 +60,18 @@ node_modules/
**/node_modules/
native/flags/target/
tools
# Build output at the repo root. `go build ./apps/gateway` and friends drop the
# binary HERE by default, and five of them (gateway 53M, account 33M, authz 30M,
# smoke 8M, gen-app-cmds 4M — ELF x86-64, ELF aarch64 and Mach-O arm64, so three
# different people's machines) were committed and pushed the module tree past Go's
# 500MB zip limit. `go get github.com/hanzoai/cloud@latest` then failed outright
# with "module source tree too large", which is every consumer, not just ours.
#
# Each is built from a real package that keeps its source: apps/gateway,
# apps/account, plugin/authz, plugin/smoke, plugin/gen-app-cmds.
/gateway
/account
/authz
/smoke
/gen-app-cmds
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# image-revision — print the commit an image was built from.
#
# WHY THIS EXISTS. A release is meant to be a receipt: the tag, the commit and
# the image all name each other, and any one of them can be checked against the
# other two after the fact. Two of those links are cheap — the git tag names a
# commit, and universe pins repo:tag@digest. The third, image -> commit, is
# readable ONLY from the image's own `org.opencontainers.image.revision` label,
# and nothing in the fleet read it, so nothing noticed when it stopped being
# true.
#
# It had stopped being true for a whole class of images. cloud's Dockerfile
# declares `ARG REVISION=unknown`, and the label takes that default unless a
# builder passes it. The docker/build-push-action lane happens to overwrite the
# label from the outside (its `labels:` input is applied after the Dockerfile's
# own LABEL), so ITS images were fine. The platform lane — buildctl, via
# buildFrontendCmd in apps/platform/k8s.go — passes build-arg:VERSION and
# build-arg:GIT_VERSION but no REVISION, so every image it published carried
# `revision=unknown` and could not be traced to a commit at all.
#
# That is exactly how the two v1.801.410 images became indistinguishable without
# a byte-level diff: one labelled 1b8b76ed (the real release), one labelled
# `unknown` (the lane that overwrote the tag 12 minutes later). With the label
# truthful on both lanes, "which commit is this image" is one call, and the
# tag -> commit -> image triangle closes.
#
# image-revision.sh <image-path> <ref> [bearer-token]
# image-path the path under the registry host, e.g. hanzoai/cloud
# ref a tag or a sha256: digest
# token a ghcr pull token; fetched anonymously when omitted
#
# Prints the revision on stdout. Exits non-zero (printing nothing) when the
# image cannot be read, so a caller can distinguish "no label" (empty output,
# exit 0) from "could not look" (exit 1) — the two demand opposite handling and
# collapsing them is how a verifier comes to pass by accident.
set -euo pipefail
IMAGE_PATH="${1:?usage: image-revision.sh <image-path> <ref> [token]}"
REF="${2:?usage: image-revision.sh <image-path> <ref> [token]}"
TOKEN="${3:-}"
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json'
if [ -z "$TOKEN" ]; then
if [ -n "${GHCR_USER:-}" ] && [ -n "${GHCR_TOKEN:-}" ]; then
TOKEN="$(curl -fsSL --max-time 30 -u "$GHCR_USER:$GHCR_TOKEN" \
"https://ghcr.io/token?scope=repository:${IMAGE_PATH}:pull&service=ghcr.io" | jq -r '.token // empty')"
else
TOKEN="$(curl -fsSL --max-time 30 \
"https://ghcr.io/token?scope=repository:${IMAGE_PATH}:pull&service=ghcr.io" | jq -r '.token // empty')"
fi
fi
[ -n "$TOKEN" ] || { echo "image-revision: no ghcr pull token for ${IMAGE_PATH}" >&2; exit 1; }
fetch_manifest() {
curl -fsSL --max-time 30 -H "Authorization: Bearer $TOKEN" -H "Accept: $ACCEPT" \
"https://ghcr.io/v2/${IMAGE_PATH}/manifests/$1"
}
MANIFEST="$(fetch_manifest "$REF")" || { echo "image-revision: cannot read ${IMAGE_PATH}:${REF}" >&2; exit 1; }
# A multi-arch tag is an INDEX, and an index carries no config blob and so no
# labels. Descend to the amd64 child — the only platform this fleet publishes —
# rather than reporting "no label" for every multi-arch image, which would make
# the verifier silently vacuous exactly where it matters most.
if printf '%s' "$MANIFEST" | jq -e 'has("manifests")' >/dev/null 2>&1; then
CHILD="$(printf '%s' "$MANIFEST" | jq -r '
(.manifests[] | select(.platform.architecture == "amd64" and .platform.os == "linux") | .digest),
(.manifests[0].digest)' | head -1)"
[ -n "$CHILD" ] || { echo "image-revision: index for ${IMAGE_PATH}:${REF} names no manifest" >&2; exit 1; }
MANIFEST="$(fetch_manifest "$CHILD")" || { echo "image-revision: cannot read child ${CHILD}" >&2; exit 1; }
fi
CONFIG="$(printf '%s' "$MANIFEST" | jq -r '.config.digest // empty')"
[ -n "$CONFIG" ] || { echo "image-revision: ${IMAGE_PATH}:${REF} has no config descriptor" >&2; exit 1; }
curl -fsSL --max-time 30 -H "Authorization: Bearer $TOKEN" \
"https://ghcr.io/v2/${IMAGE_PATH}/blobs/${CONFIG}" \
| jq -r '.config.Labels["org.opencontainers.image.revision"] // ""' \
| sed 's/^unknown$//'
+169 -47
View File
@@ -282,14 +282,20 @@ jobs:
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Version, derived ONCE — and reused on a resume
- name: Claim a version — atomically, before anything is built
id: ver
env:
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
# The commit being released, named explicitly rather than read from
# GITHUB_SHA: that variable is the runner's to set, this claim is the
# workflow's to make, and a claim that silently reads an empty string
# would tag every release at the same (invalid) sha.
SHA: ${{ github.sha }}
run: |
set -euo pipefail
[ -n "${SHA:-}" ] || { echo "::error::no commit sha — refusing to claim a version for an unknown commit"; exit 1; }
# THE DIGEST OF THE DOCUMENT, computed here and carried by every car
# below. This is the coupler: a projection generated from any other
@@ -297,19 +303,20 @@ jobs:
SPEC_SHA=$(sha256sum openapi.yaml | cut -d' ' -f1)
echo "spec_sha256=$SPEC_SHA" >> "$GITHUB_OUTPUT"
# RESUME. A release that failed at a later car is re-run at the SAME
# sha, and must not mint a second version for one commit — that is how
# a tag comes to name bytes nobody smoked. If a v* tag already points
# here, this run IS that release: take its number and let every step
# below recognise its own receipt.
MINE=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)
if [ -n "$MINE" ]; then
echo "version=${MINE#v}" >> "$GITHUB_OUTPUT"
echo "resumed=1" >> "$GITHUB_OUTPUT"
echo "resuming ${MINE} — already tagged at this commit"
exit 0
fi
echo "resumed=0" >> "$GITHUB_OUTPUT"
# RESUME IS NOT A SEPARATE PATH ANY MORE. It used to be decided here, by
# looking for a v* tag on HEAD and, if one existed, adopting its number
# and skipping the build. That reasoning depended on the tag being
# minted AFTER a proven image, so "tagged" implied "published". The
# claim below inverts that order deliberately, which makes the same
# check actively wrong: a tag now exists from the moment a version is
# claimed, so a run that died during its build would find its own tag,
# declare itself resumed, and ship a version whose image was never
# built.
#
# So resume is decided by the REGISTRY, further down, once the claim has
# established which number is ours: the tag says what we own, and the
# image says how far we got. One question each, to the system that
# actually knows the answer.
TOKEN=$(curl -fsSL -u "$GHCR_USER:$GHCR_TOKEN" \
"https://ghcr.io/token?scope=repository:hanzoai/cloud:pull&service=ghcr.io" | jq -r .token)
@@ -346,22 +353,111 @@ jobs:
exit 1
fi
# ── THE CLAIM ─────────────────────────────────────────────────────
#
# A version is not a number this run CHOSE. It is a number this run
# OWNS, and the owning act is creating refs/tags/v<N> at our sha.
# Ref creation is the ONLY operation in this pipeline the server
# performs as a compare-and-swap: 201 when the ref did not exist,
# 422 when it did, decided under the server's own lock. Everything
# else here — the registry probe, the tag list, the max() — is a
# READ, and a read cannot reserve anything.
#
# The claim used to be taken LAST, by rollout's "Tag the release",
# a whole ~20-minute build after the 404 probe that stood in for it.
# Two lanes starting inside that window both probed 404, both built,
# and both pushed — and A GHCR TAG IS MUTABLE, so the second push
# silently REPLACED the first's bytes under the same name. v1.801.361
# was overwritten at 04:40:53; v1.801.410 again at 08:17:12 by an
# image carrying no revision label. The losing lane then died at the
# tag step — long after it had already corrupted the winner's image,
# which the winner went on to pin. A check that is 20 minutes from
# the act it guards is not a check.
#
# Claiming FIRST inverts every one of those outcomes. The loser finds
# out in one HTTP call, before it has built anything, and simply takes
# the next number. Two commits can never hold one version, so no push
# can ever land on a name another lane owns, so tag -> commit is fixed
# before the image exists rather than asserted after it.
#
# A claim that is never built leaves a HOLE — a tag with no image.
# That is the correct direction to fail: a hole is visible and inert
# (pin.sh refuses a tag that does not resolve), whereas a reused
# number is invisible and serves the wrong bytes.
IFS=. read -r MAJ MIN PAT <<<"$LAST"
NEXT="$MAJ.$MIN.$((PAT + 1))"
CLAIMED=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
PAT=$((PAT + 1))
CAND="$MAJ.$MIN.$PAT"
# 404 is the ONLY acceptable answer: anything else means the tag is
# taken (200) or we cannot tell (network/auth), and pushing on either
# risks a second digest behind an existing name.
# THE COMPARE-AND-SWAP.
CODE=$(curl -s -o /tmp/claim.json -w '%{http_code}' -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H 'Accept: application/vnd.github+json' \
"https://api.github.com/repos/hanzoai/cloud/git/refs" \
-d "{\"ref\":\"refs/tags/v${CAND}\",\"sha\":\"${SHA}\"}")
if [ "$CODE" = "422" ]; then
# TWO DIFFERENT FAILURES SHARE THIS STATUS, and treating them alike
# would turn one of them into a ten-attempt loop that ends in the
# wrong diagnosis. "Reference already exists" is the collision this
# loop is for. "Object does not exist" means OUR OWN COMMIT is not
# on github.com — which is a live possibility, because this workflow
# runs on git.hanzo.ai and claims against GitHub, so a commit that
# reached the forge and not the mirror lands exactly here. It is not
# a name to skip past; it is a repo that has not been published, and
# the next number would fail identically.
WHY=$(jq -r '.message // empty' /tmp/claim.json)
if [ "$WHY" != "Reference already exists" ]; then
echo "::error::claiming v${CAND} was refused with: ${WHY:-unknown}. If this is 'Object does not exist', commit ${SHA} is on the forge but not on github.com — the release lane claims versions against GitHub, so the mirror must carry the commit first."
exit 1
fi
# Taken. By us, or by somebody else? The distinction is the whole
# difference between a resume and a collision, and it is one GET.
HAVE=$(curl -fsS -H "Authorization: Bearer $GH_PAT" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/v${CAND}" | jq -r '.object.sha // empty')
if [ "$HAVE" = "${SHA}" ]; then
echo "v${CAND} is already claimed at our sha — this release, resumed"
CLAIMED="$CAND"; break
fi
echo "v${CAND} is held by ${HAVE:-another ref} — trying the next number"
continue
fi
if [ "$CODE" != "201" ]; then
echo "::error::claiming v${CAND} returned $CODE (expected 201 or 422) — refusing to build a version this run cannot prove it owns"
cat /tmp/claim.json; exit 1
fi
echo "claimed v${CAND} at ${SHA}"
CLAIMED="$CAND"; break
done
[ -n "$CLAIMED" ] || { echo "::error::could not claim a version in 10 attempts"; exit 1; }
NEXT="$CLAIMED"
# WE OWN THE NAME — so anything already published under it is either
# our own earlier attempt or a lane that had no right to it, and those
# two need opposite handling. `resumed` is therefore derived from the
# IMAGE, not from the tag: since the claim now precedes the build, a
# tag at our sha no longer implies bytes exist.
RESUMED=0
CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
"https://ghcr.io/v2/hanzoai/cloud/manifests/v$NEXT")
if [ "$CODE" != "404" ]; then
echo "::error::refusing to push v$NEXT — manifest probe returned $CODE, expected 404 (tag taken, or existence unverifiable)"
if [ "$CODE" = "200" ]; then
REV=$(bash .hanzo/scripts/image-revision.sh "hanzoai/cloud" "v$NEXT" "$TOKEN" || echo "")
if [ "$REV" = "${SHA}" ]; then
echo "v$NEXT is already published from our sha — skipping the build"
RESUMED=1
else
echo "::error::v$NEXT is a version this run OWNS (tag at ${SHA}) but the registry already serves bytes built from '${REV:-an unlabelled commit}'. Another lane pushed onto a name it did not hold. Nothing here may overwrite it — publish the intended bytes under a new number and delete the foreign image."
exit 1
fi
elif [ "$CODE" != "404" ]; then
echo "::error::manifest probe for v$NEXT returned $CODE — cannot tell whether the name is free"
exit 1
fi
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
echo "highest seen v$LAST (registry + git tags) -> building v$NEXT (document sha256:$SPEC_SHA)"
echo "resumed=$RESUMED" >> "$GITHUB_OUTPUT"
echo "highest seen v$LAST (registry + git tags) -> v$NEXT, claimed at ${SHA} (document sha256:$SPEC_SHA)"
- uses: docker/setup-buildx-action@v3
if: steps.ver.outputs.resumed == '0'
@@ -379,8 +475,17 @@ jobs:
# VERSION is what the binary reports as X-Api-Version. Without it the
# ldflag falls back to the `dev` default and a released image cannot
# say which release it is — and every car below keys off that header.
# REVISION is passed as a BUILD-ARG, not only as a label, because the
# Dockerfile declares `ARG REVISION=unknown` and stamps the label from
# it. A builder that sets only the outside label leaves that ARG at its
# default, and a builder that sets neither publishes an image whose
# commit is unrecoverable — which is precisely what the platform lane
# did for every image it ever pushed. Feeding the ARG makes the label
# truthful no matter which builder runs the Dockerfile, instead of
# truthful only in the lane that remembers to override it afterwards.
build-args: |
VERSION=v${{ steps.ver.outputs.version }}
REVISION=${{ github.sha }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=v${{ steps.ver.outputs.version }}
@@ -391,15 +496,37 @@ jobs:
# build-push-action can exit 0 before the manifest resolves, so a green run
# could still mean a future ImagePullBackOff. Prove it pulls BEFORE the pin
# moves — pinning an image the registry cannot serve has no rollback path.
- name: Verify the pushed image resolves
#
# AND prove the bytes behind the tag are OURS. Resolving only shows that
# SOMETHING is there; it says nothing about whose. This is the moment the
# image -> commit link is established, and the moment a clobber is still
# cheap to catch: the claim above makes a collision impossible between two
# lanes that both honour it, but a lane that does not (the platform
# buildctl path pushed onto v1.801.410 twelve minutes after this lane did)
# is exactly what an invariant has to survive. Reading the revision label
# back off the registry — not off our own build output — is the difference
# between believing the push landed and knowing it did.
- name: Verify the pushed image resolves, and is the commit we built
env:
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
set -euo pipefail
img="ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}"
ok=0
for i in 1 2 3 4 5 6; do
docker buildx imagetools inspect "$img" >/dev/null 2>&1 && { echo "resolved $img"; exit 0; }
docker buildx imagetools inspect "$img" >/dev/null 2>&1 && { ok=1; break; }
sleep 5
done
echo "::error::pushed image never resolved: $img"; exit 1
[ "$ok" = 1 ] || { echo "::error::pushed image never resolved: $img"; exit 1; }
echo "resolved $img"
REV=$(bash .hanzo/scripts/image-revision.sh "hanzoai/cloud" "v${{ steps.ver.outputs.version }}")
if [ "$REV" != "${{ github.sha }}" ]; then
echo "::error::${img} resolves, but the bytes behind that tag were built from '${REV:-an unlabelled commit}', not ${{ github.sha }}. Another lane pushed over the tag this run owns. Nothing downstream may pin it."
exit 1
fi
echo "$img is built from ${{ github.sha }} — tag, commit and image agree"
# THE SMOKE GATE. Boot the image that was actually pushed and require it to
# reach "zip listening" without a crash signature, on release.go's boot env
@@ -460,37 +587,32 @@ jobs:
steps:
- uses: actions/checkout@v4
# The receipt, minted only now: build pushed it, smoke proved it boots. A
# tag can therefore never name an image that did not start.
# THE TAG IS NOT MINTED HERE ANY MORE — it was claimed before the build, as
# the compare-and-swap that made this version this run's to build (see the
# `image` job's claim step). Minting it here was the bug: for the whole
# length of a build, a number was "taken" only in the sense that a lane
# INTENDED to take it, and two lanes intending the same number both pushed
# images before either reached this step. The winner's tag then named the
# loser's bytes, and the loser died here — after the damage.
#
# 422 USED TO BE A HARD ERROR, and that is exactly what made a resume
# impossible: the second run of a release whose fanout failed died here
# instead of continuing. A 422 whose ref already points at OUR sha is this
# same release, already receipted — idempotent success. A 422 pointing
# anywhere else is a genuine collision and still fails.
- name: Tag the release
# What remains is the assertion that nothing moved underneath us. A git tag
# is not mutable by accident, so this is expected to be quiet; it is here
# because the one thing worse than a moved tag is a moved tag that shipped.
- name: The claimed tag still names this commit
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
TAG="v${{ needs.image.outputs.version }}"
CODE=$(curl -s -o /tmp/tag.json -w '%{http_code}' -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H 'Accept: application/vnd.github+json' \
"https://api.github.com/repos/hanzoai/cloud/git/refs" \
-d "{\"ref\":\"refs/tags/${TAG}\",\"sha\":\"${{ github.sha }}\"}")
if [ "$CODE" = "422" ]; then
HAVE=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/${TAG}" | jq -r '.object.sha')
if [ "$HAVE" = "${{ github.sha }}" ]; then
echo "${TAG} already names ${HAVE} — this release, resumed"; exit 0
fi
echo "::error::tag ${TAG} exists and names ${HAVE}, not ${{ github.sha }}"; cat /tmp/tag.json; exit 1
HAVE=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/${TAG}" | jq -r '.object.sha // empty')
if [ -z "$HAVE" ]; then
echo "::error::${TAG} was claimed by this run but no longer exists — refusing to ship a release whose receipt was deleted"; exit 1
fi
if [ "$CODE" != "201" ]; then
echo "::error::create tag ${TAG}: status $CODE"; cat /tmp/tag.json; exit 1
if [ "$HAVE" != "${{ github.sha }}" ]; then
echo "::error::${TAG} now names ${HAVE}, not ${{ github.sha }} — the claim was overwritten. Nothing here may pin."; exit 1
fi
echo "minted ${TAG} at ${{ github.sha }}"
echo "${TAG} names ${{ github.sha }}, as claimed"
# THE DEPLOY. cd.hanzo.ai watches hanzoai/universe, not the registry, so an
# image nothing points at is just bytes in ghcr.
+1 -1
View File
@@ -44,7 +44,7 @@
# BUMP: when a console/skills change must reach production, move its pin here in
# the same commit that claims it. That is what makes a cloud release
# reproducible and makes "what console is in v1.801.N" answerable from git.
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:sha-147ecd3-amd64
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:sha-a0a4899-amd64
ARG SKILLS_IMAGE=ghcr.io/hanzoai/agent-skills:sha-b931a11-amd64
# ── toolchain base images: the golang + alpine FROMs below pull from our own
+71
View File
@@ -4214,3 +4214,74 @@ SDK and every MCP tool list that answer 404 in production: the same dark hole th
`api-hanzo-ai-catalog` router opened under `/v1/models` and `/v1/pricing`, which
cost 17 documented-but-uncallable operations. Re-home that surface only after the
upstream ships it, and prove the upstream answers before declaring anything.
## /v1/risk/reference — the reference plane: two stores, one precedence, versions on the wire
`apps/reference` is the lookup data a risk decision needs but cannot derive:
disposable email domains, hosting and Tor address ranges, crawler user-agent
patterns, delegated autonomous system numbers, card-scheme prefixes, browsers
the fleet sees everywhere, and how current the designation lists the screening
engine holds actually are. It is a row of its own in `manifest/apps.go` (one
prefix, `/v1/risk/reference`), preceding `risk`'s bare `/v1/risk`, so
longest-prefix separates them and neither row moves. It addresses under the risk
product for the same reason `label` does: `openapi.Product` reads the product off
the first `/v1` segment, and these six operations belong to the risk product and
not to the KServe model-SERVING product on `/v1/ml`. Six operations, all typed.
**The unit of version and freshness is the SOURCE, not the set.** A set is the
union of its publishers, and each carries its own version, its own as-of and its
own failure. `net` draws on eleven publishers; a set-wide version would make one
publisher's outage either block every other publisher's update or silently
shrink the set. Per source, a publisher that stops answering ages out visibly on
its own row. The set's version is the composition (`aws@<digest>+tor@<digest>+…`),
so a decision records one string an auditor resolves back to a publisher, a
licence and a date, and the set's as-of is the OLDEST contributing publisher —
reporting the newest would let one daily source hide three that died.
**A version IS the content digest, taken over the sorted entries and not over the
bytes.** That is what makes ingest idempotent: the same set is the same version,
so re-taking an unchanged publisher writes zero rows and says `unchanged`, while
a publisher who reorders their file has not minted anything. Resumability is a
`landed` cursor on the manifest, and it is an OPTIMISATION only — the primary key
is `(set, source, version, key)` on a ReplacingMergeTree, so re-writing a chunk
that already landed produces rows the merge deduplicates.
**Two planes, two STORES, and that is the isolation argument.** The baseline
lives in `hanzo.reference_source` + `hanzo.reference_entry`, whose DDL has NO
tenant column — there is nowhere in the shape for an organisation to go, so the
cross-tenant write is unrepresentable rather than refused, and no In struct
carries a scope, org or tenant field either. A tenant's own allow/deny entries
live in that org's own SQLite file via `cloud.OrgNamespace`, which is the same
physical isolation every other per-entity store has. Resolution is override
first, baseline second, first hit wins — and BOTH go through one `candidates`
function, so a deny on `tempbox.example` covers `mail.tempbox.example` in the
tenant's sense exactly as it does in the baseline's.
**What may enter the baseline: published data under terms we hold, and
aggregates no single org could produce.** Every source states its licence
(`Source.Terms`) and it is on the wire. The one derived set (`device`) publishes
only browser identities seen under at least `Orgs`=25 organisations with
`Rows`=1000 observations — `Publishable`, enforced in the statement's WHERE and
again on read — keyed by a digest rather than the identifier, with the count
BANDED. `TestDeriveRefusesWhatOneOrgProduced` is the proof: one organisation
producing a million observations yields an EMPTY baseline.
**A source we may not redistribute is a declared SEAM, not an omission.**
`pep`, `issuer` and `reputation` are in the catalog with the licence we do not
hold as their refusal, and every lookup against them refuses. An absent set and
an unlicensed one look identical from outside and only one of them is a decision.
**Silence is never clean, and staleness is a signal.** A set that never loaded,
one held by the engine that screens against it, and one behind a licence all
answer with `refusal` — a caller reading `hit=false` without reading `refusal` is
reading "we have no idea" as "not listed". A set past `MaxAge` still answers,
because yesterday's list beats none, and every answer carries `version`, `asOf`,
`age` and `stale`. `POST /v1/risk/reference/resolve` returns `consulted` — one
version line per set — which is what a decision records.
**Restart drops the snapshots, and that is handled rather than papered over.**
cloud is `strategy: Recreate` at one replica, so every rollout starts from
nothing; `Mount` starts a loop that hydrates from the warehouse (no network) and
until it succeeds every set refuses. Re-taking happens at HALF the freshness
bound, because a set refreshed only once it is already stale is stale for the
whole interval between the two.
+10 -8
View File
@@ -179,15 +179,17 @@ hanzo: ## Build the control CLI into ./bin/hanzo (links cli and nothing else).
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) -X main.version=$(VERSION)" -o bin/$@ ./cmd/$@
# Builds the host plus EXACTLY the plugins it is told to mount — not all 106.
# The host resolves a plugin as a file beside itself (manifest.App.Plugin), so a
# name in RUN_ENABLE with no binary in ./bin is the one way this fails; building
# that same list here is what keeps the two in step.
RUN_ENABLE ?= iam,base,kms,gateway,o11y
# Builds the host plus the plugins you want to exercise locally — not all 106.
# The host mounts what manifest.Apps lists and resolves each plugin as a file
# beside itself (manifest.App.Plugin); a lazy one with no binary simply never
# starts, and a Required one fails loudly. So this list is a BUILD list, not a
# mount list — the binary has never taken one, and stating the app set a second
# time is what took devnet down twice.
RUN_PLUGINS ?= iam,base,kms,gateway,o11y
run: cloud ## Run the host with iam,base,kms,gateway,o11y (matches README quickstart); builds just those plugins.
@for a in $$(echo $(RUN_ENABLE) | tr ',' ' '); do $(MAKE) --no-print-directory plugin APP=$$a; done
./bin/cloud --enable=$(RUN_ENABLE)
run: cloud ## Run the host, building the plugins in RUN_PLUGINS (iam,base,kms,gateway,o11y).
@for a in $$(echo $(RUN_PLUGINS) | tr ',' ' '); do $(MAKE) --no-print-directory plugin APP=$$a; done
./bin/cloud
smoke: ## Build and run the smoke prober (mount-time integration check).
$(GO) run ./plugin/smoke
+4 -4
View File
@@ -120,10 +120,10 @@ in its own `plugin/<name>/main.go`.
Same artifact; different startup configuration:
```bash
cloud --enable=iam,base,kms,commerce,ai,gateway,o11y --brand=hanzo --domain=hanzo.ai
cloud --enable=iam,base,kms,commerce,ai,gateway,o11y --brand=osage --domain=osage.cloud
cloud --enable=iam,base,kms,commerce,ai,gateway,o11y --brand=lux --domain=lux.cloud
cloud --enable=iam,base,kms,commerce,ai,gateway,o11y --brand=zoo --domain=zoo.cloud
cloud --brand=hanzo --domain=hanzo.ai
cloud --brand=osage --domain=osage.cloud
cloud --brand=lux --domain=lux.cloud
cloud --brand=zoo --domain=zoo.cloud
```
## Architecture
+2 -2
View File
@@ -67,7 +67,7 @@ func TestCredentialClass_ReadsTheCredentialNotTheClient(t *testing.T) {
if tc.ua != "" {
req.Header.Set("User-Agent", tc.ua)
}
if _, err := app.Fiber().Test(req); err != nil {
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got != tc.want {
@@ -148,7 +148,7 @@ func TestCredentialClass_UsesTheBoundarysOwnResolution(t *testing.T) {
req.Header.Set("X-Org-Id", "acme")
req.Header.Set("X-User-Id", "u-acme")
tc.set(req)
if _, err := app.Fiber().Test(req); err != nil {
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if class != tc.wantClass {
+83
View File
@@ -0,0 +1,83 @@
package cloud
// Inference reached over the peer's own socket.
//
// `ai` is a plugin of this same binary running as its own process. Its routes
// ride its unix socket exactly as they ride a public listener — zip's plane is
// "an ordinary route on the app … ZAP over a unix socket is simply the address
// the caller dialed" — so a sibling speaks the ordinary OpenAI-compatible wire
// to it WITHOUT leaving the host.
//
// What that deletes is the whole reason the old path existed:
//
// base_url https://api.hanzo.ai/v1 the pod's OWN public address
// token_url http://iam.hanzo.svc/… a token minted to authenticate to itself
//
// Both were consequences of addressing a peer by URL. There is no address to
// configure here: the socket is derived from the app NAME, the same mapping the
// meter and the ledger already use.
import (
"context"
"net"
"net/http"
"github.com/zap-proto/zip"
)
// aiApp is the app name the socket is derived from. One spelling.
const aiApp = "ai"
// aiPeerURL is the base a socket-dialed call carries. The HOST is inert — the
// transport dials a named peer, not this address — so it names the peer for logs
// and error text and nothing more. The /v1 prefix is real: it is the peer's own
// route prefix.
const aiPeerURL = "http://ai/v1"
// aiRoute answers the two questions a caller has about reaching `ai`: over what
// transport, and under what address. It is ONE decision, shared by the
// completions and the embeddings pickers so they cannot drift into disagreeing
// about where the peer is.
//
// !Enabled(ai) means this process does not carry the app, which is exactly when
// `ai` is a SIBLING and its socket is the honest address. The process that IS
// `ai` keeps the configured one — routing inference back through the picker
// there would be the process calling itself.
func aiRoute(cfg *Config) (http.RoundTripper, string) {
if cfg.Enabled(aiApp) {
return nil, cfg.AIBaseURL
}
return newSocketTransport(aiApp), aiPeerURL
}
// socketRoundTripper speaks HTTP to one app over its canonical unix socket.
//
// It WAKES the peer before dialing, through the same reach() every plane call
// uses: an app is lazy by default, so a sibling that dialed a cold socket would
// read "not deployed here" from what is really "not started yet". reach asks the
// router, which owns the manifest, so absence and outage stay distinguishable.
type socketRoundTripper struct {
app string
next http.RoundTripper
}
func newSocketTransport(app string) http.RoundTripper {
srt := &socketRoundTripper{app: app}
srt.next = &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
// network and address are DISCARDED: the peer is named, not addressed.
// Whatever host the base URL carries is inert here, which is why the
// deployment no longer states one.
return (&net.Dialer{}).DialContext(ctx, "unix", zip.SocketPath(srt.app))
},
}
return srt
}
func (s *socketRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
bindRuntimeDir()
if err := reach(r.Context(), s.app); err != nil {
return nil, err
}
return s.next.RoundTrip(r)
}
+52
View File
@@ -0,0 +1,52 @@
package cloud
import "testing"
// A SIBLING REACHES `ai` OVER ITS SOCKET, NOT THROUGH THE INTERNET.
//
// `ai` is a plugin of this same binary running as its own process. Addressing it
// by its public URL sent a completion out through Cloudflare and back, and made
// the pod mint an OAuth token to authenticate to its own deployment. Which
// transport a process gets is decided by WHAT IT IS, never by configuration.
func TestSiblingReachesAIOverItsSocket(t *testing.T) {
sibling := &Config{Enable: []string{"agents"}, AIBaseURL: "https://api.hanzo.ai/v1"}
via, base := aiRoute(sibling)
if via == nil {
t.Error("a sibling took the default transport — it would leave the host to reach a peer")
}
if base == "https://api.hanzo.ai/v1" {
t.Error("a sibling addressed `ai` by the pod's OWN public URL")
}
if base != aiPeerURL {
t.Errorf("sibling base = %q, want the named peer %q", base, aiPeerURL)
}
srt, ok := via.(*socketRoundTripper)
if !ok {
t.Fatalf("transport is %T, want the socket one", via)
}
if srt.app != aiApp {
t.Errorf("socket targets %q, want %q — the peer is NAMED, never addressed", srt.app, aiApp)
}
}
// The process that IS `ai` keeps the configured address: routing inference back
// through the picker there would be the process calling itself.
func TestTheAIProcessDoesNotDialItself(t *testing.T) {
self := &Config{Enable: []string{"ai"}, AIBaseURL: "https://api.hanzo.ai/v1"}
via, base := aiRoute(self)
if via != nil {
t.Error("the ai process resolved itself to its own socket — it would call itself")
}
if base != "https://api.hanzo.ai/v1" {
t.Errorf("ai process base = %q, want its configured address", base)
}
}
// The host carries every app, so it is not a sibling either.
func TestTheHostIsNotASibling(t *testing.T) {
host := &Config{AIBaseURL: "https://api.hanzo.ai/v1"} // empty Enable = carries all
if via, _ := aiRoute(host); via != nil {
t.Error("the host took the sibling path while carrying `ai` itself")
}
}
+33 -16
View File
@@ -1,5 +1,4 @@
// Package account is your own account: API keys you mint and revoke, org onboarding,
// and wallet top-up.
// Package account is your own account: API keys you mint and revoke, and org onboarding.
//
// It mounts the signed-in caller's OWN self-service surface natively in the unified
// cloud binary — the Go port of the console's two NON-proxy Next server routes
@@ -12,9 +11,8 @@
// reverse-proxies — app/cloud, app/ai — vanish in the one-binary model: the SPA calls
// the canonical /v1/* on its own origin and the already-mounted subsystems answer. The
// routes ported HERE do REAL server work a static SPA cannot: keys/onboard run
// privileged IAM logic as the confidential `hanzo-console` client, and
// embed/topup do server-side verification. Each has no pure-proxy equivalent,
// so it must be ported.
// privileged IAM logic as the confidential `hanzo-console` client, and embed does
// server-side verification. Each has no pure-proxy equivalent, so it must be ported.
//
// The billing and store DATA are not among them, and the difference is the whole
// lesson. They were ported as two catch-all forwarders — GET|POST /v1/billing/* and
@@ -39,8 +37,6 @@
// POST /v1/orgs — create the caller's org (+ move them in on first run).
// GET /v1/csrf — mint the anti-CSRF token the SPA echoes on money writes (csrf.go).
// GET /v1/embed — brand-app embed entitlement + reachability probe (embed.go).
// POST /v1/commerce/topup/wallet — HUSD on-chain verify → commerce credit (topup.go).
// GET /v1/commerce/topup/rails — the accepted on-chain rails the send UI renders (topup.go).
//
// ONE SUBSYSTEM REGISTRATION, at order 48. The order is a convention, not the
// protection: the fiber fork inserts endpoint routes MOST-SPECIFIC-FIRST regardless of
@@ -106,6 +102,10 @@ type state struct {
iam *iamClient
csrfKey []byte // keyed-BLAKE3 MAC key for the money-write CSRF token (csrf.go)
writesRL *rateLimiter // per-IP abuse cap on the money-write routes (ratelimit.go)
// vfs is cloud's blob seam (deps.VFS) — where a profile photo's bytes live
// (avatar.go). NewBase does not carry it, so it is taken from deps here, the
// same way apps/team's files plane takes it.
vfs cloud.VFSClient
}
// keysWriteRatePerMin caps money-write frequency per client IP (mint/rotate/revoke
@@ -117,7 +117,7 @@ const keysWriteRatePerMin = 30
// singleton (csrf.go), so a token minted here verifies wherever it is echoed.
func newService(deps cloud.Deps) *cloud.Service[state] {
b := cloud.NewBase(deps, "account")
st := state{iam: newIAMClient()}
st := state{iam: newIAMClient(), vfs: deps.VFS}
st.csrfKey = sharedCSRFKey(b.Log)
st.writesRL = newRateLimiter(keysWriteRatePerMin)
return &cloud.Service[state]{Base: b, State: st}
@@ -226,14 +226,31 @@ func routesAccount(s *cloud.Service[state], app cloud.Router) error {
zip.Post(guard, "/orgs", o.onboard)
// Console module embed-entitlement + reachability probe (embed.go).
zip.Get(open, "/embed", o.embedStatus)
// HUSD wallet top-up (on-chain verify → commerce credit). A SPECIFIC commerce route
// that must beat the commerce embed (100), so it mounts here at 48, ahead of it.
zip.Post(write, "/commerce/topup/wallet", o.walletTopup)
// The accepted rails are public on-chain data (chain, token, treasury), read by
// the browser to render the send UI. A GET with no side effects and no secret,
// so it needs neither CSRF nor the write limiter — but it MUST sit beside the
// POST at this priority, for the same reason.
zip.Get(open, "/commerce/topup/rails", o.topupRails)
// The crypto wallet top-up (POST /commerce/topup/wallet + GET /commerce/topup/rails)
// used to mount here. It verified an on-chain transfer and then recorded the credit
// to commerce at POST /v1/billing/payment — an address NO app in either server repo
// has EVER registered, in any commit. So the last step of the only path that credited
// anything always failed, and a customer who had already sent real USDC to the
// treasury got a 502 for it. It was 501 besides: TOPUP_RAILS is configured in no
// environment, so `configured()` was false everywhere and the surface never took a
// cent.
//
// It is not a rename and there was nothing to point it at. Money-IN has ONE door
// (commerce's mint-gated POST /v1/billing/deposit, which requires an
// X-Idempotency-Key naming the settlement or tx hash that caused the credit), and
// the fleet deliberately routes NO mint address at the edge — the only two money-in
// paths manifest.Apps hands to an app are the card ones, both with a
// server-authoritative amount. Wiring this to the mint would newly expose that
// surface, which is a money decision and not a routing fix, so the phantom is
// deleted rather than plumbed. Deciding to accept crypto is a product decision that
// starts from the mint gate, not from this handler.
// The signed-in user's profile photo (avatar.go). The write is gated like the
// others here; the read takes no credentials because its whole job is to be an
// <img src> from another origin. Both are UNTYPED and cannot be otherwise —
// multipart in, raw image bytes out — which is why they are the only two names
// in typed_wire_test.go's refusal list.
registerAvatar(o, open, limit, csrf)
return nil
}
+33 -4
View File
@@ -37,9 +37,17 @@ type fakeIAM struct {
revokedFor []string
revokedType []string
movedTo map[string]string // id → new owner (from update-user)
// rows is every row update-user was asked to write, whole. movedTo keeps only
// the owner, which is all the onboarding move needed; the profile photo is a
// different field of the same write, so the row itself is what a test must see.
rows []map[string]any
createdOrgs []map[string]any
failAddOrg bool // when true, add-organization answers status!=ok
failMintKey bool
// failUpdateUser models an IAM that accepts the read but refuses the write —
// the state where a profile photo's bytes have landed and the record pointing
// at them has not.
failUpdateUser bool
// ignoreKeyType models an IAM that predates the type field: it drops the
// parameter and mints the secret key it always did.
ignoreKeyType bool
@@ -81,7 +89,23 @@ func (f *fakeIAM) server(t *testing.T) *httptest.Server {
mux.HandleFunc("/v1/iam/users/get", func(w http.ResponseWriter, r *http.Request) {
f.capture(r)
id := r.URL.Query().Get("id")
// IAM keys this read on owner+name, NOT on the `<owner>/<name>` composite —
// measured against the running service, where every `?id=` form answers
// 400 "field \"owner\" is required". The fake insists on the same shape so
// a client that regresses to `id` fails here instead of in production.
q := r.URL.Query()
owner, name := q.Get("owner"), q.Get("name")
id := q.Get("id")
if owner != "" || name != "" {
// The shape IAM actually accepts. A client that regresses to the
// `<owner>/<name>` composite for a caller that HAS an owner gets the
// same 400 the running service gives.
if owner == "" || name == "" {
bad(w, `field "owner" is required`)
return
}
id = owner + "/" + name
}
f.mu.Lock()
defer f.mu.Unlock()
if row, present := f.user[id]; present {
@@ -199,6 +223,11 @@ func (f *fakeIAM) server(t *testing.T) *httptest.Server {
_ = json.Unmarshal(body, &row)
f.mu.Lock()
defer f.mu.Unlock()
if f.failUpdateUser {
bad(w, "update refused")
return
}
f.rows = append(f.rows, row)
if owner, _ := row["owner"].(string); owner != "" {
f.movedTo[id] = owner
}
@@ -257,7 +286,7 @@ func callH(t *testing.T, app *zip.App, method, path string, headers map[string]s
req.Header.Set(k, v)
}
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -285,7 +314,7 @@ func call(t *testing.T, app *zip.App, method, path, user, org, body string) (int
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -537,7 +566,7 @@ func TestKeys_DirectBearerPath_MintsByUsernameNotUUID(t *testing.T) {
req.Header.Set("X-User-Id", uuid) // direct-path stamp: the subject UUID
req.Header.Set("X-User-Name", "z") // direct-path stamp: the IAM username
req.Header.Set("X-Org-Id", "hanzo")
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
+291
View File
@@ -0,0 +1,291 @@
package account
// The signed-in user's profile photo.
//
// There was no way to set one. IAM carries an `avatar` on every user row and the
// console renders it, but the only writers were FEDERATION (a GitHub avatar_url, an
// OIDC `picture` claim) and SCIM — so a user who signed up with a password had a
// monogram and no way to replace it, and the console's Profile card answered the
// attempt with "Edit in IAM", which links to an IAM that cannot do it either.
// Production agreed: /v1/avatar was a 404 while /v1/keys was a 403.
//
// STORAGE IS deps.VFS — the existing S3 seam (SeaweedFS via clients/s3vfs), which
// was chosen for exactly this: "an adapter+crypto is needless complexity for small
// avatars". No new store, no second blob path.
//
// CONTENT-ADDRESSED. The key ends in the sha256 of the bytes, so a photo has ONE
// address that never means anything else. That is what makes the read cacheable
// forever and what makes replacing a photo a new URL rather than a stale one every
// cache in the path still believes — the bug you cannot fix from the server if the
// address is a mutable "…/me.png".
//
// A REPLACED PHOTO IS NOT DELETED. The old key is left behind deliberately: the
// previous URL is already inside issued tokens and rendered pages, and an object
// store costs bytes where a broken face costs a person their profile. Orphans are
// a GC concern, not a correctness one.
//
// THE READ IS UNAUTHENTICATED, AND MUST BE. The URL's whole job is to be an
// <img src> from console.hanzo.ai — a different origin from api.hanzo.ai, which
// sends no cookies and cannot carry an Authorization header. So the address IS the
// capability: 64 hex of sha256 that a caller can only produce by already holding
// the image. This is what every avatar system does, and it is the honest reason,
// not an oversight. What it is NOT is a way to read anything else: the digest is
// verified to be a digest, the org and user are refused unless they are plain
// identifiers, and the response is served only if the STORED BYTES are one of four
// raster formats — so a key cannot address another subsystem's blob and a stored
// object cannot be talked into executing in this origin.
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
"github.com/hanzoai/cloud/internal/magic"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/types"
"github.com/zap-proto/zip"
)
// maxAvatarSize caps one upload. A profile photo is small by nature; this is
// generous enough for a phone camera original and tight enough that the route
// cannot be used as free object storage. The console downscales before sending,
// so this is the backstop, not the working limit.
const maxAvatarSize = 8 << 20
// avatarPrefix is this subsystem's box in the shared blob bucket. deps.VFS is ONE
// bucket keyed by whatever the consumer supplies (clients/s3vfs), so the prefix is
// what keeps account's objects from colliding with team's.
const avatarPrefix = "account/avatars/"
// registerAvatar wires the two routes. They are the only UNTYPED operations in this
// package and cannot be otherwise: the request is a multipart form and the response
// is raw image bytes under a byte-derived Content-Type — neither is a shape a typed
// In/Out can carry (see typed_wire_test.go, which holds that as a closed list).
//
// The write takes the same gates as the other writes here — requireCSRF, because
// the console authenticates with an ambient cookie, and the rate limiter, because
// this one lands bytes in an object store.
func registerAvatar(o ops, open zip.Router, limit, csrf zip.Middleware) {
open.Post("/avatar", limit(csrf(o.putAvatar)))
// The read is deliberately on `open` with no gate: see the file header.
open.Get("/avatar/:org/:user/:digest", o.getAvatar)
}
func init() {
openapi.Describe("/v1/avatar", http.MethodPost,
"Set your profile photo",
"Stores one image as the signed-in user's profile photo and answers the URL it is "+
"served from, which is also written to the user's IAM record — so every surface "+
"that already renders `avatar` picks it up with no further call.\n\n"+
"The body is a multipart form with a `file` part. The format is decided by the "+
"BYTES, never the filename or the part's Content-Type: png, jpeg, gif and webp are "+
"accepted and everything else is refused with 415, so an SVG cannot be stored as a "+
"picture and later served as a program. Over 8 MiB is 413; empty is 400.\n\n"+
"The photo is addressed by the sha256 of its bytes, so setting a new one yields a "+
"new URL rather than a stale cache of the old face. The caller is taken from the "+
"validated identity ONLY — there is no way to name a different subject — so this "+
"always sets your own photo, and a caller with no organization yet is refused.")
openapi.Describe("/v1/avatar/:org/:user/:digest", http.MethodGet,
"Fetch a profile photo",
"Streams a profile photo's raw BYTES. This is the address stored on the user's IAM "+
"record and rendered directly by an `<img>`, so it takes no credentials — the "+
"64-hex content digest in the path is the capability, and it can only be produced "+
"by someone who already has the image.\n\n"+
"The Content-Type is derived from the stored bytes and the response carries "+
"nosniff, so only a real raster image is ever served and only under its true type. "+
"Anything else — a miss, a malformed path, an object that is not an image — is one "+
"404, and a hit caches for a year because the address is the content.")
}
// avatarKey is the physical blob address: org and user come from the VALIDATED
// identity (never a request value), and the digest is computed here, so every
// component is server-chosen.
func avatarKey(org, user, digest string) string {
return avatarPrefix + org + "/" + user + "/" + digest
}
// safe reports whether a path component may be used verbatim in a blob key.
//
// It REFUSES rather than sanitizes, and that distinction is the tenancy boundary.
// The sanitizing form of this function (apps/team's seg) folds — "a/b" and "a_b"
// both become "a_b" — and a fold in a key is two tenants sharing one address. A
// refusal cannot collide. These values come from validated IAM claims, so a
// rejection means something upstream is wrong and failing closed is the answer.
func safe(s string) bool {
if s == "" || s == "." || s == ".." || len(s) > 128 {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
default:
return false
}
}
return true
}
// digest reports whether s is exactly a sha256 in lowercase hex. The read path
// checks this before touching the store so a caller cannot use the digest segment
// to address something that is not an avatar.
func digest(s string) bool {
if len(s) != sha256.Size*2 {
return false
}
for _, r := range s {
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
return false
}
}
return true
}
// putAvatar stores the upload and records its URL on the caller's IAM user row.
func (o ops) putAvatar(c *zip.Ctx) error {
cr, ok := resolveCaller(c, true) // requireOwner: the key is org-scoped
if !ok {
return zip.ErrUnauthorized("sign in to set a profile photo")
}
if o.s.State.vfs == nil {
return zip.Errorf(http.StatusNotImplemented, "photo storage is not configured on this deployment")
}
if !safe(cr.owner) || !safe(cr.name) {
// Validated claims that cannot address a blob. Fail closed rather than fold
// two identities onto one key.
return zip.Errorf(http.StatusUnprocessableEntity, "this account's identity cannot address a photo")
}
fh, err := c.Fiber().FormFile("file")
if err != nil || fh == nil {
return zip.ErrBadRequest(`multipart field "file" required`)
}
if fh.Size > maxAvatarSize {
return zip.Errorf(http.StatusRequestEntityTooLarge, "photo too large (max %d bytes)", maxAvatarSize)
}
f, err := fh.Open()
if err != nil {
return zip.ErrBadRequest("cannot read upload")
}
defer func() { _ = f.Close() }()
data := make([]byte, 0, fh.Size)
buf := make([]byte, 32<<10)
for len(data) <= maxAvatarSize {
n, rerr := f.Read(buf)
data = append(data, buf[:n]...)
if rerr != nil {
break
}
}
if len(data) == 0 {
return zip.ErrBadRequest("empty upload")
}
if len(data) > maxAvatarSize {
return zip.Errorf(http.StatusRequestEntityTooLarge, "photo too large (max %d bytes)", maxAvatarSize)
}
// The format is decided by the BYTES. A name and a part Content-Type are the
// client's to choose, so neither may decide what this origin later serves.
kind := magic.Type(data)
if kind == "" {
return zip.Errorf(http.StatusUnsupportedMediaType,
"a profile photo must be a PNG, JPEG, GIF or WebP image")
}
sum := sha256.Sum256(data)
dg := hex.EncodeToString(sum[:])
key := avatarKey(cr.owner, cr.name, dg)
if err := o.s.State.vfs.Put(c.Context(), key, data); err != nil {
// deps.VFS is the fail-closed stub unless an object store is wired: an honest
// 502, never a success we did not perform.
o.s.Log.Error("avatar: blob store write failed", "key", key, "err", err)
return zip.Errorf(http.StatusBadGateway, "photo storage unavailable")
}
url := o.avatarURL(c, cr.owner, cr.name, dg)
// IAM is the system of record for `avatar` — every surface already reads it from
// there, so writing it here is what makes the photo appear everywhere instead of
// only in whatever called this.
//
// keyID(), not id: IAM's user ops parse `<owner>/<name>` through
// GetOwnerAndNameFromId, and on the direct-Bearer path X-User-Id is a UUID, so
// `<owner>/<uuid>` is not a user IAM can find. Measured in production —
// `iam non-envelope response (400)` for id hanzo/2d4d67ab-…, the photo stored
// and the profile not updated. keyID() is the same composite the key ops
// already use for the same reason; on the gateway path the two are identical.
if err := o.s.State.iam.setAvatar(c.Context(), cr.keyID(), url); err != nil {
switch {
case errors.Is(err, errNotConfigured):
return zip.Errorf(http.StatusNotImplemented, "identity service is not configured on this deployment")
case errors.Is(err, errNotFound):
return zip.ErrNotFound("no such user")
}
o.s.Log.Error("avatar: iam update failed", "id", cr.id, "err", err)
// The bytes landed but the record did not, so the photo is stored and not
// shown. Say that, rather than reporting a success the user cannot see.
return zip.Errorf(http.StatusBadGateway, "photo stored but the profile could not be updated; try again")
}
return c.JSON(http.StatusOK, map[string]string{"avatar": url})
}
// avatarURL builds the absolute address the photo is served from. It must be
// absolute: it is written into IAM and rendered by an <img> on OTHER origins
// (console.hanzo.ai), where a relative path would resolve against the wrong host.
// Domain is the deployment's own public API host (CLOUD_DOMAIN, api.hanzo.ai),
// falling back to the request's host so a non-default deployment still answers with
// itself rather than with production.
func (o ops) avatarURL(c *zip.Ctx, org, user, dg string) string {
host := strings.TrimSpace(o.s.Domain)
if host == "" {
host = strings.TrimSpace(c.Host())
}
scheme := "https://"
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
scheme = "http://"
}
return scheme + host + "/v1/avatar/" + org + "/" + user + "/" + dg
}
// getAvatar streams a stored photo. No credentials — see the file header.
func (o ops) getAvatar(c *zip.Ctx) error {
org, user, dg := c.Param("org"), c.Param("user"), c.Param("digest")
// Every denial below is the SAME 404: a malformed path, a miss and a key that
// belongs to nothing all reveal exactly nothing about what exists.
if !safe(org) || !safe(user) || !digest(dg) {
return zip.ErrNotFound("no such photo")
}
if o.s.State.vfs == nil {
return zip.ErrNotFound("no such photo")
}
data, err := o.s.State.vfs.Get(c.Context(), avatarKey(org, user, dg))
switch {
case errors.Is(err, types.ErrBlobNotFound), err == nil && data == nil:
return zip.ErrNotFound("no such photo")
case err != nil:
// Backend unavailable → fail closed with 502, never an empty 200 a browser
// would cache as "this user has no face".
return zip.Errorf(http.StatusBadGateway, "photo storage unavailable")
}
// Defense in depth: the upload already refused anything that is not a raster
// image, so this can only fire on an object written by some other path. Serving
// it inline under a guessed type is the XSS the allow-list exists to prevent.
kind := magic.Type(data)
if kind == "" {
return zip.ErrNotFound("no such photo")
}
c.SetHeader("Content-Type", kind)
c.SetHeader("X-Content-Type-Options", "nosniff")
// The address IS the content, so it can never go stale. `public` because the
// route takes no credentials — a shared cache holds nothing private that the
// URL itself did not already grant.
c.SetHeader("Cache-Control", "public, max-age=31536000, immutable")
return c.Bytes(http.StatusOK, data)
}
// avatarFor is the URL a stored digest is served from, used by tests and by any
// caller that needs to name a photo it did not just upload.
func avatarFor(domain, org, user, dg string) string {
return fmt.Sprintf("https://%s/v1/avatar/%s/%s/%s", domain, org, user, dg)
}
+484
View File
@@ -0,0 +1,484 @@
package account
// The profile-photo surface, end to end on a real mounted app.
//
// The bug these cover is an ABSENCE — there was no way to set a photo at all, and
// production said so (/v1/avatar 404 while /v1/keys 403). So the first test is
// simply that a user can now set one and get it back, and the rest hold the two
// properties that make it safe to serve an uploaded file back from an API origin
// with no credentials: the format is decided by the BYTES, and the address is the
// CONTENT.
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
)
// ── fakes ────────────────────────────────────────────────────────────────────
// memVFS is deps.VFS in a map. failPut makes the object store refuse writes, which
// is the only way to reach the "stored nothing, said so" branch.
type memVFS struct {
mu sync.Mutex
obj map[string][]byte
failPut bool
failGet bool
}
func newMemVFS() *memVFS { return &memVFS{obj: map[string][]byte{}} }
func (m *memVFS) Put(_ context.Context, key string, payload []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.failPut {
return fmt.Errorf("object store down")
}
m.obj[key] = append([]byte(nil), payload...)
return nil
}
func (m *memVFS) Get(_ context.Context, key string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.failGet {
return nil, fmt.Errorf("object store down")
}
b, ok := m.obj[key]
if !ok {
return nil, types.ErrBlobNotFound
}
return b, nil
}
func (m *memVFS) Delete(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.obj, key)
return nil
}
func (m *memVFS) keys() []string {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]string, 0, len(m.obj))
for k := range m.obj {
out = append(out, k)
}
return out
}
// lastRow is the whole row update-user was last asked to write. The photo must
// reach the system of record, not only the blob store — a row that never arrived
// means the bytes exist somewhere no surface reads.
func lastRow(t *testing.T, f *fakeIAM) map[string]any {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.rows) == 0 {
t.Fatal("IAM was never asked to update the user row — the photo would exist in the blob store and nowhere a surface reads")
}
return f.rows[len(f.rows)-1]
}
// ── harness ──────────────────────────────────────────────────────────────────
// mountAvatar builds the app with a real object store behind it, on the SAME fake
// IAM every other test in this package uses. The user row carries fields this
// package does not own, so a test can prove the whole-row re-submit preserves them.
func mountAvatar(t *testing.T) (*zip.App, *memVFS, *fakeIAM) {
t.Helper()
f := newFakeIAM()
f.user["hanzo/u-antje"] = map[string]any{
"owner": "hanzo", "name": "u-antje", "password": "$2a$hashed", "displayName": "Antje",
}
t.Setenv("IAM_URL", f.server(t).URL)
t.Setenv("IAM_MINT_CLIENT_ID", "hanzo-console")
t.Setenv("IAM_MINT_CLIENT_SECRET", "s3cr3t")
vfs := newMemVFS()
// The edge body limit PRODUCTION runs (config.go: GATEWAY_BODY_LIMIT, 16 MiB).
// Left at zip's 4 MiB default this app would refuse an oversize upload at the
// framework layer, and the handler's own 413 — the one a person reads — would be
// unreachable and untested. That is the shape of the bug where studio's 4K
// sources could not enqueue: a framework cap below the app's, surfacing as an
// opaque error nobody could act on.
app := zip.New(zip.Config{Logger: luxlog.New("test"), BodyLimit: edgeBodyLimit})
deps := cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo", Domain: "api.hanzo.ai", VFS: vfs}
if err := MountAccount(app, deps); err != nil {
t.Fatalf("MountAccount: %v", err)
}
return app, vfs, f
}
// edgeBodyLimit mirrors config.go's GATEWAY_BODY_LIMIT default.
const edgeBodyLimit = 16 << 20
// The photo cap must sit BELOW the edge body limit, or the framework refuses the
// request first and the caller gets an opaque error instead of "photo too large".
func TestPhotoCapIsReachableBeneathTheEdgeLimit(t *testing.T) {
if maxAvatarSize >= edgeBodyLimit {
t.Fatalf("maxAvatarSize (%d) >= edge body limit (%d): the handler's 413 can never fire, "+
"so an oversize photo fails as a framework error nobody can act on", maxAvatarSize, edgeBodyLimit)
}
}
// onePNG is the smallest thing that is genuinely a PNG by signature.
func onePNG() []byte {
return append([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, []byte("one-pixel")...)
}
// upload POSTs a multipart form exactly as a browser does.
func upload(t *testing.T, app *zip.App, user, org, filename string, data []byte) (int, []byte) {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if filename != "" {
part, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write part: %v", err)
}
} else {
_ = mw.WriteField("notafile", "x")
}
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, "/v1/avatar", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if user != "" {
req.Header.Set("X-User-Id", user)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST /v1/avatar: %v", err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// fetch drives the read route with NO credentials, which is how an <img> loads it.
func fetch(t *testing.T, app *zip.App, path string) (*http.Response, []byte) {
t.Helper()
resp, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil))
if err != nil {
t.Fatalf("Test GET %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp, b
}
func photoURL(t *testing.T, body []byte) string {
t.Helper()
var out struct {
Avatar string `json:"avatar"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode response %q: %v", body, err)
}
if out.Avatar == "" {
t.Fatalf("response carried no avatar url: %s", body)
}
return out.Avatar
}
// path strips the origin so the served app can be asked for it.
func path(url string) string {
if i := strings.Index(url, "/v1/"); i >= 0 {
return url[i:]
}
return url
}
// ── the feature ──────────────────────────────────────────────────────────────
// The whole point: a signed-in user sets a photo and it comes back. Before this
// existed the console offered "Edit in IAM" and IAM had no way to do it either.
func TestSetAndFetchProfilePhoto(t *testing.T) {
app, vfs, iam := mountAvatar(t)
png := onePNG()
code, body := upload(t, app, "u-antje", "hanzo", "me.png", png)
if code != http.StatusOK {
t.Fatalf("upload = %d, want 200: %s", code, body)
}
url := photoURL(t, body)
// The URL is ABSOLUTE and on the deployment's own public host — it is rendered
// by an <img> on console.hanzo.ai, where a relative path would resolve against
// the wrong origin.
sum := sha256.Sum256(png)
want := "https://api.hanzo.ai/v1/avatar/hanzo/u-antje/" + hex.EncodeToString(sum[:])
if url != want {
t.Fatalf("url = %q, want %q", url, want)
}
// It is readable with NO credentials, and under its true type.
resp, got := fetch(t, app, path(url))
if resp.StatusCode != http.StatusOK {
t.Fatalf("fetch = %d, want 200 — an <img> sends no credentials", resp.StatusCode)
}
if !bytes.Equal(got, png) {
t.Fatalf("fetched %d bytes, want the %d uploaded", len(got), len(png))
}
if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type = %q, want image/png", ct)
}
if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("every response must carry nosniff so the browser cannot re-sniff the type")
}
// It reached the system of record, and the re-submit did not blank the row.
row := lastRow(t, iam)
if row["avatar"] != url {
t.Fatalf("IAM avatar = %v, want %q", row["avatar"], url)
}
if row["password"] != "$2a$hashed" {
t.Fatalf("the whole-row re-submit dropped the password hash (%v) — that locks the user out", row["password"])
}
if row["displayName"] != "Antje" {
t.Fatal("the re-submit dropped a field it does not own")
}
if len(vfs.keys()) != 1 {
t.Fatalf("stored %d objects, want 1: %v", len(vfs.keys()), vfs.keys())
}
}
// The address is the CONTENT, which is what makes replacing a photo safe: a new
// face is a new URL, so no cache anywhere can still be serving the old one.
func TestPhotoAddressIsItsContent(t *testing.T) {
app, _, _ := mountAvatar(t)
_, b1 := upload(t, app, "u-antje", "hanzo", "a.png", onePNG())
_, b2 := upload(t, app, "u-antje", "hanzo", "different-name.png", onePNG())
if photoURL(t, b1) != photoURL(t, b2) {
t.Fatal("the same bytes must have the same address — the filename must not enter it")
}
other := append(onePNG(), 'x')
_, b3 := upload(t, app, "u-antje", "hanzo", "a.png", other)
if photoURL(t, b3) == photoURL(t, b1) {
t.Fatal("different bytes must have a different address, or a replaced photo is a stale cache")
}
// Both remain fetchable: replacing does not delete, deliberately (the old URL is
// already inside issued tokens and rendered pages).
if resp, _ := fetch(t, app, path(photoURL(t, b1))); resp.StatusCode != http.StatusOK {
t.Fatal("replacing a photo must not break the previous address")
}
}
// Two users uploading the SAME image get different keys: the key is org- and
// user-scoped, so one person's photo is never addressed by another's identity.
func TestPhotoIsScopedToItsOwner(t *testing.T) {
app, vfs, _ := mountAvatar(t)
png := onePNG()
_, b1 := upload(t, app, "u-antje", "hanzo", "me.png", png)
_, b2 := upload(t, app, "u-other", "zoo", "me.png", png)
if photoURL(t, b1) == photoURL(t, b2) {
t.Fatal("two users' photos must not share an address")
}
if len(vfs.keys()) != 2 {
t.Fatalf("stored %d objects, want 2: %v", len(vfs.keys()), vfs.keys())
}
for _, k := range vfs.keys() {
if !strings.HasPrefix(k, "account/avatars/") {
t.Fatalf("key %q escaped this subsystem's prefix in the shared bucket", k)
}
}
}
// ── the safety properties ────────────────────────────────────────────────────
// The format is decided by the BYTES. An SVG is a program, and one stored as a
// picture and later served under the type its NAME claimed is script running in
// this origin.
func TestOnlyRasterImagesAreAccepted(t *testing.T) {
for name, data := range map[string]string{
"svg": `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`,
"html": `<!doctype html><script>alert(1)</script>`,
"pdf": "%PDF-1.7\n",
"text": "just some text",
} {
t.Run(name, func(t *testing.T) {
app, vfs, _ := mountAvatar(t)
// The NAME claims png; only the bytes are consulted.
code, body := upload(t, app, "u-antje", "hanzo", "innocent.png", []byte(data))
if code != http.StatusUnsupportedMediaType {
t.Fatalf("upload = %d, want 415: %s", code, body)
}
if len(vfs.keys()) != 0 {
t.Fatalf("a refused upload must store nothing, stored: %v", vfs.keys())
}
})
}
}
// Defense in depth on the read: an object under an avatar key that is not an image
// is a 404, never bytes served inline. The upload already refuses these, so this
// can only fire on something written by another path — which is exactly when a
// guessed Content-Type would be an XSS.
func TestReadNeverServesNonImageBytes(t *testing.T) {
app, vfs, _ := mountAvatar(t)
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
sum := sha256.Sum256(svg)
dg := hex.EncodeToString(sum[:])
if err := vfs.Put(context.Background(), avatarKey("hanzo", "u-antje", dg), svg); err != nil {
t.Fatalf("seed: %v", err)
}
resp, _ := fetch(t, app, "/v1/avatar/hanzo/u-antje/"+dg)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("fetch = %d, want 404 — a stored non-image must never be served", resp.StatusCode)
}
}
// A malformed address is refused before the store is touched, and every refusal is
// the same 404 so a probe learns nothing.
func TestReadRefusesAnythingThatIsNotAPhotoAddress(t *testing.T) {
app, _, _ := mountAvatar(t)
good := hex.EncodeToString(func() []byte { s := sha256.Sum256(onePNG()); return s[:] }())
for name, p := range map[string]string{
"digest is not hex": "/v1/avatar/hanzo/u-antje/" + strings.Repeat("z", 64),
"digest is the wrong size": "/v1/avatar/hanzo/u-antje/abcd",
"traversal in the org": "/v1/avatar/..%2f..%2fetc/u-antje/" + good,
"traversal in the user": "/v1/avatar/hanzo/..%2f..%2fpasswd/" + good,
"never uploaded": "/v1/avatar/hanzo/u-nobody/" + good,
} {
t.Run(name, func(t *testing.T) {
resp, _ := fetch(t, app, p)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("fetch = %d, want 404", resp.StatusCode)
}
})
}
}
// A path component is REFUSED, not folded. Sanitizing maps "a/b" and "a_b" onto one
// key, and in a tenancy key that is two identities sharing an address.
func TestKeyComponentsAreRefusedNotFolded(t *testing.T) {
for _, bad := range []string{"", ".", "..", "a/b", "a\\b", "a b", "a\x00b", strings.Repeat("a", 129)} {
if safe(bad) {
t.Fatalf("safe(%q) = true, want false", bad)
}
}
for _, ok := range []string{"hanzo", "u-antje", "a.b_c-d", "0"} {
if !safe(ok) {
t.Fatalf("safe(%q) = false, want true", ok)
}
}
// "a/b" and "a_b" must not become one key — the fold this refusal prevents.
if avatarKey("a_b", "u", "d") == avatarKey("a/b", "u", "d") {
t.Fatal("two distinct orgs collided onto one key")
}
}
// ── the honest failures ──────────────────────────────────────────────────────
// No validated identity → refused. The subject is ALWAYS the caller's own claims,
// so there is no request value that could name someone else's photo.
func TestUnauthenticatedCannotSetAPhoto(t *testing.T) {
app, vfs, _ := mountAvatar(t)
code, _ := upload(t, app, "", "", "me.png", onePNG())
if code != http.StatusUnauthorized {
t.Fatalf("upload = %d, want 401", code)
}
// A user with no organization yet cannot either: the key is org-scoped.
code, _ = upload(t, app, "u-antje", "", "me.png", onePNG())
if code != http.StatusUnauthorized {
t.Fatalf("org-less upload = %d, want 401", code)
}
if len(vfs.keys()) != 0 {
t.Fatalf("a refused upload must store nothing, stored: %v", vfs.keys())
}
}
// A dead object store is a 502, and the profile is NOT updated — the record must
// never point at bytes that were not written.
func TestStoreFailureIsHonestAndLeavesTheProfileAlone(t *testing.T) {
app, vfs, iam := mountAvatar(t)
vfs.failPut = true
code, body := upload(t, app, "u-antje", "hanzo", "me.png", onePNG())
if code != http.StatusBadGateway {
t.Fatalf("upload = %d, want 502: %s", code, body)
}
iam.mu.Lock()
defer iam.mu.Unlock()
if len(iam.rows) != 0 {
t.Fatal("the profile was pointed at a photo the store refused to write")
}
}
// The bytes landed but the record did not: the photo exists and is not shown, so
// say that rather than reporting a success the user cannot see.
func TestPhotoStoredButProfileNotUpdatedSaysSo(t *testing.T) {
app, _, iam := mountAvatar(t)
// An IAM that cannot return the row: the whole-row re-submit has nothing to
// re-submit, so the profile write fails after the bytes have landed.
iam.mu.Lock()
delete(iam.user, "hanzo/u-antje")
iam.failUpdateUser = true
iam.mu.Unlock()
code, body := upload(t, app, "u-antje", "hanzo", "me.png", onePNG())
if code == http.StatusOK {
t.Fatal("a failed profile write must not report success")
}
if !strings.Contains(strings.ToLower(string(body)), "profile") {
t.Fatalf("the error should name what failed, got: %s", body)
}
}
// The two shapes a form can be wrong in.
func TestMalformedUploads(t *testing.T) {
app, _, _ := mountAvatar(t)
if code, _ := upload(t, app, "u-antje", "hanzo", "", nil); code != http.StatusBadRequest {
t.Fatalf("form with no file part = %d, want 400", code)
}
if code, _ := upload(t, app, "u-antje", "hanzo", "empty.png", []byte{}); code != http.StatusBadRequest {
t.Fatalf("empty file = %d, want 400", code)
}
big := make([]byte, maxAvatarSize+1)
copy(big, onePNG())
if code, _ := upload(t, app, "u-antje", "hanzo", "big.png", big); code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversize = %d, want 413", code)
}
}
// avatarFor is the address any caller can name a stored photo by; it must agree
// with what the upload answered, or the two spellings drift.
func TestAvatarForMatchesWhatTheUploadAnswers(t *testing.T) {
app, _, _ := mountAvatar(t)
_, body := upload(t, app, "u-antje", "hanzo", "me.png", onePNG())
sum := sha256.Sum256(onePNG())
if got, want := photoURL(t, body), avatarFor("api.hanzo.ai", "hanzo", "u-antje", hex.EncodeToString(sum[:])); got != want {
t.Fatalf("upload answered %q, avatarFor says %q", got, want)
}
}
+6
View File
@@ -40,6 +40,12 @@ func echoBody(c *zip.Ctx) error {
return c.JSON(200, got)
}
// alice is a VALIDATED principal: X-User-Id is set by the gateway only from a
// verified credential, and X-Org-Id is the owner claim minted alongside it. It
// lived in the crypto-top-up suite that this package no longer has, and it is the
// caller identity every test below pins against.
var alice = map[string]string{"X-User-Id": "alice", "X-Org-Id": "acme"}
func pinApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
+1 -1
View File
@@ -25,7 +25,7 @@ func req(t *testing.T, app *zip.App, method, path string, hdr map[string]string,
for k, v := range hdr {
r.Header.Set(k, v)
}
resp, err := app.Fiber().Test(r)
resp, err := app.Test(r)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+146 -14
View File
@@ -32,11 +32,10 @@ import (
"os"
"strings"
"time"
"github.com/hanzoai/cloud"
)
// defaultIAMBase is the in-cluster IAM service; overridable by IAM_URL for other
// environments and by tests (an httptest.Server URL). Mirrors identity.ts's IAM_URL.
const defaultIAMBase = "http://iam.hanzo.svc.cluster.local:8000"
// iamMaxBody bounds an IAM response read — these are small JSON envelopes (a key,
// a user row, an org row), never blobs.
@@ -53,7 +52,7 @@ type iamClient struct {
}
func newIAMClient() *iamClient {
base := strings.TrimRight(strings.TrimSpace(getenv("IAM_URL", defaultIAMBase)), "/")
base := cloud.IAMBase()
return &iamClient{
base: base,
clientID: strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_ID")),
@@ -132,7 +131,8 @@ type userRow struct {
// getUserRow resolves the user by the caller's id (the same read the move did) into
// its authoritative (owner, name).
func (c *iamClient) getUserRow(ctx context.Context, id string) (userRow, error) {
raw, err := c.getUser(ctx, id)
owner, name := splitID(id)
raw, err := c.getUser(ctx, owner, name)
if err != nil {
return userRow{}, err
}
@@ -199,18 +199,50 @@ func (c *iamClient) do(ctx context.Context, method, path string, q url.Values, b
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return iamEnvelope{}, fmt.Errorf("iam denied (%d)", resp.StatusCode)
}
// TWO WIRE SHAPES, and this door has to read both.
//
// Some routes answer the {status,msg,data} envelope this type was written
// for. Others — /v1/iam/users/get among them — answer the RESOURCE DIRECTLY,
// and errors come back as {"status":404,"error":"…"} where `status` is a
// NUMBER, not the string "ok".
//
// Assuming the envelope broke both: a raw row parsed with Status "" and was
// rejected as `iam status 200`, and an error body failed to unmarshal at all
// and was reported as `iam non-envelope response (400)`. Both were the avatar
// write's "photo stored but the profile could not be updated" — measured
// against the running IAM, where GET users/get?owner=hanzo&name=z returns
// {createdAt,updatedAt,deleted,id,owner,name,…} with no envelope in sight.
//
// So the HTTP status decides, and the body is only read for what it carries:
// a 2xx with no envelope IS the data; a non-2xx yields its `error` or `msg`.
var env iamEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return iamEnvelope{}, fmt.Errorf("iam non-envelope response (%d)", resp.StatusCode)
}
if env.Status != "ok" {
enveloped := json.Unmarshal(raw, &env) == nil && env.Status != ""
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := env.Msg
if msg == "" {
msg = fmt.Sprintf("iam status %d", resp.StatusCode)
var alt struct {
Error string `json:"error"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(raw, &alt)
msg = firstNonEmpty(alt.Error, alt.Msg, fmt.Sprintf("iam status %d", resp.StatusCode))
}
return iamEnvelope{}, fmt.Errorf("iam: %s", msg)
}
return env, nil
if enveloped {
if env.Status != "ok" {
msg := env.Msg
if msg == "" {
msg = fmt.Sprintf("iam status %d", resp.StatusCode)
}
return iamEnvelope{}, fmt.Errorf("iam: %s", msg)
}
return env, nil
}
// A 2xx that is not an envelope: the body is the resource.
return iamEnvelope{Status: "ok", Data: json.RawMessage(raw)}, nil
}
// ── the Cloud API key (per-user) ─────────────────────────────────────────────
@@ -387,8 +419,76 @@ func (c *iamClient) createOrganization(ctx context.Context, o iamOrg) error {
}
// getUser reads a full user row (for the move: update-user re-submits it whole).
func (c *iamClient) getUser(ctx context.Context, id string) (json.RawMessage, error) {
env, err := c.do(ctx, http.MethodGet, "/v1/iam/users/get", url.Values{"id": {id}}, nil)
// It takes owner and name SEPARATELY because that is what the endpoint wants.
// Sending the `<owner>/<name>` composite as `id` — which this did — answers
// `400 field "owner" is required` for EVERY id, measured against the running
// IAM:
//
// ?id=hanzo/2d4d67ab-… 400 field "owner" is required
// ?id=hanzo/z 400 field "owner" is required
// ?owner=hanzo&name=z 200
//
// So no caller of this ever read a user row: the avatar write surfaced it
// ("photo stored but the profile could not be updated"), and moveUserToOrg has
// the same fault silently. `name` is the USERNAME — the row's own `name` field,
// "z" — not the UUID that `sub` carries.
// splitID splits the `<owner>/<name>` composite the callers carry into the two
// fields IAM's user ops actually want. A bare name (a first-run, org-less user)
// yields an empty owner, which IAM refuses with its own message rather than
// being guessed at here.
func splitID(id string) (owner, name string) {
if i := strings.IndexByte(id, '/'); i > 0 {
return id[:i], id[i+1:]
}
return "", id
}
// nameOf resolves a user's NAME from its id within an org, for the callers whose
// only handle is the UUID `sub`. One roster read, used only after the direct
// lookup has already failed — never on the happy path.
func (c *iamClient) nameOf(ctx context.Context, owner, id string) (string, error) {
env, err := c.do(ctx, http.MethodGet, "/v1/iam/get-users", url.Values{"owner": {owner}}, nil)
if err != nil {
return "", err
}
var rows []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(env.Data, &rows); err != nil {
return "", err
}
for _, r := range rows {
if r.ID == id {
return r.Name, nil
}
}
return "", errNotFound
}
func (c *iamClient) getUser(ctx context.Context, owner, name string) (json.RawMessage, error) {
// An org-less caller (first-run onboarding) has no owner to send, and this is
// the ONE read that must still be attempted for them — resolving their
// authoritative (owner, name) is the whole point of the call. The composite
// form is kept for exactly that case rather than refused here, so onboarding
// behaves as it always did; every caller that HAS an owner now sends the
// shape IAM actually accepts.
q := url.Values{"id": {name}}
if owner != "" {
q = url.Values{"owner": {owner}, "name": {name}}
}
env, err := c.do(ctx, http.MethodGet, "/v1/iam/users/get", q, nil)
if err != nil && owner != "" {
// `name` was not a username. On the direct-Bearer path the only user
// handle a token carries is the UUID `sub`, and IAM addresses a row by
// its NAME — so the lookup that just failed asked for a user that does
// not exist under that spelling. The org's roster carries both, so the
// id resolves to the name and the read is retried once.
if n, rerr := c.nameOf(ctx, owner, name); rerr == nil && n != "" && n != name {
env, err = c.do(ctx, http.MethodGet, "/v1/iam/users/get",
url.Values{"owner": {owner}, "name": {n}}, nil)
}
}
if err != nil {
return nil, err
}
@@ -398,12 +498,44 @@ func (c *iamClient) getUser(ctx context.Context, id string) (json.RawMessage, er
return env.Data, nil
}
// setAvatar records the user's profile photo URL on their IAM row. IAM is the
// system of record for `avatar` — the console, the session claims and every other
// surface already read it from there — so this one write is what makes a new photo
// appear everywhere at once.
//
// Same whole-row re-submit as moveUserToOrg: update-user takes the entire row, so
// it is read, ONE field is changed, and it goes back. Reading first is not
// optional — a partial row would blank every field it omitted, including the
// password hash.
func (c *iamClient) setAvatar(ctx context.Context, id, photo string) error {
owner, name := splitID(id)
rowRaw, err := c.getUser(ctx, owner, name)
if err != nil {
return err
}
var row map[string]any
if err := json.Unmarshal(rowRaw, &row); err != nil {
return fmt.Errorf("iam get-user: decode: %w", err)
}
row["avatar"] = photo
// avatarType tells IAM the photo is ours rather than a federated provider's, so
// a later sign-in through GitHub does not silently overwrite what the user chose.
row["avatarType"] = "custom"
body, err := json.Marshal(row)
if err != nil {
return err
}
_, err = c.do(ctx, http.MethodPost, "/v1/iam/update-user", url.Values{"id": {id}}, body)
return err
}
// moveUserToOrg makes the zero-org user an admin of `slug`: it re-submits the user
// row with owner=slug + isAdmin=true (update-user takes the whole row). The user's
// password travels with the row (IAM verifies against user.PasswordType first), so
// the move never locks them out. `id` is the caller's CURRENT `<owner>/<name>`.
func (c *iamClient) moveUserToOrg(ctx context.Context, id, slug string) error {
rowRaw, err := c.getUser(ctx, id)
owner, name := splitID(id)
rowRaw, err := c.getUser(ctx, owner, name)
if err != nil {
return err
}
-546
View File
@@ -1,546 +0,0 @@
// topup.go is the verify-and-record seam for a crypto wallet top-up: the browser
// sends a USD-pegged ERC-20 transfer to our treasury and posts the tx hash here;
// this handler reads the receipt from that chain, confirms it is a mined, successful
// Transfer(from → treasury, value), derives USD cents from the on-chain value using
// the TOKEN'S OWN decimals, records it to commerce, and returns the credited amount
// plus the new balance.
//
// A rail is one accepted (chain, token, treasury) triple, configured as data in
// TOPUP_RAILS and discoverable at GET /v1/commerce/topup/rails. This replaced a
// single hardcoded HUSD-on-Hanzo-Mainnet pair: HUSD is not deployed, so the surface
// was permanently 501 — complete, correct and unable to take a cent. Customers
// already hold USDC on Base/Ethereum/Polygon, so accepting the assets they have is
// what makes this earn.
//
// THE CREDITED AMOUNT IS THE ON-CHAIN VALUE, never a client number — which is exactly
// why this MUST be a server handler and cannot collapse to a same-origin call. Three
// properties worth keeping:
//
// - IDOR-safe: the credit lands on the VALIDATED caller's own org/user (the
// gateway-verified X-Org-Id/X-User-Id), never a client-supplied `userId`.
// - S2S to commerce: recorded with the admin COMMERCE_SERVICE_TOKEN + the caller's
// X-Org-Id (the same service-to-service pattern clients/admin reads balances on),
// not by forwarding a browser cookie.
// - Per-rail decimals: cents come from 10^(decimals-2), so a 6-decimal USDC and an
// 18-decimal token cannot be priced with one another's divisor.
//
// The EVM receipt is read over plain JSON-RPC (eth_getTransactionReceipt) — one
// well-known call + one well-known event, so the stdlib is sufficient and no EVM
// client dependency is pulled in. That also means a new chain costs no new code.
//
// Honest failure (no fabricated credit, ever): no rail configured → 501; an unknown
// rail, or a missing/failed/non-matching tx → 400; the chain or commerce unreachable
// → 502.
package account
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/hanzoai/cloud/apps/commerce/transport"
"github.com/zap-proto/zip"
)
// httpClient is the shared outbound client for the account subsystem's plain-HTTP seams
// (EVM JSON-RPC, commerce billing/store S2S). Small JSON envelopes, bounded reads; 15s
// is generous for an in-cluster / same-region hop. (Owned here — the S2S transport home
// — since the former waitlist.go was retired with the /v1/console namespace.)
var httpClient = &http.Client{Timeout: 15 * time.Second}
// commerceHTTP is the client for the commerce S2S seam ONLY (commerceDo). Separate
// from httpClient (which also dials EVM JSON-RPC) so that — when commerce is folded
// in-process (task #111) — commerce calls dispatch to the in-process handler via
// the commerce transport's self-routing dispatch (no socket to the standalone), while the
// HUSD chain RPC keeps going over the real network. Off the co-resident path it is a
// plain HTTP client, exactly like before.
var commerceHTTP = transport.Client(15 * time.Second)
// transferTopic is keccak256("Transfer(address,address,uint256)") — the ERC-20
// Transfer event signature, topics[0] of every transfer log. A universally-fixed
// constant (no need to hash at runtime).
const transferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
// centDivisor: base units per cent for a `decimals`-place USD-pegged token, i.e.
// 10^(decimals-2). This MUST be per-token, not a constant: HUSD has 18 decimals
// (1e16 per cent) while USDC has 6 (1e4 per cent). Sharing one divisor across both
// would misprice a credit by 10^12 — the difference between a $10 top-up and a
// $10,000,000,000 one — so the token's own decimals are carried on the rail and
// used here.
func centDivisor(decimals int) *big.Int {
return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals-2)), nil)
}
var (
addrRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`)
txHashRe = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`)
)
func isAddr(a string) bool { return addrRe.MatchString(a) }
// rail is ONE accepted way to pay: a USD-pegged ERC-20 on a specific chain, sent to
// a treasury address we control there. Everything a receipt check needs is on the
// rail, so accepting a new chain or token is data, never code.
//
// This replaced a single hardcoded (HUSD, treasury) pair. That pair could only ever
// describe HUSD on Hanzo Mainnet, and since HUSD is not deployed the whole surface
// was permanently 501 — architecturally complete and earning nothing. Customers
// already hold USDC on Base/Ethereum/Polygon, so the rail set is what makes the
// path able to take money at all.
type rail struct {
// Stable id the client names when submitting, e.g. "base-usdc".
ID string `json:"id"`
// Human chain name for the UI, e.g. "Base".
Chain string `json:"chain"`
// EIP-155 chain id — the wallet must be on this chain.
ChainID int64 `json:"chainId"`
// JSON-RPC endpoint used to read the receipt. Carried in the CONFIG json (this
// struct is what TOPUP_RAILS decodes into) but never published — the public
// listing is a separate view type, because an input model and an output model
// are different things and collapsing them once made this field unsettable.
RPCURL string `json:"rpcUrl"`
// The ERC-20 contract.
Token string `json:"token"`
// Display symbol, e.g. "USDC".
Symbol string `json:"symbol"`
// Token decimals. USDC is 6; an 18-decimal token must say 18.
Decimals int `json:"decimals"`
// Where the customer sends funds on this chain. Public by nature.
Treasury string `json:"treasury"`
}
// ok reports whether a rail is usable. A malformed rail is DROPPED rather than
// rejected at request time, so one bad entry cannot take the whole surface down —
// and a rail with impossible decimals cannot silently misprice a credit.
func (r rail) ok() bool {
return r.ID != "" && isAddr(r.Token) && isAddr(r.Treasury) && r.RPCURL != "" &&
r.Decimals >= 2 && r.Decimals <= 36
}
// topupConfig is the deployment's accepted rails + commerce wiring, resolved from
// server-only env (sourced from KMS by the deployment, never a browser value).
type topupConfig struct {
rails []rail
commerce string // commerce base (e.g. http://commerce.hanzo.svc.cluster.local:8001)
token string // admin S2S bearer for commerce (COMMERCE_SERVICE_TOKEN; never logged)
}
// TOPUP_RAILS is a JSON array of rails — ONE variable describing the whole accepted
// set, rather than a family of per-token env names that would have to be invented
// again for every chain. Unparseable or malformed entries are dropped.
func loadTopupConfig() topupConfig {
var rails []rail
if raw := strings.TrimSpace(os.Getenv("TOPUP_RAILS")); raw != "" {
var parsed []rail
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
for _, r := range parsed {
r.RPCURL = strings.TrimRight(strings.TrimSpace(r.RPCURL), "/")
if r.ok() {
rails = append(rails, r)
}
}
}
}
return topupConfig{
rails: rails,
commerce: strings.TrimRight(getenv("COMMERCE_URL", "https://api.hanzo.ai"), "/"),
token: strings.TrimSpace(os.Getenv("COMMERCE_SERVICE_TOKEN")),
}
}
// configured reports whether ANY rail is accepted. With none the surface is honestly
// 501 rather than pretending to take money it cannot verify.
func (t topupConfig) configured() bool { return len(t.rails) > 0 }
// find returns the named rail. An unknown id is a client error, not a server one.
func (t topupConfig) find(id string) (rail, bool) {
for _, r := range t.rails {
if strings.EqualFold(r.ID, id) {
return r, true
}
}
return rail{}, false
}
type walletTopupReq struct {
// Which accepted rail the transfer was sent on, e.g. "base-usdc". The client
// names it rather than the server guessing from the tx: the same address can
// exist on several chains, so inferring would risk crediting against the wrong
// treasury. It may be omitted only while exactly one rail is enabled.
Rail string `json:"rail"`
// TxHash is the hash of the ERC-20 transfer that was already sent to the rail's
// treasury. The receipt is read from that chain; nothing is credited that the
// chain did not confirm.
TxHash string `json:"txHash"`
// FromAddress is the wallet the transfer was sent from. Optional; when given it
// must match the transfer's on-chain sender.
FromAddress string `json:"fromAddress"`
// A client-supplied `userId` is intentionally NOT read — the credit lands on the
// validated caller (no IDOR). Neither is any amount: the credit is the ON-CHAIN
// value, so a client number could never inflate it.
}
type walletTopupResp struct {
// CreditedCents is the USD credit recorded, derived from the ON-CHAIN value
// using the token's own decimals — never a client-supplied number.
CreditedCents int64 `json:"creditedCents"`
// Balance is the org's new USD-ledger balance in cents. Best-effort: a read
// failure reports 0, and the credit has already landed either way.
Balance int64 `json:"balance"`
// TxHash is the transfer that was credited.
TxHash string `json:"txHash"`
// Status is how commerce recorded the payment.
Status string `json:"status"`
}
// railList is the accepted-rail set a browser reads to render the send UI.
type railList struct {
// Rails is every (chain, token, treasury) triple this deployment accepts.
Rails []railView `json:"rails"`
}
// TopupRails lists the accepted (chain, token, treasury) triples, so a browser can
// render "send USDC here" without the addresses being baked into its bundle.
//
// This exists because the console previously gated its top-up UI on
// NEXT_PUBLIC_HANZO_HUSD_ADDRESS/_TREASURY — build-time constants. Enabling a rail
// therefore meant rebuilding and redeploying the frontend, and with them unset the
// UI reported "not available yet" no matter what the server could actually accept.
// Serving the set at runtime keeps ONE source of truth (the server's config) and
// lets a rail be switched on without shipping a bundle.
//
// Everything here is public on-chain data; no secret is exposed, and the set is
// empty on a deployment that accepts no crypto rail.
func (o ops) topupRails(ctx context.Context, _ *noInput) (*railList, error) {
cfg := loadTopupConfig()
// Encode as [] rather than null, so clients can just read .length.
view := make([]railView, 0, len(cfg.rails))
for _, r := range cfg.rails {
view = append(view, railView{
ID: r.ID, Chain: r.Chain, ChainID: r.ChainID,
Token: r.Token, Symbol: r.Symbol, Decimals: r.Decimals, Treasury: r.Treasury,
})
}
return &railList{Rails: view}, nil
}
// railView is what a browser is told about a rail: everything needed to send funds
// and nothing else. Distinct from `rail` so that adding an operational field to the
// config (an RPC URL, a key reference, a provider credential) cannot leak by merely
// existing — a new field is published only if it is added here on purpose.
type railView struct {
// ID is the stable rail id to name when submitting a transfer, e.g. "base-usdc".
ID string `json:"id"`
// Chain is the human chain name, e.g. "Base".
Chain string `json:"chain"`
// ChainID is the EIP-155 chain id the wallet must be on.
ChainID int64 `json:"chainId"`
// Token is the ERC-20 contract address to transfer.
Token string `json:"token"`
// Symbol is the display symbol, e.g. "USDC".
Symbol string `json:"symbol"`
// Decimals is the token's decimal places — 6 for USDC, 18 for an 18-decimal
// token. Cents are derived per-rail from it.
Decimals int `json:"decimals"`
// Treasury is the address on this chain to send funds to.
Treasury string `json:"treasury"`
}
// WalletTopup credits the caller's org for a stablecoin transfer they already sent
// to the treasury. It reads the receipt from that rail's chain, confirms a mined,
// successful ERC-20 Transfer to the rail's treasury, derives USD cents from the
// on-chain value using the token's own decimals, records the credit, and returns
// the amount plus the new balance.
//
// The credited amount is the ON-CHAIN value, never a number the caller sends, and
// the credit lands on the caller's own validated org — there is no way to name a
// third-party subject. Nothing is credited that the chain did not confirm: a
// missing, failed or non-matching transaction is refused, and a deployment with no
// payment rail enabled says so rather than inventing a credit.
//
// Example: {"rail": "base-usdc", "txHash": "0x0000000000000000000000000000000000000000000000000000000000000001"}
func (o ops) walletTopup(ctx context.Context, in *walletTopupReq) (*walletTopupResp, error) {
cfg := loadTopupConfig()
// No accepted rail ⇒ honest "not configured yet" rather than a fake credit.
if !cfg.configured() {
return nil, zip.Errorf(http.StatusNotImplemented, "crypto top-up is not configured yet (no payment rail is enabled)")
}
// The credit lands on the VALIDATED caller's own org (X-Org-Id) — require it.
cr, c, ok := requestCaller(ctx, true)
if !ok {
return nil, zip.ErrForbidden("sign in to top up your balance")
}
body := *in
txHash := strings.TrimSpace(body.TxHash)
if !txHashRe.MatchString(txHash) {
return nil, zip.ErrBadRequest("a valid transaction hash is required")
}
// With exactly one rail the client may omit it; naming it is required as soon as
// there is a choice, so a transfer can never be checked against another chain's
// treasury by default.
railID := strings.TrimSpace(body.Rail)
if railID == "" && len(cfg.rails) == 1 {
railID = cfg.rails[0].ID
}
rl, ok := cfg.find(railID)
if !ok {
return nil, zip.ErrBadRequest("unknown payment rail: name one from GET /v1/commerce/topup/rails")
}
rctx := c.Context()
// ── 1. Verify the transfer on-chain ──────────────────────────────────────────
cents, verifiedFrom, herr := verifyTransfer(rctx, rl, txHash, strings.TrimSpace(body.FromAddress))
if herr != nil {
return nil, herr
}
// ── 2. Record to commerce as a crypto payment on this rail (S2S) ─────────────
status, herr := recordCryptoPayment(rctx, cfg, rl, cr, txHash, verifiedFrom, cents)
if herr != nil {
return nil, herr
}
// New USD-ledger balance — best-effort; the credit already landed.
balance := commerceBalanceCents(rctx, cfg, cr)
return &walletTopupResp{CreditedCents: cents, Balance: balance, TxHash: txHash, Status: status}, nil
}
// ── on-chain verification (plain JSON-RPC) ───────────────────────────────────────
// rpcReceipt is the subset of an eth_getTransactionReceipt result we read.
type rpcReceipt struct {
Status string `json:"status"` // "0x1" success, "0x0" failed
Logs []rpcLog `json:"logs"`
}
type rpcLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
}
// verifyTransfer reads the receipt on the rail's chain and confirms a mined,
// successful Transfer of the rail's token to the rail's treasury, returning the
// credited cents and the sender. Any non-conforming tx is an honest 400; an
// unreachable chain is a 502. Nothing is credited that the chain did not confirm.
func verifyTransfer(ctx context.Context, rl rail, txHash, wantFrom string) (int64, string, error) {
rcpt, err := getReceipt(ctx, rl.RPCURL, txHash)
if err != nil {
return 0, "", zip.Errorf(http.StatusBadGateway, "could not verify the transaction on %s: %v", rl.Chain, err)
}
if rcpt == nil {
return 0, "", zip.ErrBadRequest("transaction not found or not yet mined")
}
if strings.ToLower(rcpt.Status) != "0x1" {
return 0, "", zip.ErrBadRequest("transaction failed on-chain")
}
token := strings.ToLower(rl.Token)
treasuryTopic := addrToTopic(rl.Treasury)
div := centDivisor(rl.Decimals)
for _, lg := range rcpt.Logs {
if strings.ToLower(lg.Address) != token {
continue
}
if len(lg.Topics) < 3 || strings.ToLower(lg.Topics[0]) != transferTopic {
continue
}
if strings.ToLower(lg.Topics[2]) != treasuryTopic { // indexed `to`
continue
}
value, ok := new(big.Int).SetString(strings.TrimPrefix(lg.Data, "0x"), 16)
if !ok {
continue
}
cents := new(big.Int).Div(value, div)
if cents.Sign() <= 0 || !cents.IsInt64() {
return 0, "", zip.ErrBadRequest("transferred amount is below the minimum (1 cent)")
}
from := topicToAddr(lg.Topics[1]) // indexed `from`
if wantFrom != "" && isAddr(wantFrom) && !strings.EqualFold(from, wantFrom) {
return 0, "", zip.ErrBadRequest("transfer sender does not match the connected wallet")
}
return cents.Int64(), from, nil
}
return 0, "", zip.ErrBadRequest("no " + rl.Symbol + " transfer to the treasury was found in this transaction")
}
// getReceipt calls eth_getTransactionReceipt over JSON-RPC. A null result (not mined)
// returns (nil,nil); a transport / RPC error propagates.
func getReceipt(ctx context.Context, rpcURL, txHash string) (*rpcReceipt, error) {
reqBody, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "eth_getTransactionReceipt", "params": []string{txHash},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("rpc unreachable: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("rpc status %d", resp.StatusCode)
}
var env struct {
Result *rpcReceipt `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("rpc decode: %w", err)
}
if env.Error != nil {
return nil, fmt.Errorf("rpc error: %s", env.Error.Message)
}
return env.Result, nil // Result==nil ⇒ not found / not yet mined
}
// addrToTopic left-pads a 20-byte address to a 32-byte indexed topic (lowercase).
func addrToTopic(addr string) string {
h := strings.ToLower(strings.TrimPrefix(addr, "0x"))
return "0x" + strings.Repeat("0", 64-len(h)) + h
}
// topicToAddr extracts the 20-byte address from a 32-byte indexed topic (lowercase,
// 0x-prefixed).
func topicToAddr(topic string) string {
h := strings.TrimPrefix(topic, "0x")
if len(h) < 40 {
return "0x" + h
}
return "0x" + h[len(h)-40:]
}
// ── commerce (S2S) ───────────────────────────────────────────────────────────────
// recordCryptoPayment records the verified credit to commerce, scoped to the
// caller's org via the S2S service token + X-Org-Id. Network, chain, currency and
// destination all come from the RAIL the transfer was verified against, so the
// ledger row describes the payment that actually happened rather than a fixed
// assumption about which chain and token it was.
func recordCryptoPayment(ctx context.Context, cfg topupConfig, rl rail, cr caller, txHash, from string, cents int64) (string, error) {
payload, _ := json.Marshal(map[string]any{
"method": "crypto",
"network": rl.Chain,
"chainId": rl.ChainID,
"currency": strings.ToLower(rl.Symbol),
"amount": cents,
"txHash": txHash,
"fromAddress": from,
"toAddress": rl.Treasury,
"userId": cr.id, // the VALIDATED caller — never a client-supplied id
})
raw, status, err := commerceDo(ctx, cfg.commerce, cfg.token, http.MethodPost, "/v1/billing/payment", nil, cr.owner, payload)
if err != nil {
return "", zip.Errorf(http.StatusBadGateway, "could not reach commerce to record the payment: %v", err)
}
if status < 200 || status >= 300 {
return "", zip.Errorf(http.StatusBadGateway, "commerce rejected the payment (HTTP %d): %s", status, strings.TrimSpace(string(raw)))
}
var out struct {
Status string `json:"status"`
}
_ = json.Unmarshal(raw, &out)
if out.Status == "" {
out.Status = "recorded"
}
return out.Status, nil
}
// commerceBalanceCents reads the caller's USD-ledger balance (cents). Best-effort:
// the credit is already recorded, so a read error degrades to 0, not a failure.
func commerceBalanceCents(ctx context.Context, cfg topupConfig, cr caller) int64 {
q := url.Values{"user": {cr.id}, "currency": {"usd"}}
raw, status, err := commerceDo(ctx, cfg.commerce, cfg.token, http.MethodGet, "/v1/billing/balance", q, cr.owner, nil)
if err != nil || status < 200 || status >= 300 {
return 0
}
var b struct {
Balance int64 `json:"balance"`
Available int64 `json:"available"`
}
if err := json.Unmarshal(raw, &b); err != nil {
return 0
}
if b.Balance != 0 {
return b.Balance
}
return b.Available
}
// commerceDo performs one S2S commerce request: admin bearer + X-Org-Id (commerce's
// EdgeAuth trusts the org header ONLY behind the service token). Returns the raw
// body + status. Mirrors clients/admin/commerce.go's auth. Takes (base, token) rather
// than the HUSD topupConfig so both the wallet top-up AND the /v1/billing/* data bridge
// (billing.go) share this ONE S2S transport.
//
// It is a JSON transport, NOT a transparent proxy, and the two bridges that share it
// inherit exactly that. Three facts, none of them accidental and none repaired here:
// the request Content-Type is SET to application/json whenever there is a body (so a
// form/multipart/binary body forwards its bytes under a JSON label), the response
// headers are not returned at all (so an upstream Content-Type or
// Content-Disposition cannot be relayed — see billing.go's header note), and the
// response body is capped at 1 MiB by the LimitReader below, which TRUNCATES a
// larger answer and reports it with the upstream's own 200. That cap is right for
// the JSON callers it was written for and wrong for a PDF, which is the one
// non-JSON payload in billingForwardable.
func commerceDo(ctx context.Context, base, token, method, path string, q url.Values, org string, body []byte) ([]byte, int, error) {
if base == "" {
return nil, 0, fmt.Errorf("commerce not configured")
}
u := base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, rdr)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := commerceHTTP.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("commerce unreachable: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, err
}
return raw, resp.StatusCode, nil
}
-378
View File
@@ -1,378 +0,0 @@
package account
import (
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
const (
tHusd = "0x1111111111111111111111111111111111111111"
tTreasury = "0x2222222222222222222222222222222222222222"
tSender = "0x3333333333333333333333333333333333333333"
tOther = "0x4444444444444444444444444444444444444444"
tTxHash = "0xabc0000000000000000000000000000000000000000000000000000000000001"
)
// fakeRPC is a minimal eth JSON-RPC node: it returns a settable `result` for
// eth_getTransactionReceipt (nil ⇒ null ⇒ not mined) and records the tx it was asked.
type fakeRPC struct {
mu sync.Mutex
result any
gotTx string
}
func (f *fakeRPC) server(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Params []string `json:"params"`
}
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &req)
f.mu.Lock()
if len(req.Params) > 0 {
f.gotTx = req.Params[0]
}
res := f.result
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": res})
}))
t.Cleanup(srv.Close)
return srv
}
// fakeCommerce records the S2S payment record + balance reads.
type fakeCommerce struct {
mu sync.Mutex
payment map[string]any
gotOrg string
gotAuth string
balance int64
}
func (f *fakeCommerce) server(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/billing/payment", func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var p map[string]any
_ = json.Unmarshal(raw, &p)
f.mu.Lock()
f.payment, f.gotOrg, f.gotAuth = p, r.Header.Get("X-Org-Id"), r.Header.Get("Authorization")
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"paid"}`)
})
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
bal := f.balance
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"balance": bal})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// husdReceipt builds a receipt carrying one 18-decimal Transfer(from→to, cents*1e16).
func husdReceipt(status, husd, from, to string, cents int64) map[string]any {
return tokenReceipt(status, husd, from, to, cents, 18)
}
// tokenReceipt builds a receipt for a Transfer of `cents` worth of a token with the
// given decimals — the knob that proves a 6-decimal USDC is not priced with an
// 18-decimal divisor.
func tokenReceipt(status, token, from, to string, cents int64, decimals int) map[string]any {
value := new(big.Int).Mul(big.NewInt(cents), centDivisor(decimals))
return map[string]any{
"status": status,
"logs": []any{map[string]any{
"address": token,
"topics": []any{transferTopic, addrToTopic(from), addrToTopic(to)},
"data": "0x" + fmt.Sprintf("%064x", value),
}},
}
}
// setTopupEnv configures ONE 18-decimal rail, preserving these tests' original
// arithmetic (18 decimals ⇒ 1e16 per cent) so they still assert the same cents.
// An empty token/treasury yields no rail at all — the "not configured" case.
func setTopupEnv(t *testing.T, token, treasury, rpcURL, commerceURL string) {
t.Helper()
setTopupRails(t, commerceURL, rail{
ID: "hanzo-husd", Chain: "Hanzo", ChainID: 36963, RPCURL: rpcURL,
Token: token, Symbol: "HUSD", Decimals: 18, Treasury: treasury,
})
}
// setTopupRails installs an explicit rail set. Malformed rails are dropped by
// loadTopupConfig, which is how the not-configured cases above stay 501.
func setTopupRails(t *testing.T, commerceURL string, rails ...rail) {
t.Helper()
raw, err := json.Marshal(rails)
if err != nil {
t.Fatalf("marshal rails: %v", err)
}
t.Setenv("TOPUP_RAILS", string(raw))
t.Setenv("COMMERCE_URL", commerceURL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-token")
}
// principal for a signed-in caller in org acme.
var alice = map[string]string{"X-User-Id": "alice", "X-Org-Id": "acme"}
func TestTopup_NotConfigured_501(t *testing.T) {
setTopupEnv(t, "", "", "http://rpc.invalid", "http://commerce.invalid")
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice, `{"txHash":"`+tTxHash+`"}`)
if code != http.StatusNotImplemented {
t.Fatalf("HUSD unconfigured: want 501, got %d", code)
}
}
func TestTopup_RequiresValidatedPrincipal(t *testing.T) {
rpc := &fakeRPC{}
com := &fakeCommerce{}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", nil, `{"txHash":"`+tTxHash+`"}`)
if code != http.StatusForbidden {
t.Fatalf("no principal: want 403, got %d", code)
}
}
func TestTopup_BadTxHash_400(t *testing.T) {
rpc := &fakeRPC{}
com := &fakeCommerce{}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice, `{"txHash":"0xnothex"}`)
if code != http.StatusBadRequest {
t.Fatalf("bad txHash: want 400, got %d", code)
}
}
func TestTopup_HappyPath_VerifiesAndCredits(t *testing.T) {
rpc := &fakeRPC{result: husdReceipt("0x1", tHusd, tSender, tTreasury, 500)}
com := &fakeCommerce{balance: 1200}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
code, body := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice,
`{"txHash":"`+tTxHash+`","fromAddress":"`+tSender+`"}`)
if code != http.StatusOK {
t.Fatalf("topup: want 200, got %d (%s)", code, body)
}
var r walletTopupResp
mustJSON(t, body, &r)
if r.CreditedCents != 500 || r.Balance != 1200 || r.Status != "paid" || r.TxHash != tTxHash {
t.Fatalf("topup result wrong: %+v", r)
}
// Commerce recorded the on-chain amount (500), scoped S2S to the caller's org, on
// the caller's own subject, with the service bearer.
if com.gotOrg != "acme" || com.gotAuth != "Bearer svc-token" {
t.Fatalf("commerce S2S auth wrong: org=%q auth=%q", com.gotOrg, com.gotAuth)
}
if com.payment["userId"] != "acme/alice" {
t.Fatalf("credit must target the validated caller acme/alice, got %v", com.payment["userId"])
}
if amt, _ := com.payment["amount"].(float64); amt != 500 {
t.Fatalf("recorded amount must be the on-chain 500 cents, got %v", com.payment["amount"])
}
if com.payment["currency"] != "husd" {
t.Fatalf("currency must be husd, got %v", com.payment["currency"])
}
if rpc.gotTx != tTxHash {
t.Fatalf("rpc should have been asked for %s, got %s", tTxHash, rpc.gotTx)
}
}
func TestTopup_IDOR_CreditsCallerNotBodyUserId(t *testing.T) {
rpc := &fakeRPC{result: husdReceipt("0x1", tHusd, tSender, tTreasury, 100)}
com := &fakeCommerce{}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
// The body tries to credit "victim/root"; the handler MUST ignore it and credit
// the validated caller (acme/alice).
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice,
`{"txHash":"`+tTxHash+`","userId":"victim/root"}`)
if code != http.StatusOK {
t.Fatalf("topup: want 200, got %d", code)
}
if com.payment["userId"] != "acme/alice" || com.gotOrg != "acme" {
t.Fatalf("IDOR: credit must land on acme/alice, got userId=%v org=%q", com.payment["userId"], com.gotOrg)
}
}
func TestTopup_NotMined_400(t *testing.T) {
rpc := &fakeRPC{result: nil} // JSON-RPC null ⇒ not found / not mined
com := &fakeCommerce{}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice, `{"txHash":"`+tTxHash+`"}`)
if code != http.StatusBadRequest {
t.Fatalf("not mined: want 400, got %d", code)
}
if com.payment != nil {
t.Fatalf("commerce must not be called for an unmined tx")
}
}
func TestTopup_FailedTx_400(t *testing.T) {
rpc := &fakeRPC{result: husdReceipt("0x0", tHusd, tSender, tTreasury, 500)} // reverted
com := &fakeCommerce{}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice, `{"txHash":"`+tTxHash+`"}`)
if code != http.StatusBadRequest {
t.Fatalf("failed tx: want 400, got %d", code)
}
}
func TestTopup_NoTransferToTreasury_400(t *testing.T) {
// A valid HUSD transfer, but to some OTHER address (not the treasury) → rejected.
rpc := &fakeRPC{result: husdReceipt("0x1", tHusd, tSender, tOther, 500)}
com := &fakeCommerce{}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice, `{"txHash":"`+tTxHash+`"}`)
if code != http.StatusBadRequest {
t.Fatalf("non-treasury transfer: want 400, got %d", code)
}
}
func TestTopup_SenderMismatch_400(t *testing.T) {
rpc := &fakeRPC{result: husdReceipt("0x1", tHusd, tSender, tTreasury, 500)}
com := &fakeCommerce{}
setTopupEnv(t, tHusd, tTreasury, rpc.server(t).URL, com.server(t).URL)
app := mountBrand(t, "hanzo")
// Claims a different fromAddress than the on-chain sender → rejected.
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice,
`{"txHash":"`+tTxHash+`","fromAddress":"`+tOther+`"}`)
if code != http.StatusBadRequest {
t.Fatalf("sender mismatch: want 400, got %d", code)
}
}
// ── rails: the multi-chain generalisation ────────────────────────────────────────
const (
tUSDC = "0x5555555555555555555555555555555555555555"
tTreasBase = "0x6666666666666666666666666666666666666666"
)
func usdcRail(rpcURL string) rail {
return rail{
ID: "base-usdc", Chain: "Base", ChainID: 8453, RPCURL: rpcURL,
Token: tUSDC, Symbol: "USDC", Decimals: 6, Treasury: tTreasBase,
}
}
// The decimals bug this design exists to prevent: USDC has 6 decimals, so 500 cents
// is 5_000_000 base units. Priced with an 18-decimal divisor it would round to ZERO
// and silently credit nothing; priced the other way it would credit 10^12 times too
// much. The rail's own decimals must be what is used.
func TestTopup_USDC_SixDecimals_PricedByRail(t *testing.T) {
rpc := &fakeRPC{result: tokenReceipt("0x1", tUSDC, tSender, tTreasBase, 500, 6)}
com := &fakeCommerce{balance: 500}
setTopupRails(t, com.server(t).URL, usdcRail(rpc.server(t).URL))
app := mountBrand(t, "hanzo")
code, body := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice,
`{"rail":"base-usdc","txHash":"`+tTxHash+`","fromAddress":"`+tSender+`"}`)
if code != http.StatusOK {
t.Fatalf("usdc topup: want 200, got %d (%s)", code, body)
}
var r walletTopupResp
mustJSON(t, body, &r)
if r.CreditedCents != 500 {
t.Fatalf("6-decimal USDC must credit 500 cents, got %d", r.CreditedCents)
}
// The ledger row must describe the rail that was actually verified.
if com.payment["currency"] != "usdc" || com.payment["network"] != "Base" {
t.Fatalf("payment must record the real rail, got currency=%v network=%v",
com.payment["currency"], com.payment["network"])
}
if id, _ := com.payment["chainId"].(float64); id != 8453 {
t.Fatalf("chainId must be the rail's 8453, got %v", com.payment["chainId"])
}
}
// With several rails configured the client MUST name one: silently picking a default
// could verify a transfer against another chain's treasury.
func TestTopup_MultipleRails_RequiresNamingOne(t *testing.T) {
rpc := &fakeRPC{result: tokenReceipt("0x1", tUSDC, tSender, tTreasBase, 500, 6)}
com := &fakeCommerce{}
setTopupRails(t, com.server(t).URL,
usdcRail(rpc.server(t).URL),
rail{ID: "hanzo-husd", Chain: "Hanzo", ChainID: 36963, RPCURL: rpc.server(t).URL,
Token: tHusd, Symbol: "HUSD", Decimals: 18, Treasury: tTreasury},
)
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice,
`{"txHash":"`+tTxHash+`"}`)
if code != http.StatusBadRequest {
t.Fatalf("omitting the rail with >1 configured: want 400, got %d", code)
}
code, _ = callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice,
`{"rail":"nope","txHash":"`+tTxHash+`"}`)
if code != http.StatusBadRequest {
t.Fatalf("unknown rail: want 400, got %d", code)
}
}
// A transfer on the right token but to ANOTHER rail's treasury must not credit.
func TestTopup_WrongRailTreasury_400(t *testing.T) {
rpc := &fakeRPC{result: tokenReceipt("0x1", tUSDC, tSender, tTreasury, 500, 6)} // hanzo treasury
com := &fakeCommerce{}
setTopupRails(t, com.server(t).URL, usdcRail(rpc.server(t).URL))
app := mountBrand(t, "hanzo")
code, _ := callH(t, app, http.MethodPost, "/v1/commerce/topup/wallet", alice,
`{"rail":"base-usdc","txHash":"`+tTxHash+`"}`)
if code != http.StatusBadRequest {
t.Fatalf("transfer to a different treasury: want 400, got %d", code)
}
}
// The public listing gives a browser what it needs to send funds — and must not leak
// the operational RPC endpoint, which lives on the same config struct.
func TestTopupRails_PublishesSendInfoWithoutRPC(t *testing.T) {
com := &fakeCommerce{}
setTopupRails(t, com.server(t).URL, usdcRail("http://secret-rpc.internal"))
app := mountBrand(t, "hanzo")
code, body := callH(t, app, http.MethodGet, "/v1/commerce/topup/rails", alice, "")
if code != http.StatusOK {
t.Fatalf("rails: want 200, got %d (%s)", code, body)
}
var got struct{ Rails []map[string]any }
mustJSON(t, body, &got)
if len(got.Rails) != 1 || got.Rails[0]["treasury"] != tTreasBase || got.Rails[0]["decimals"].(float64) != 6 {
t.Fatalf("rails listing wrong: %s", body)
}
if _, leaked := got.Rails[0]["rpcUrl"]; leaked {
t.Fatalf("rails listing must not publish the RPC endpoint: %s", body)
}
}
// No rail configured ⇒ the listing is an empty array, not null, so a client can read
// .length without a nil check — and the POST is an honest 501.
func TestTopupRails_EmptyWhenUnconfigured(t *testing.T) {
t.Setenv("TOPUP_RAILS", "")
app := mountBrand(t, "hanzo")
code, body := callH(t, app, http.MethodGet, "/v1/commerce/topup/rails", alice, "")
if code != http.StatusOK || !strings.Contains(string(body), `"rails":[]`) {
t.Fatalf("unconfigured rails: want 200 with [], got %d (%s)", code, body)
}
}
+9 -1
View File
@@ -34,7 +34,15 @@ import (
// generated SDK method come from — so an operation missing from that registry is
// invisible to all four. Nothing is missing today; an addition here needs the wire
// fact that makes typing it impossible, not a preference.
var untypedByDesign = map[string]string{}
var untypedByDesign = map[string]string{
// The profile-photo pair (avatar.go). Both are raw by a property of the WIRE, not
// by preference: the upload's request is a multipart form, where op.invoke
// unmarshals JSON before the handler runs; and the read's response is the image's
// BYTES under a Content-Type derived from those bytes, where a typed dispatch ends
// in c.JSON under one declared 2xx. Neither is a shape an In/Out can carry.
"POST /v1/avatar": "multipart upload: the request body is a form, not JSON",
"GET /v1/avatar/{org}/{user}/{digest}": "raw image bytes under a byte-derived Content-Type, not a JSON envelope",
}
// accountOps reads BOTH projections of the live router at their one shared address
// form: what the document says is served, and which of those carry a typed
+2 -25
View File
@@ -18,18 +18,8 @@ func init() {
},
Example: json.RawMessage(`{"type":"publishable"}`),
})
zip.Describe("GET /v1/commerce/topup/rails", zip.Doc{
Description: "Lists the accepted (chain, token, treasury) triples, so a browser can\nrender \"send USDC here\" without the addresses being baked into its bundle.\n\nThis exists because the console previously gated its top-up UI on\nNEXT_PUBLIC_HANZO_HUSD_ADDRESS/_TREASURY — build-time constants. Enabling a rail\ntherefore meant rebuilding and redeploying the frontend, and with them unset the\nUI reported \"not available yet\" no matter what the server could actually accept.\nServing the set at runtime keeps ONE source of truth (the server's config) and\nlets a rail be switched on without shipping a bundle.\n\nEverything here is public on-chain data; no secret is exposed, and the set is\nempty on a deployment that accepts no crypto rail.",
Fields: map[string]string{
"railList.rails": "Rails is every (chain, token, treasury) triple this deployment accepts.",
"railView.chain": "Chain is the human chain name, e.g. \"Base\".",
"railView.chainId": "ChainID is the EIP-155 chain id the wallet must be on.",
"railView.decimals": "Decimals is the token's decimal places — 6 for USDC, 18 for an 18-decimal\ntoken. Cents are derived per-rail from it.",
"railView.id": "ID is the stable rail id to name when submitting a transfer, e.g. \"base-usdc\".",
"railView.symbol": "Symbol is the display symbol, e.g. \"USDC\".",
"railView.token": "Token is the ERC-20 contract address to transfer.",
"railView.treasury": "Treasury is the address on this chain to send funds to.",
},
zip.Describe("GET /avatar/:org/:user/:digest", zip.Doc{
Description: "Streams a stored photo. No credentials — see the file header.",
})
zip.Describe("GET /v1/csrf", zip.Doc{
Description: "IssueCSRFToken mints the anti-CSRF token a browser echoes as X-CSRF-Token on\nevery money write (mint/revoke a key, top up, onboard, and the billing/commerce\nwrite verbs). The token is bound to the caller's validated identity and expires,\nso one minted for one identity cannot authorize a write as another.\n\nIt is answered no-store, so it is never cached by a shared proxy. This is the\nsame-origin endpoint the embedded console reads — the Same-Origin Policy is what\nstops a cross-site page from reading the response and forging a write.",
@@ -61,19 +51,6 @@ func init() {
"apiKeyList.keys": "Keys is every key the caller holds, at most one per type.",
},
})
zip.Describe("POST /v1/commerce/topup/wallet", zip.Doc{
Description: "Credits the caller's org for a stablecoin transfer they already sent\nto the treasury. It reads the receipt from that rail's chain, confirms a mined,\nsuccessful ERC-20 Transfer to the rail's treasury, derives USD cents from the\non-chain value using the token's own decimals, records the credit, and returns\nthe amount plus the new balance.\n\nThe credited amount is the ON-CHAIN value, never a number the caller sends, and\nthe credit lands on the caller's own validated org — there is no way to name a\nthird-party subject. Nothing is credited that the chain did not confirm: a\nmissing, failed or non-matching transaction is refused, and a deployment with no\npayment rail enabled says so rather than inventing a credit.",
Fields: map[string]string{
"walletTopupReq.fromAddress": "FromAddress is the wallet the transfer was sent from. Optional; when given it\nmust match the transfer's on-chain sender.",
"walletTopupReq.rail": "Which accepted rail the transfer was sent on, e.g. \"base-usdc\". The client\nnames it rather than the server guessing from the tx: the same address can\nexist on several chains, so inferring would risk crediting against the wrong\ntreasury. It may be omitted only while exactly one rail is enabled.",
"walletTopupReq.txHash": "TxHash is the hash of the ERC-20 transfer that was already sent to the rail's\ntreasury. The receipt is read from that chain; nothing is credited that the\nchain did not confirm.",
"walletTopupResp.balance": "Balance is the org's new USD-ledger balance in cents. Best-effort: a read\nfailure reports 0, and the credit has already landed either way.",
"walletTopupResp.creditedCents": "CreditedCents is the USD credit recorded, derived from the ON-CHAIN value\nusing the token's own decimals — never a client-supplied number.",
"walletTopupResp.status": "Status is how commerce recorded the payment.",
"walletTopupResp.txHash": "TxHash is the transfer that was credited.",
},
Example: json.RawMessage(`{"rail":"base-usdc","txHash":"0x0000000000000000000000000000000000000000000000000000000000000001"}`),
})
zip.Describe("POST /v1/keys", zip.Doc{
Description: "Creates — or rotates — the caller's API key of the requested type and\nreturns it ONCE. A real IAM failure surfaces as 502, never a fabricated key.\n\nRotating is what creating means here: a user holds one key per type, so the\nendpoint is idempotent by (caller, type) and the superseded credential stops\nworking. Two live secrets for one user would make \"revoke my key\" a lie.",
Fields: map[string]string{
+2 -7
View File
@@ -119,7 +119,7 @@ func routes(app cloud.Router, s *cloud.Service[core.State]) {
// after these leaves would never run — and every op below takes the request off the
// context it parks. Bounded to admin's own subtree. Serve installs one app-wide too;
// nesting is harmless, and this is what makes the surface testable on a bare app.
app.Group("/v1/admin").Use(cloud.Bridge())
app.Use(cloud.Bridge())
// Org-scoped panels — AdmitScoped. Cross-tenant reads are impossible for a
// non-super caller.
@@ -144,11 +144,6 @@ func routes(app cloud.Router, s *cloud.Service[core.State]) {
zip.Get(z, "/v1/admin/money", o.Money, op("adminMoney"))
zip.Post(z, "/v1/admin/sync", syncNow, op("adminSync"))
// Credit — the ONE admin mint surface (SuperAdmin only). Thin, audited relay
// to commerce's mint-gated POST /v1/billing/credits; commerce is the sole
// ledger. See credits.go.
zip.Post(z, "/v1/admin/credits", o.createCredit, op("adminCreateCredit"))
// Product analytics — org-scoped (SuperAdmin: all-orgs; org admin: their own org).
zip.Get(z, "/v1/admin/analytics", o.analytics, op("adminAnalytics"))
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
@@ -253,7 +248,7 @@ func (o ops) orgs(ctx context.Context, _ *core.None) (*orgsOut, error) {
rows := make([]orgRow, 0, len(orgs))
for _, row := range orgs {
users := orgUserCount(o.s, ctx, cr, row.Name)
// orgs is a per-ROW panel (OrgRow[] via OKList; it carries NO sources[] channel):
// orgs is a per-ROW panel (orgRow[]; it carries NO sources[] channel):
// a failed read degrades THAT org's row to an honest zero, never a fleet total that
// falsely reads healthy. The aggregate-freshness signal lives on /overview.
spend, credits, _ := core.OrgMoney(o.s, ctx, row.Name)
+1 -1
View File
@@ -212,7 +212,7 @@ func TestAdminAudit_VerifyWithoutStore(t *testing.T) {
for k, v := range superAdmin {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
resp, err := app.Test(req, zip.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("verify: %v", err)
}
-12
View File
@@ -306,18 +306,6 @@ func (c *Client) Deposit(ctx context.Context, subject string, amount money.Cents
return out, nil
}
// CreateCreditGrant forwards a credit-grant request verbatim to commerce's
// mint-gated POST /v1/billing/credits (CreateCreditGrant), authenticated
// by the admin service token, with subject as the target-org namespace selector.
// Commerce is the sole credit-grant ledger; this relays its contract untouched
// (the raw response is returned to the caller) so the admin surface stays thin.
func (c *Client) CreateCreditGrant(ctx context.Context, subject string, body []byte, idempotencyKey string) ([]byte, error) {
if !c.Ready() {
return nil, errUnconfigured
}
return c.post(ctx, "/v1/billing/credits", subject, body, idempotencyKey)
}
// post performs one admin-authenticated commerce POST (JSON body) and returns the
// raw response. The admin S2S service token is the bearer and X-Org-Id=<subject>
// the per-org namespace selector commerce's EdgeAuth trusts only after verifying
+1 -1
View File
@@ -64,7 +64,7 @@ func TestGrantIdempotencyKeyBindsTheSubject(t *testing.T) {
})
req := httptest.NewRequest("GET", "/k", nil)
req.Header.Set("Idempotency-Key", "one-nonce")
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("key probe: %v", err)
}
-74
View File
@@ -1,74 +0,0 @@
package admin
import (
"context"
"encoding/json"
"strings"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/apps/admin/core"
)
// createCredit mints credit for one org. It is the ONE admin mint surface, and it
// does NOT mint in-process: it forwards the request to commerce's already-mint-gated
// POST /v1/billing/credits, authenticated by the service token and scoped to the
// target org, then writes one tamper-evident compliance record. Commerce stays the sole
// credit ledger; this is a thin, audited relay so there is exactly one place credit is
// created.
//
// The body is commerce's OWN CreateCreditGrant contract, forwarded whole — every field
// it carries reaches commerce. The only two this layer reads are the target org (`org`,
// or `user` as the org-pool alias), which selects the namespace commerce's EdgeAuth
// trusts, and `idempotencyKey`, which makes a double-clicked grant credit once.
//
// A FAILED grant is audited too, with the request body attached: an attempted mint is
// exactly as interesting to a compliance auditor as a successful one.
//
// Example: {"org":"acme","amountCents":50000,"reason":"design partner credit",
// "idempotencyKey":"grant-2026-07-27-acme"}
// Response: {"status":"ok","msg":"","data":{"id":"cg_01J","org":"acme","amountCents":50000,
// "remainingCents":50000}}
func (o ops) createCredit(ctx context.Context, in *creditGrantIn) (*rawOut, error) {
c, err := core.Admit(ctx)
if err != nil {
return nil, err
}
s := o.s
if !s.State.Commerce.Ready() {
return &rawOut{Status: core.Err, Msg: "commerce is not configured on this deployment"}, nil
}
req := map[string]any(*in)
org, _ := req["org"].(string)
if strings.TrimSpace(org) == "" {
org, _ = req["user"].(string)
}
org = strings.TrimSpace(org)
if org == "" {
return &rawOut{Status: core.Err, Msg: "org is required"}, nil
}
idempotencyKey, _ := req["idempotencyKey"].(string)
body, err := json.Marshal(req)
if err != nil {
return &rawOut{Status: core.Err, Msg: "invalid request body"}, nil
}
raw, err := s.State.Commerce.CreateCreditGrant(ctx, org, body, idempotencyKey)
if err != nil {
core.EmitAudit(s, c, "admin.customer.credit-grant", "credit-grant", org,
req, map[string]any{"error": err.Error()},
audit.Outcome{Result: "error", Status: 502, Reason: "credit-grant failed"})
return &rawOut{Status: core.Err, Msg: "credit-grant failed: " + err.Error()}, nil
}
core.EmitAudit(s, c, "admin.customer.credit-grant", "credit-grant", org,
nil, json.RawMessage(raw),
audit.Outcome{Result: "success", Status: 200})
return &rawOut{Status: core.OK, Data: json.RawMessage(raw)}, nil
}
// creditGrantIn is commerce's CreateCreditGrant body, held open rather than modelled: a
// Go struct here would silently DROP any field commerce adds, and commerce — not this
// relay — owns that contract. See the handler for the two keys admin itself reads.
type creditGrantIn map[string]any
-5
View File
@@ -229,11 +229,6 @@ func init() {
Example: json.RawMessage(`{"org":"acme","limitCents":100000,"enforce":true}`),
Response: json.RawMessage(`{"status":"ok","msg":"","data":{"id":"cap_1","limitCents":100000,"enforce":true},"total":0}`),
})
zip.Describe("POST /v1/admin/credits", zip.Doc{
Description: "Mints credit for one org. It is the ONE admin mint surface, and it\ndoes NOT mint in-process: it forwards the request to commerce's already-mint-gated\nPOST /v1/billing/credits, authenticated by the service token and scoped to the\ntarget org, then writes one tamper-evident compliance record. Commerce stays the sole\ncredit ledger; this is a thin, audited relay so there is exactly one place credit is\ncreated.\n\nThe body is commerce's OWN CreateCreditGrant contract, forwarded whole — every field\nit carries reaches commerce. The only two this layer reads are the target org (`org`,\nor `user` as the org-pool alias), which selects the namespace commerce's EdgeAuth\ntrusts, and `idempotencyKey`, which makes a double-clicked grant credit once.\n\nA FAILED grant is audited too, with the request body attached: an attempted mint is\nexactly as interesting to a compliance auditor as a successful one.",
Example: json.RawMessage(`{"org":"acme","amountCents":50000,"reason":"design partner credit","idempotencyKey":"grant-2026-07-27-acme"}`),
Response: json.RawMessage(`{"status":"ok","msg":"","data":{"id":"cg_01J","org":"acme","amountCents":50000,"remainingCents":50000}}`),
})
zip.Describe("POST /v1/admin/services", zip.Doc{
Description: "Onboards a hosted service, or edits one, so a new host comes under the\nlaunch gate WITHOUT a redeploy. Re-registering an existing service PRESERVES its live\nswitch — editing the hosts of a service that is already open must not silently close\nit again.",
Fields: map[string]string{
+1 -1
View File
@@ -27,7 +27,7 @@ func ctxWith(t *testing.T, headers map[string]string, fn func(c *zip.Ctx)) {
for k, v := range headers {
hr.Header.Set(k, v)
}
resp, err := app.Fiber().Test(hr)
resp, err := app.Test(hr)
if err != nil {
t.Fatalf("probe: %v", err)
}
+1 -1
View File
@@ -79,7 +79,7 @@ func drive(t *testing.T, app *zip.App, r greq) (int, string) {
if r.apiKeyHeader != "" {
hr.Header.Set("api-key", r.apiKeyHeader)
}
resp, err := app.Fiber().Test(hr)
resp, err := app.Test(hr)
if err != nil {
t.Fatalf("drive: %v", err)
}
+1 -1
View File
@@ -350,7 +350,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// A typed op receives only a context, so the request the ?host= default falls
// back to has to be parked there. Installed BEFORE the leaf — fiber runs
// middleware in registration order, so one installed after it never runs.
app.Group("/v1/flags/waitlist").Use(cloud.Bridge())
app.Use(cloud.Bridge())
zip.Get(cloud.ZipApp(app), "/v1/flags/waitlist", waitlistOps{}.mode)
log.Info("admission gate ready", "services", n)
return nil
+1 -1
View File
@@ -39,7 +39,7 @@ func ask(t *testing.T, app *zip.App, url, hostHeader string) waitlistModeView {
if hostHeader != "" {
req.Host = hostHeader
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s: %v", url, err)
}
+1 -1
View File
@@ -65,7 +65,7 @@ func do(t *testing.T, app *zip.App, method, path, org string, body []byte) (int,
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u_"+org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+4 -106
View File
@@ -23,13 +23,14 @@
// 2. A new org signs up via the link → the console posts POST /v1/affiliates/
// attribute with the code → we record referred_org↔affiliate (first-touch,
// one per referred org, self-attribution blocked).
// 3. The ACCRUAL SWEEP (POST /v1/admin/affiliates/sweep, the cron path; also lazy
// 3. The ACCRUAL SWEEP (POST /v1/admin/affiliates/sweep, SuperAdmin; also lazy
// on the affiliate's own dashboard read) folds over each affiliate's referred
// orgs: commission = the referred org's metered spend THIS PERIOD × the rate,
// accrued into the affiliate's balance as an affiliate_event. The accrual is
// LATCHED at-most-once per (affiliate, referred_org, period) — a re-run in the
// same period never double-accrues, mirroring the referral credit latch.
// 4. Staff PAY OUT accrued commission (POST /v1/admin/affiliates/:id/payout):
// 4. Staff RECORD a payout of accrued commission (POST /v1/admin/affiliates/:id/payout,
// record-only — a human settles it):
// a "credits" method issues a commerce grant into the affiliate's wallet; cash
// methods (wire/paypal/…) are record-only. A payout can never exceed pending
// (accrued paid), guarded atomically.
@@ -42,7 +43,7 @@
// GET /v1/admin/affiliates (SuperAdmin) every affiliate + a summary
// POST /v1/admin/affiliates/:id/approve (SuperAdmin) approve + mint the code
// POST /v1/admin/affiliates/:id/suspend (SuperAdmin) suspend
// POST /v1/admin/affiliates/:id/payout (SuperAdmin) record a payout (credits → grant; cash → record-only)
// POST /v1/admin/affiliates/:id/payout (SuperAdmin) RECORD a payout (record-only; a human settles it)
// POST /v1/admin/affiliates/sweep (SuperAdmin) accrue commission for every referred org this period
//
// serve.go auto-registers GET /v1/affiliates/health.
@@ -66,7 +67,6 @@ import (
"github.com/hanzoai/cloud/apps/commerce/transport"
"github.com/hanzoai/cloud/apps/flags"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/treasury"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
@@ -501,17 +501,6 @@ func myAffiliates(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
// Lazy accrual sweep for MY referred orgs (bounded, best-effort — a commerce
// hiccup never fails the page; it simply accrues on the next sweep).
if a.Status == StatusApproved {
if _, _, serr := sweepAffiliate(s, ctx, a); serr != nil {
s.Log.Warn("affiliates: lazy sweep failed", "affiliate", a.ID, "err", serr)
}
if refreshed, rerr := s.State.store.GetByID(ctx, a.ID); rerr == nil {
a = refreshed // pick up any accrual the lazy sweep just latched
}
}
referred, err := s.State.store.CountReferrals(ctx, a.ID)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "count referrals: %v", err)
@@ -572,15 +561,6 @@ func myAffiliatesMe(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
if a.Status == StatusApproved {
if _, _, serr := sweepAffiliate(s, ctx, a); serr != nil {
s.Log.Warn("affiliates: lazy sweep failed", "affiliate", a.ID, "err", serr)
}
if refreshed, rerr := s.State.store.GetByID(ctx, a.ID); rerr == nil {
a = refreshed
}
}
downline, err := s.State.store.DownlineByLevel(ctx, a.Org, maxDepth)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "downline: %v", err)
@@ -970,41 +950,6 @@ func adminPayout(s *cloud.Service[state], c *zip.Ctx) error {
}
}
// BACK the payout against the platform reserve fund (double-entry
// fund→payout:affiliate, idempotent by payout id). This is the SECOND guard: a
// payout must not exceed EITHER the affiliate's pending commission (above) OR the
// funded reserve (here). Not backed → VOID the pending reservation (restore it)
// and refuse honestly — the platform has not reserved capital for this payout.
backed, _, berr := treasury.Reserve(ctx, treasury.ProgramAffiliate, "payout:"+payoutID,
fmt.Sprintf("Affiliate commission payout (%s)", a.Code), body.AmountCents)
if berr != nil || !backed {
if verr := s.State.store.VoidPayout(ctx, payoutID, a.ID, body.AmountCents); verr != nil {
s.Log.Error("affiliates: void after unbacked payout failed", "payout", payoutID, "err", verr)
}
if berr != nil {
return zip.Errorf(http.StatusInternalServerError, "reserve payout: %v", berr)
}
reserve, _ := treasury.ReserveCents(ctx)
return zip.Errorf(http.StatusPaymentRequired,
"treasury reserve insufficient to back this payout (%d cents available); replenish via /v1/admin/treasury/sweep or seed", reserve)
}
// A credits payout issues the actual grant AFTER both reservations. The
// reservations are the safety authority (at-most-pending AND at-most-reserve); a
// grant failure is logged loud (never silent) so an operator reconciles from the
// payout row + audit.
if method == methodCredits {
txn, gerr := s.State.commerce.deposit(ctx, a.Org, orgSubject(a.Org), body.AmountCents, grantCurrency,
fmt.Sprintf("Affiliate commission payout (%s)", a.Code), grantTag)
if gerr != nil {
s.Log.Error("affiliates: credits payout grant failed (reserved against pending; not retried)",
"affiliate", a.ID, "payout", payoutID, "err", gerr)
} else if serr := s.State.store.SetPayoutTxn(ctx, payoutID, txn); serr != nil {
s.Log.Error("affiliates: record payout txn failed", "payout", payoutID, "err", serr)
}
payout.Txn = txn
}
after, _ := s.State.store.GetByID(ctx, a.ID)
emitAudit(s, ctx, "affiliate.payout", after, map[string]any{
"payoutId": payout.ID, "amountCents": payout.AmountCents, "method": payout.Method,
@@ -1111,53 +1056,6 @@ func accrueSource(s *cloud.Service[state], ctx context.Context, sourceOrg string
return created, nil
}
// sweepAffiliate refreshes ONE affiliate's accrual for the dashboard read: it walks
// DOWN the affiliate's referredBy subtree to maxDepth and accrues this period's
// commission from each downline source at that source's level, latched at-most-once.
// It is the per-affiliate mirror of the source-centric admin sweep (same latch key,
// so the two never double-accrue). Returns (sources checked, accruals created).
func sweepAffiliate(s *cloud.Service[state], ctx context.Context, a Affiliate) (checked, created int, err error) {
if a.Status != StatusApproved {
return 0, 0, nil
}
downline, err := s.State.store.DownlineByLevel(ctx, a.Org, maxDepth)
if err != nil {
return 0, 0, err
}
period := periodKey(time.Now())
now := time.Now().Unix()
for src, level := range downline {
checked++
spend, serr := s.State.commerce.spendCents(ctx, src, orgSubject(src))
if serr != nil {
s.Log.Warn("affiliates: spend read failed", "affiliate", a.ID, "source", src, "err", serr)
continue
}
margin := marginOf(spend, affiliateMarginBps())
commission := margin * levelRateBps(level, a) / bpsDenom
if commission <= 0 {
continue
}
accrualID, gerr := genID("aca")
if gerr != nil {
continue
}
moved, lerr := s.State.store.Accrue(ctx, accrualID, a.ID, src, period, level, spend, margin, commission, now)
if lerr != nil {
s.Log.Warn("affiliates: accrual failed", "affiliate", a.ID, "source", src, "err", lerr)
continue
}
if moved {
created++
emitAudit(s, ctx, "affiliate.accrue", a, map[string]any{
"sourceOrg": src, "period": period, "level": level,
"spendCents": spend, "marginCents": margin, "commissionCents": commission,
})
}
}
return checked, created, nil
}
// ── audit ─────────────────────────────────────────────────────────────────────
// emitAudit records an affiliate money/lifecycle action in cloud's tamper-evident
+32 -31
View File
@@ -17,7 +17,6 @@ import (
// test process has no KMS.
_ "github.com/hanzoai/cloud/internal/devmaster"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
@@ -40,7 +39,7 @@ func newFakeCommerce() *fakeCommerce {
func (f *fakeCommerce) configured() bool { return true }
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _ string) (string, error) {
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failDep {
@@ -131,7 +130,7 @@ func req(t *testing.T, app *zip.App, method, path, org string, admin bool, body
// A generous ceiling: a correct request completes in well under 100ms, so 30s
// never fires spuriously — it only guards a genuine hang. The fiber default is 1s,
// which flakes under CI/machine load, not on request latency.
resp, err := app.Fiber().Test(hr, fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
resp, err := app.Test(hr, zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -399,9 +398,9 @@ func TestSweepAccruesSpendTimesRateIdempotent(t *testing.T) {
}
}
// TestLazyAccrualOnAffiliateRead proves the affiliate's OWN GET /v1/affiliates runs
// the accrual sweep for its referred orgs (self-updating dashboard).
func TestLazyAccrualOnAffiliateRead(t *testing.T) {
// TestAffiliateReadGrantsNothing is the inverse of the lazy sweep that used to live
// here: GET /v1/affiliates is a PURE READ. Only the admin POST accrues.
func TestAffiliateReadGrantsNothing(t *testing.T) {
app, s, fc := mount(t)
_, codeA := applyAndApprove(t, app, s, "orgA", "acme", "")
req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeA})
@@ -429,16 +428,24 @@ func TestLazyAccrualOnAffiliateRead(t *testing.T) {
if v.Link != "https://hanzo.ai/?aff="+codeA {
t.Fatalf("link = %q", v.Link)
}
want := share(5000, defaultRateBps) // margin × rate
if v.ReferredCount != 1 || v.AccruedCents != want || v.PendingCents != want {
t.Fatalf("lazy accrual not reflected: %+v (want accrued %d)", v, want)
if v.ReferredCount != 1 || v.AccruedCents != 0 || v.PendingCents != 0 {
t.Fatalf("a GET accrued: %+v (want 0/0)", v)
}
if fc.depositCount() != 0 {
t.Fatalf("a GET deposited %d time(s); want 0", fc.depositCount())
}
// Not vacuous: the same state accrues the moment a human asks.
req(t, app, http.MethodPost, "/v1/admin/affiliates/sweep", "admin", true, nil)
a, _ := s.State.store.GetByOrg(context.Background(), "orgA")
if want := share(5000, defaultRateBps); a.AccruedCents != want {
t.Fatalf("admin sweep accrued %d, want %d", a.AccruedCents, want)
}
}
// TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard: a credits payout issues
// exactly ONE commerce grant + moves paid; a cash payout is record-only; a payout
// can never exceed pending.
func TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard(t *testing.T) {
// TestPayoutIsRecordOnlyAndPendingGuard: a payout RECORDS a disbursement and moves
// paid — for every method, credits included. It issues no grant and touches no wallet;
// a human settles the recorded row. A payout can never exceed pending.
func TestPayoutIsRecordOnlyAndPendingGuard(t *testing.T) {
app, s, fc := mount(t)
ctx := context.Background()
idA, codeA := applyAndApprove(t, app, s, "orgA", "acme", "")
@@ -455,16 +462,13 @@ func TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard(t *testing.T) {
t.Fatalf("over-pending payout want 400, got %d", st)
}
// Credits payout of 1200c → ONE grant into orgA's wallet, paid moves.
// Credits payout of 1200c → RECORDED, paid moves, wallet untouched.
st, body := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/payout", "admin", true, map[string]any{"amountCents": 1200, "method": "credits", "reference": "ledger-1"})
if st != http.StatusOK {
t.Fatalf("credits payout want 200, got %d (%s)", st, body)
}
if fc.bal("orgA") != 1200 {
t.Fatalf("affiliate wallet = %d, want 1200 (the credits payout)", fc.bal("orgA"))
}
if fc.depositCount() != 1 {
t.Fatalf("deposit count = %d, want 1 (one grant)", fc.depositCount())
if fc.bal("orgA") != 0 || fc.depositCount() != 0 {
t.Fatalf("a credits payout MOVED money: bal=%d deposits=%d, want 0/0 (record-only)", fc.bal("orgA"), fc.depositCount())
}
a, _ := s.State.store.GetByID(ctx, idA)
if a.PaidCents != 1200 || a.PendingCents() != 800 {
@@ -478,20 +482,17 @@ func TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard(t *testing.T) {
Txn string `json:"txn"`
}
_ = json.Unmarshal(pd["payout"], &payout)
if payout.AmountCents != 1200 || payout.Method != "credits" || payout.Txn == "" {
t.Fatalf("payout view wrong: %+v", payout)
if payout.AmountCents != 1200 || payout.Method != "credits" || payout.Txn != "" {
t.Fatalf("payout view wrong: %+v (txn must be empty — nothing settled)", payout)
}
// Cash payout of the remaining 800c via wire → RECORD-ONLY (no new grant).
// Cash payout of the remaining 800c via wire → recorded the same way.
st, body = req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/payout", "admin", true, map[string]any{"amountCents": 800, "method": "wire", "reference": "wire-xyz"})
if st != http.StatusOK {
t.Fatalf("cash payout want 200, got %d (%s)", st, body)
}
if fc.depositCount() != 1 {
t.Fatalf("cash payout issued a grant: deposit count = %d, want 1", fc.depositCount())
}
if fc.bal("orgA") != 1200 {
t.Fatalf("cash payout moved the wallet: bal = %d, want 1200", fc.bal("orgA"))
if fc.depositCount() != 0 || fc.bal("orgA") != 0 {
t.Fatalf("cash payout moved money: deposits=%d bal=%d, want 0/0", fc.depositCount(), fc.bal("orgA"))
}
a, _ = s.State.store.GetByID(ctx, idA)
if a.PaidCents != 2000 || a.PendingCents() != 0 {
@@ -791,9 +792,9 @@ func TestAffiliatesMeSurface(t *testing.T) {
if v.Levels[1].Level != 2 || v.Levels[1].RateBps != defaultL2RateBps || v.Levels[1].DownlineCount != 1 {
t.Fatalf("L2 row wrong: %+v", v.Levels[1])
}
// A earns L2 on orgC's $100 spend = 5% of the 40% margin (lazy sweep from the read).
if v.AccruedCents != share(10000, defaultL2RateBps) {
t.Fatalf("A accrued via /me = %d, want %d", v.AccruedCents, share(10000, defaultL2RateBps))
// /me is a PURE READ: it reports the downline but accrues nothing.
if v.AccruedCents != 0 {
t.Fatalf("GET /me accrued %d, want 0", v.AccruedCents)
}
}
@@ -860,7 +861,7 @@ func TestMount(t *testing.T) {
t.Cleanup(func() { _ = Shutdown() })
// A no-principal GET is refused 403 (proves the route is bound + gated).
r := httptest.NewRequest(http.MethodGet, "/v1/affiliates", nil)
resp, err := app.Fiber().Test(r, fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
resp, err := app.Test(r, zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
if err != nil {
t.Fatalf("Test: %v", err)
}
+13 -17
View File
@@ -6,36 +6,32 @@ import (
"github.com/hanzoai/cloud/apps/payout"
)
// commerce is the narrow money seam the affiliate loop needs: read a referred org's
// metered spend (the commission accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:affiliate). It is an INTERFACE so the
// store/handler logic is testable with a fake ledger; the production binding is
// clients/payout, reached through the thin adapter below.
// commerce is the ONE thing the commission loop asks of the money plane, and it is a
// QUESTION, not an instruction: what has this org spent? That read is the accrual
// base. It is an INTERFACE so the sweep is testable against a fake.
//
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. An affiliate payout-in-credits still lands in precisely the wallet
// the balance panel reads, indistinguishable from an admin grant except by its
// grant:affiliate tag.
// THERE IS NO DEPOSIT HERE, AND THERE IS NOT GOING TO BE ONE. This seam used to
// carry `deposit`, which is how a GET on this surface came to mint platform credit:
// the capability existed, so a caller eventually reached it. An affiliate commission is a PAYABLE —
// accrued and recorded here, settled by a human out of band — and platform credit is
// issued only by an admin grant. Re-adding a write method here re-opens exactly the
// hole that was shut, so the SHAPE of this interface is load-bearing and
// TestCommerceSeamIsReadOnly fails if it ever grows one.
type commerce interface {
configured() bool
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom payout.
// errUnconfigured is the shared sentinel a read against an unwired commerce returns,
// so accrual stays honestly pending rather than silently earning.
var errUnconfigured = payout.ErrUnconfigured
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
// delegation, and it delegates exactly one read.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
-9
View File
@@ -198,15 +198,6 @@ func myEarnings(s *cloud.Service[state], c *zip.Ctx) error {
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
if a.Status == StatusApproved {
if _, _, serr := sweepAffiliate(s, ctx, a); serr != nil {
s.Log.Warn("affiliates: lazy sweep failed", "affiliate", a.ID, "err", serr)
}
if refreshed, rerr := s.State.store.GetByID(ctx, a.ID); rerr == nil {
a = refreshed
}
}
byPeriod, err := s.State.store.EarningsByPeriod(ctx, a.ID, earningsLimit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "earnings by period: %v", err)
+1 -1
View File
@@ -19,6 +19,7 @@ import (
"context"
"encoding/json"
"fmt"
fiber "github.com/zap-proto/fiber/v3"
"io"
"net/http"
"net/http/httptest"
@@ -28,7 +29,6 @@ import (
"github.com/hanzoai/cloud/apps/tools"
"github.com/hanzoai/cloud/openapi"
openai "github.com/hanzoai/go-openai"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
+1 -1
View File
@@ -252,7 +252,7 @@ func TestRunRequiresValidatedPrincipal(t *testing.T) {
// A raw run request carrying ONLY X-Org-Id (no X-User-Id) must be 403.
req := httptest.NewRequest(http.MethodPost, "/v1/agents/a/run", nil)
req.Header.Set("X-Org-Id", "acme") // forged/unvalidated org, no principal
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func do(t *testing.T, app *zip.App, method, path, org string, body any) (int, []
// the gateway would. Empty org => no user (the anonymous 403 path).
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+3 -3
View File
@@ -23,7 +23,7 @@ func doKey(t *testing.T, app *zip.App, method, path, org, key string) (int, []by
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -162,7 +162,7 @@ func doKeyBody(t *testing.T, app *zip.App, method, path, org, key string, body a
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -283,7 +283,7 @@ func reqAs(t *testing.T, app *zip.App, method, path, org, user string, admin boo
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+1 -1
View File
@@ -182,7 +182,7 @@ func doNoUser(t *testing.T, app *zip.App, method, path, org string, body any) (i
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+135
View File
@@ -0,0 +1,135 @@
package agents
import "testing"
// The three machines this capability plane exists for, as they ACTUALLY advertise
// themselves today — measured on the boxes, not imagined. All three are 128 GB
// unified-memory accelerators from three different vendors, and all three report
// VRAM 0, each for its own reason:
//
// - spark NVIDIA GB10: `nvidia-smi --query-gpu=memory.total` answers "[N/A]"
// (Grace Blackwell has no discrete VRAM), and parse_nvidia's int parse of
// "[N/A]" fails -> 0.
// - dbc Apple M4 Max: `system_profiler SPDisplaysDataType` emits NO
// "VRAM (Total):" line on Apple Silicon -> 0.
// - evo AMD Radeon 8060S (gfx1151): no nvidia-smi, so the probe falls back to
// lspci, which carries no memory at all (parse_lspci hardcodes memory: 0) and
// names the part "Device 1586" because the PCI id is unresolved. rocm-smi DOES
// report both the real model and the VRAM, and is not consulted.
//
// Holding them here as data means a probe change that starts advertising real
// accelerator memory shows up as these fixtures changing, in one place.
var (
spark = Spec{OS: "linux", Arch: "arm64", CPUs: 20, Memory: 128 << 30,
GPUs: []GPU{{Vendor: "nvidia", Model: "GB10", Memory: 0}}}
dbc = Spec{OS: "darwin", Arch: "arm64", CPUs: 16, Memory: 128 << 30,
GPUs: []GPU{{Vendor: "apple", Model: "Apple M4 Max", Memory: 0}}}
evo = Spec{OS: "linux", Arch: "amd64", CPUs: 32, Memory: 128 << 30,
GPUs: []GPU{{Vendor: "amd", Model: "Advanced Micro Devices, Inc. [AMD/ATI] Device 1586", Memory: 0}}}
laptop = Spec{OS: "darwin", Arch: "arm64", CPUs: 8, Memory: 16 << 30}
)
// THE point of the whole exercise: ONE requirement, satisfied by three vendors.
// Under `nvidia.com/gpu` only spark could ever match; two boxes that can run the
// same hanzo-kernel source were unroutable because the contract named a vendor.
func TestNeed_OneGPURequirementIsSatisfiedByEveryVendor(t *testing.T) {
need := Need{GPUs: 1}
for _, m := range []struct {
name string
spec Spec
}{{"spark/nvidia", spark}, {"dbc/apple", dbc}, {"evo/amd", evo}} {
if !m.spec.Satisfies(need) {
t.Errorf("%s: a machine with an accelerator must satisfy Need{GPUs:1}", m.name)
}
}
if laptop.Satisfies(need) {
t.Error("a machine with no accelerator must NOT satisfy Need{GPUs:1}")
}
}
// There is no vendor in Need, so no phrasing of a requirement can prefer one. This
// asserts the ABSENCE of the hardcode: swapping only the vendor never changes the
// answer.
func TestNeed_VendorIsNotAMatchableFact(t *testing.T) {
need := Need{GPUs: 1, CPUs: 4}
base := Spec{OS: "linux", Arch: "arm64", CPUs: 8, Memory: 64 << 30}
for _, vendor := range []string{"nvidia", "amd", "apple", "intel", "", "totally-new-vendor"} {
s := base
s.GPUs = []GPU{{Vendor: vendor, Model: "x", Memory: 8 << 30}}
if !s.Satisfies(need) {
t.Errorf("vendor %q changed the routing answer; vendor must not be matchable", vendor)
}
}
}
// Unknown memory must never clear a floor, or a 70B job lands on a box that cannot
// hold it. Today that refuses all three lab boxes -- the honest answer, and the
// reason the probe must learn to report accelerator-addressable memory.
func TestNeed_UnknownVRAMFailsClosed(t *testing.T) {
need := Need{GPUs: 1, VRAM: 40 << 30}
for _, m := range []struct {
name string
spec Spec
}{{"spark", spark}, {"dbc", dbc}, {"evo", evo}} {
if m.spec.Satisfies(need) {
t.Errorf("%s advertises VRAM 0; an unknown must not satisfy a %d-byte floor", m.name, need.VRAM)
}
}
// The same machine, once it advertises what its accelerator can address, fits.
honest := spark
honest.GPUs = []GPU{{Vendor: "nvidia", Model: "GB10", Memory: 128 << 30}}
if !honest.Satisfies(need) {
t.Error("a machine advertising 128G of accelerator memory must satisfy a 40G floor")
}
}
// A VRAM floor with no explicit count still implies an accelerator, so it can never
// be silently satisfied by a machine that has none.
func TestNeed_VRAMFloorImpliesAnAccelerator(t *testing.T) {
if laptop.Satisfies(Need{VRAM: 1 << 30}) {
t.Error("a VRAM floor must not be a no-op on a machine with no accelerator")
}
}
func TestNeed_ZeroNeedIsSatisfiedByAnything(t *testing.T) {
if !(Need{}).IsZero() {
t.Fatal("the zero Need must report IsZero")
}
for _, s := range []Spec{spark, dbc, evo, laptop, {}} {
if !s.Satisfies(Need{}) {
t.Error("the zero Need constrains nothing and must be satisfied by any machine")
}
}
}
func TestNeed_CountFloorsAndPlatform(t *testing.T) {
two := Spec{OS: "linux", Arch: "amd64", CPUs: 64, Memory: 512 << 30, GPUs: []GPU{
{Vendor: "amd", Model: "a", Memory: 48 << 30},
{Vendor: "amd", Model: "b", Memory: 16 << 30},
}}
cases := []struct {
name string
spec Spec
need Need
want bool
}{
{"count met", two, Need{GPUs: 2}, true},
{"count exceeded", two, Need{GPUs: 3}, false},
{"only one clears the vram floor", two, Need{GPUs: 2, VRAM: 32 << 30}, false},
{"one is enough at that floor", two, Need{GPUs: 1, VRAM: 32 << 30}, true},
{"cpu floor met", evo, Need{CPUs: 32}, true},
{"cpu floor missed", laptop, Need{CPUs: 32}, false},
{"host memory floor met", dbc, Need{Memory: 64 << 30}, true},
{"host memory floor missed", laptop, Need{Memory: 64 << 30}, false},
{"os match is case-folded", dbc, Need{OS: "Darwin"}, true},
{"os mismatch", dbc, Need{OS: "linux"}, false},
{"arch match", spark, Need{Arch: "arm64"}, true},
{"arch mismatch", spark, Need{Arch: "amd64"}, false},
{"arch is orthogonal to os", evo, Need{OS: "linux", Arch: "arm64"}, false},
}
for _, c := range cases {
if got := c.spec.Satisfies(c.need); got != c.want {
t.Errorf("%s: Satisfies(%+v) = %v, want %v", c.name, c.need, got, c.want)
}
}
}
+1 -1
View File
@@ -615,7 +615,7 @@ type patchTargetIn struct {
// zipdoc lifts the doc comment off each typed op and its In/Out fields into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by `make openapi`.
// and the MCP tool list — Go drops comments at compile time. Run by `make describe`.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
+74
View File
@@ -146,6 +146,80 @@ func clampF01(f float64) float64 {
return f
}
// Need is what a job requires OF a machine, written in the SAME vocabulary a machine
// advertises. It is the other half of Spec: Spec says what a machine has, Need says
// what a job wants, and Satisfies is the ONE place the two meet.
//
// THERE IS NO VENDOR FIELD, AND THAT IS THE POINT. `resourcesPerNode.limits.
// "nvidia.com/gpu"` is not a requirement, it is one vendor's name for a requirement —
// baking it into the scheduler contract is what made a GPU job unroutable to an AMD or
// Apple machine that could have run it. A job needs ACCELERATORS with enough memory;
// which vendor satisfies that is the machine's business, and hanzo-kernel lowers one
// kernel source to CUDA/ROCm/Vulkan/Metal precisely so the job never has to care.
// Re-adding a vendor here would reintroduce the hardcode as a value, so it stays out:
// a requirement no advertised capability can express is not a requirement.
//
// The zero Need is "anything will do" — every field is a floor that only constrains
// when set, so an unrelated caller is never forced to describe a machine it does not
// care about.
type Need struct {
GPUs int `json:"gpus,omitempty"` // accelerators required
VRAM int64 `json:"vram,omitempty"` // bytes each accelerator must address
CPUs int `json:"cpus,omitempty"` // logical cores
Memory int64 `json:"memory,omitempty"` // host RAM bytes
OS string `json:"os,omitempty"` // linux | darwin | windows
Arch string `json:"arch,omitempty"` // amd64 | arm64 | ...
}
// IsZero reports a Need that constrains nothing.
func (n Need) IsZero() bool {
return n.GPUs == 0 && n.VRAM == 0 && n.CPUs == 0 && n.Memory == 0 && n.OS == "" && n.Arch == ""
}
// Satisfies reports whether this machine's advertised capability meets a job's Need.
// It is a pure function of two values — no clock, no store, no vendor table — so the
// dispatch gate, a scheduler and a UI preview all get the same answer from the same
// rule, and a test can state a fleet as data.
//
// UNKNOWN IS NOT ENOUGH. A machine that advertises VRAM 0 does not satisfy a VRAM
// floor: 0 means "the probe could not tell", and admitting it would route a 70B job
// to a machine that cannot hold it. This is deliberately fail-closed, and it is why
// the probe reporting truthful accelerator memory matters — on a unified-memory
// machine (Apple Silicon, an NVIDIA GB10, an AMD APU) nvidia-smi/system_profiler/lspci
// report no discrete VRAM, so such a box advertises 0 and is refused by any VRAM floor
// until it advertises the memory its accelerator can actually address.
func (s Spec) Satisfies(n Need) bool {
if n.CPUs > 0 && s.CPUs < n.CPUs {
return false
}
if n.Memory > 0 && s.Memory < n.Memory {
return false
}
if n.OS != "" && !strings.EqualFold(strings.TrimSpace(s.OS), strings.TrimSpace(n.OS)) {
return false
}
if n.Arch != "" && !strings.EqualFold(strings.TrimSpace(s.Arch), strings.TrimSpace(n.Arch)) {
return false
}
// Accelerators: a VRAM floor implies at least one, so "vram only" is not a silent
// no-op on a machine with no GPU at all.
want := n.GPUs
if want == 0 && n.VRAM > 0 {
want = 1
}
if want == 0 {
return true
}
fit := 0
for _, g := range s.GPUs {
if n.VRAM > 0 && g.Memory < n.VRAM {
continue // 0 (unknown) never clears a floor
}
fit++
}
return fit >= want
}
// encodeSpec/decodeSpec + encodeMetrics/decodeMetrics are the column codecs. An empty
// value encodes to "" (a NULL-equivalent the column defaults to), and a malformed
// stored blob decodes to the zero value rather than failing a whole target read.
+23
View File
@@ -21,6 +21,7 @@ import (
"fmt"
aimod "github.com/hanzoai/ai"
aictl "github.com/hanzoai/ai/controllers"
aiobject "github.com/hanzoai/ai/object"
airouters "github.com/hanzoai/ai/routers"
"github.com/hanzoai/cloud"
@@ -110,6 +111,28 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if cloud.TracerProviderInstalled() {
aiobject.AdoptHostTracerProvider()
}
// THE PREPAID GATE'S COMPLETION CEILING, PER MODEL, FROM THE CATALOG.
//
// cloud's meter must bound a completion BEFORE it runs, and that bound is a
// property of the model — 1M-context models exist, and any constant caps them
// at whatever number was typed. It cannot read models.yaml itself:
// hanzoai/ai/controllers imports hanzoai/cloud, so the catalog is a CYCLE from
// cloud's root, not merely weight. This package already links both, which is
// why the seam is installed here beside the other cross-module hooks.
//
// max_output_tokens is the answer when the catalog declares one; otherwise the
// model's context window is still a true architectural bound (prompt +
// completion can never exceed it). 0 from both leaves cloud on its own floor.
cloud.SetCompletionCeiling(func(model string) int {
mc := aictl.GetModelConfig()
if mc == nil {
return 0
}
if n := mc.MaxOutput(model); n > 0 {
return n
}
return mc.ContextWindow(model)
})
// INSTALL ONLY WHAT THIS PROCESS ACTUALLY HAS. `ai` runs as its OWN process
// (ps in a prod pod: /cloud, /kms, /tasks, /ai, …), and these hooks are
// package-level vars — so a reader wireFinance sets in the CLOUD process is
+2 -3
View File
@@ -47,7 +47,6 @@ func served(t *testing.T) *zip.App {
app := zip.New(zip.Config{AppName: "ai", Logger: luxlog.New("aimcptest"), DisableStartupMessage: true})
app.Use(cloud.Bridge())
mountMCP(app)
app.Prepare()
return app
}
@@ -63,7 +62,7 @@ func rpc(t *testing.T, app *zip.App, msg, user, org string) string {
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("POST %s: %v", door, err)
}
@@ -125,7 +124,7 @@ func get(t *testing.T, app *zip.App, user string) (int, string) {
req.Header.Set("X-User-Id", user)
req.Header.Set("X-Org-Id", "acme")
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func postHostBody(t *testing.T, app *zip.App, host, path, body string) (int, []b
req := httptest.NewRequest(http.MethodPost, "http://"+host+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Host = host
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s%s: %v", host, path, err)
}
+1 -1
View File
@@ -84,7 +84,7 @@ func postAuth(t *testing.T, app *zip.App, path, auth, body string) (int, []byte)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", auth)
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
+1 -1
View File
@@ -57,7 +57,7 @@ func postKeyed(t *testing.T, app *zip.App, path, host, body string, hdr map[stri
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
+2 -2
View File
@@ -233,7 +233,7 @@ func livePost(t *testing.T, app *zip.App, path, user, org, body string) (int, []
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Id", user)
req.Header.Set("X-Org-Id", org)
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
@@ -264,7 +264,7 @@ func TestLiveAnonymousCapture(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, canonDoor, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Host = "hanzo.ai" // brand host buys NOTHING; the row lands under $public
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("anon POST: %v", err)
}
+2 -2
View File
@@ -395,7 +395,7 @@ func doBody(t *testing.T, app *zip.App, method, path, user, org, body string) (i
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -489,7 +489,7 @@ func doHost(t *testing.T, app *zip.App, path, user, org, host, body string) (int
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
+2 -2
View File
@@ -132,7 +132,7 @@ func postHost(t *testing.T, app *zip.App, host, path, body string, hdr map[strin
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s%s: %v", host, path, err)
}
@@ -258,7 +258,7 @@ func TestMount_HostCarve_CustomDomainCarves(t *testing.T) {
func TestMount_HostCarve_GetNotHijacked(t *testing.T) {
app := carveApp(t, "hanzo")
req := httptest.NewRequest(http.MethodGet, "http://yadota.hanzo.app/v1/analytics/overview", nil)
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
+1 -1
View File
@@ -48,7 +48,7 @@ func do(t *testing.T, app *zip.App, method, path, user, org string) (int, []byte
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+2 -2
View File
@@ -32,7 +32,7 @@ func postAnon(t *testing.T, app *zip.App, path, body string, hdr map[string]stri
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
@@ -581,7 +581,7 @@ func TestAuthenticated_OptOutNotHonoredForPrincipal(t *testing.T) {
req.Header.Set("X-User-Id", "user-dave")
req.Header.Set("X-Org-Id", "acme")
req.Header.Set("DNT", "1")
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
+4 -4
View File
@@ -48,7 +48,7 @@ func postBody(t *testing.T, app *zip.App, path, body, auth string) (int, Capture
if auth != "" {
req.Header.Set("Authorization", "Bearer "+auth)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
@@ -440,7 +440,7 @@ func resolvedTeamOrg(t *testing.T, app *zip.App, bearer string) (string, bool) {
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := probe.Fiber().Test(req)
resp, err := probe.Test(req)
if err != nil {
t.Fatalf("probe: %v", err)
}
@@ -629,7 +629,7 @@ func runTenant(t *testing.T, headers map[string]string, fn func(*zip.Ctx) (admis
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := probe.Fiber().Test(req)
resp, err := probe.Test(req)
if err != nil {
t.Fatalf("probe: %v", err)
}
@@ -671,7 +671,7 @@ func teamPresented2(t *testing.T, bearer string) bool {
})
req := httptest.NewRequest(http.MethodPost, "/probe", strings.NewReader("[]"))
req.Header.Set("Authorization", "Bearer "+bearer)
resp, _ := probe.Fiber().Test(req)
resp, _ := probe.Test(req)
defer func() { _ = resp.Body.Close() }()
return got
}
+2 -2
View File
@@ -91,7 +91,7 @@ func ask(t *testing.T, app *zip.App, org, question string) (int, askAnswer) {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("ask %q: %v", question, err)
}
@@ -242,7 +242,7 @@ func TestAnonymousRefused(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/ask", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Org-Id", "acme")
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("test: %v", err)
}
+1 -1
View File
@@ -12,13 +12,13 @@ import (
"context"
"encoding/json"
"fmt"
fiber "github.com/zap-proto/fiber/v3"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/hanzoai/cloud"
fiber "github.com/zap-proto/fiber/v3"
)
// booksMetricsPath is the books domain's grounded read the contributor replays. It is the ONE
+1 -1
View File
@@ -40,7 +40,7 @@ func askRaw(t *testing.T, app *zip.App, body string, hdr map[string]string) *htt
for k, v := range hdr {
rq.Header.Set(k, v)
}
resp, err := app.Fiber().Test(rq)
resp, err := app.Test(rq)
if err != nil {
t.Fatalf("POST /v1/ask: %v", err)
}
+1 -1
View File
@@ -61,7 +61,7 @@ func TestAskWebModeDispatch(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Org-Id", "acme")
req.Header.Set("X-User-Id", "u-acme")
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("web ask: %v", err)
}
+26 -11
View File
@@ -83,19 +83,34 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// routes registers the org-scoped audit surface.
//
// Bridge FIRST and noStore beside it, both installed on the subsystem's own
// prefix BEFORE the leaf: fiber runs middleware in registration order, so one
// installed after its route never runs. Bridge parks the validated org, which is
// the only way a typed op — which receives a context and nothing else — can
// resolve the tenant. Serve installs one app-wide too; nesting is harmless (the
// inner one is what the handler sees) and this package's own tests mount on a
// bare app with no Serve, so this install is what makes them pass.
// Bridge FIRST and noStore beside it, both installed BEFORE the leaf: fiber runs
// middleware in registration order, so one installed after its route never runs.
// Bridge parks the validated org, which is the only way a typed op — which
// receives a context and nothing else — can resolve the tenant. Serve installs
// one app-wide too; nesting is harmless (the inner one is what the handler sees)
// and this package's own tests mount on a bare app with no Serve, so this
// install is what makes them pass.
//
// The op is declared on the App with its WHOLE path, not on the group with an
// empty leaf: joining "/v1/audit" with "" yields "/v1/audit/", a different path
// from the one this API has always served.
// USE, NOT A MIDDLEWARE-CARRYING GROUP. This used to say
// `app.Group("/v1/audit", Bridge(), noStore())`, and that is the one shape this
// surface cannot use: the op below is declared on the App with its WHOLE path,
// so the routes are NOT beneath the group, and a group whose subtree has no
// routes is middleware that can never run. zip refuses to compose it (walk.go's
// inert-middleware check) — which on a bare app turned every test in this
// package into a panic out of app.Test.
//
// Use is the ONE composition verb and it says the right thing in both routers a
// subsystem is mounted through: cloud's scope gates it to the subtrees this
// subsystem declares (scope.Use), and a bare *zip.App treats root middleware as
// always-live (depth 0 is exempt by construction — it is what 404 logging and
// CORS need). The prefix is not repeated here BECAUSE scope already holds it;
// restating it would be the second place a subsystem's subtree is written down.
//
// The op keeps its WHOLE path rather than moving to a group with an empty leaf:
// joining "/v1/audit" with "" yields "/v1/audit/", a different path from the one
// this API has always served, and one that would ship in OpenAPI and the SDK.
func routes(app cloud.Router, zapp *zip.App, s *cloud.Service[state]) {
app.Group("/v1/audit", cloud.Bridge(), noStore())
app.Use(zip.H(cloud.Bridge()), zip.H(noStore()))
o := ops{s: s}
zip.Get(zapp, "/v1/audit", o.list)
}
+1 -1
View File
@@ -55,7 +55,7 @@ func call(t *testing.T, app *zip.App, path, user, org string) (int, []audit.Wire
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test GET %s: %v", path, err)
}
+1 -1
View File
@@ -27,7 +27,7 @@ func doRaw(t *testing.T, app *zip.App, path, user, org string) (*http.Response,
if org != "" {
rq.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(rq)
resp, err := app.Test(rq)
if err != nil {
t.Fatalf("Test GET %s: %v", path, err)
}
+20 -162
View File
@@ -2,7 +2,8 @@
//
// An author links GitHub, proves they own a repo, and earns a royalty on the
// metered spend of every org that deploys a project built from it — accrued per
// period and auto-paid, with a full audit trail behind the number.
// period, with a full audit trail behind the number. Accrual records what is OWED;
// paying it is a human act.
//
// It is the CREATOR member of the three programs built on the same shape;
// apps/referrals is the one-time bonus and apps/affiliates the partner commission.
@@ -25,23 +26,20 @@
// record): deploying_org↔repo↔project, idempotent per (repo, project, org).
// hanzo.app persists sourceRepo on the published project so the deploy is
// attributable.
// 4. The ACCRUAL SWEEP (the scheduler's automatic loop; POST /v1/admin/authors/sweep
// as an operator override; also lazy on the author's own dashboard read) folds
// over each approved author's DISTINCT deploying orgs (excluding the author's own):
// royalty = that org's metered spend THIS PERIOD × the author's share (20%),
// accrued at-most-once per (author, deploying_org, period).
// 5. The AUTO-PAYOUT (same scheduler pass, right after accrual; POST
// /v1/admin/authors/:id/payout as an operator override) settles each author's
// pending royalty with NO human in the loop: "credits" issues a commerce grant into
// an external author's wallet; a Hanzo-MAINTAINED template's royalty is realized
// into the Hanzo treasury reserve instead ("pay ourselves"). A payout can never
// exceed pending (accrued paid), guarded atomically — idempotent, never
// double-pays.
// 4. The ACCRUAL SWEEP (POST /v1/admin/authors/sweep, SuperAdmin) folds over each
// approved author's DISTINCT deploying orgs (excluding the author's own): royalty =
// that org's metered spend THIS PERIOD × the author's share (20%), accrued
// at-most-once per (author, deploying_org, period). Accrual is TRACKING — it records
// what we owe and issues nothing.
// 5. A PAYOUT (POST /v1/admin/authors/:id/payout, SuperAdmin) RECORDS a disbursement
// against pending royalty, for every method. It moves no money: a human settles the
// recorded payout out of band. A payout can never exceed pending (accrued paid),
// guarded atomically.
//
// HANZO FORKS. A repo whose owner is a brand org (owner ∈ {hanzoai, hanzo-*}) is
// auto-attributed on first deploy to the treasury SYSTEM author (org = the brand slug),
// so Hanzo earns 20% on its OWN templates when other orgs deploy them, credited to the
// treasury reserve via the shared treasury client — no external wallet, no new ledger.
// so Hanzo earns 20% on its OWN templates when other orgs deploy them — recorded, not
// settled.
//
// Surface:
//
@@ -54,7 +52,7 @@
// POST /v1/admin/authors/sweep (SuperAdmin) accrue royalty for every deploying org this period
// POST /v1/admin/authors/:id/approve (SuperAdmin) admit to earning (+ optional share override)
// POST /v1/admin/authors/:id/suspend (SuperAdmin) suspend
// POST /v1/admin/authors/:id/payout (SuperAdmin) record a payout (credits → grant; cash → record-only)
// POST /v1/admin/authors/:id/payout (SuperAdmin) RECORD a payout (record-only; a human settles it)
// GET /v1/admin/authors/:id/basis (SuperAdmin) the SAME basis payload the author reads (support mirror)
//
// serve.go auto-registers GET /v1/authors/health.
@@ -71,7 +69,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/commerce/transport"
"github.com/hanzoai/cloud/apps/treasury"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
@@ -143,10 +140,6 @@ type state struct {
var mounted *cloud.Service[state]
// stopScheduler stops the background accrual+auto-payout loop. Set by Mount, called
// by Shutdown; the default no-op keeps an unmounted/partial deploy safe.
var stopScheduler = func() {}
// Mount wires the authors surface onto app per HIP-0106.
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
@@ -183,12 +176,6 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
routes(app, zapp, s)
// Drive the DEFAULT automatic money loop: a periodic goroutine that accrues every
// approved author's royalty AND auto-pays their pending balance, no human in the
// loop (the manual sweep/payout admin endpoints remain as overrides). Single-writer:
// authors mounts only on the writer pod, so exactly one scheduler runs.
stopScheduler = startScheduler(s)
b.Log.Info("authors mounted", "brand", deps.Brand, "badgeBase", s.State.badgeBase,
"maintainerOrg", s.State.maintainerOrg, "commerce", s.State.commerce.configured())
return nil
@@ -295,23 +282,11 @@ func resolveDeployAuthor(s *cloud.Service[state], ctx context.Context, repoURL s
return claim.AuthorID, nil
}
// issuePayout is the ONE payout path — the manual admin endpoint AND the automatic
// scheduler share it, so a payout settles identically however it is triggered. It
// RESERVES amountCents against the author's pending royalty ATOMICALLY (RecordPayout's
// WHERE guard makes it impossible to exceed accruedpaid, even concurrently), then
// settles:
//
// - a TREASURY (system) author → CREDIT the platform reserve fund ("pay ourselves",
// treasury.Credit, idempotent by payout id); NO external wallet, NO reserve-backing.
// - an external author, method=credits → BACK the payout against the reserve
// (treasury.Reserve; unbacked → void the reservation + PaymentRequired) then issue
// the commerce grant into the author's wallet.
// - an external author, cash method (wire/paypal/…) → record-only after backing.
//
// The reservation(s) are the money authority (at-most-pending AND, for external, at-
// most-reserve). A settlement error AFTER a successful reservation is logged LOUD, not
// retried — an operator reconciles from the payout row + audit; this never double-pays
// and never leaks reserve. Errors are the raw store sentinels (errNotFound /
// issuePayout RECORDS a payout of accrued royalty. It is record-only for EVERY method:
// it reserves amountCents against the author's pending royalty atomically (RecordPayout's
// WHERE guard makes it impossible to exceed accruedpaid, even concurrently) and writes
// the row. It moves NO money — a human settles the recorded payout out of band, which is
// the only way value leaves. Errors are the raw store sentinels (errNotFound /
// errInsufficientPending) or a ready zip error; the caller maps them.
func issuePayout(s *cloud.Service[state], ctx context.Context, a Author, amountCents int64, method, reference string) (Payout, error) {
payoutID, err := genID("apo")
@@ -325,56 +300,6 @@ func issuePayout(s *cloud.Service[state], ctx context.Context, a Author, amountC
return Payout{}, err // errNotFound | errInsufficientPending | internal
}
// Pay ourselves: a Hanzo-maintained template's royalty is realized INTO the treasury
// reserve fund, not paid to an external wallet. Idempotent by payout id; a failure is
// logged loud (the pending reservation stands, reconciled from audit) — symmetric
// with the external credits path. No reserve-backing debit: we are crediting.
if isTreasuryAuthor(s, a) {
credited, entryID, cerr := treasury.Credit(ctx, treasury.ProgramAuthor, "payout:"+payoutID,
fmt.Sprintf("OSS author royalty → treasury (%s)", a.GithubLogin), amountCents)
if cerr != nil || !credited {
s.Log.Error("authors: treasury credit failed (reserved against pending; not retried)",
"author", a.ID, "payout", payoutID, "err", cerr)
} else if entryID != "" {
if serr := s.State.store.SetPayoutTxn(ctx, payoutID, entryID); serr != nil {
s.Log.Error("authors: record treasury payout txn failed", "payout", payoutID, "err", serr)
}
payout.Txn = entryID
}
return payout, nil
}
// External author: BACK the payout against the platform reserve fund (double-entry
// fund→payout:author, idempotent by payout id). SECOND guard: a payout must not
// exceed EITHER the author's pending royalty (above) OR the funded reserve (here).
// Not backed → VOID the pending reservation (restore it) and refuse honestly.
backed, _, berr := treasury.Reserve(ctx, treasury.ProgramAuthor, "payout:"+payoutID,
fmt.Sprintf("OSS author royalty payout (%s)", a.GithubLogin), amountCents)
if berr != nil || !backed {
if verr := s.State.store.VoidPayout(ctx, payoutID, a.ID, amountCents); verr != nil {
s.Log.Error("authors: void after unbacked payout failed", "payout", payoutID, "err", verr)
}
if berr != nil {
return Payout{}, zip.Errorf(http.StatusInternalServerError, "reserve payout: %v", berr)
}
reserve, _ := treasury.ReserveCents(ctx)
return Payout{}, zip.Errorf(http.StatusPaymentRequired,
"treasury reserve insufficient to back this payout (%d cents available); replenish via /v1/admin/treasury/sweep or seed", reserve)
}
// A credits payout issues the actual grant AFTER both reservations. A grant failure
// is logged loud (never silent) so an operator reconciles from the payout row + audit.
if method == methodCredits {
txn, gerr := s.State.commerce.deposit(ctx, a.Org, orgSubject(a.Org), amountCents, grantCurrency,
fmt.Sprintf("OSS author royalty payout (%s)", a.GithubLogin), grantTag)
if gerr != nil {
s.Log.Error("authors: credits payout grant failed (reserved against pending; not retried)",
"author", a.ID, "payout", payoutID, "err", gerr)
} else if serr := s.State.store.SetPayoutTxn(ctx, payoutID, txn); serr != nil {
s.Log.Error("authors: record payout txn failed", "payout", payoutID, "err", serr)
}
payout.Txn = txn
}
return payout, nil
}
@@ -485,69 +410,6 @@ func AccrueForOrg(ctx context.Context, deployingOrg string, spend int64, period
return created
}
// ── automatic money loop (accrue + auto-payout, no human) ──────────────────────
// sweepAndPayout is the automatic creator-payout loop the scheduler drives: for every
// approved author it ACCRUES this period's royalty, then AUTO-PAYS the author's full
// pending balance. Both halves are idempotent — accrual latches at-most-once per
// (author, deploying_org, period); payout reserves against pending atomically and
// never exceeds accruedpaid — so running it on a schedule (or twice) never
// double-accrues or double-pays. This closes the loop: accrual AND payout run with no
// human. Returns (authors swept, accruals created, payouts issued).
func sweepAndPayout(s *cloud.Service[state]) (swept, accrued, paid int) {
ctx := context.Background()
approved, err := s.State.store.ListApproved(ctx, sweepLimit)
if err != nil {
s.Log.Error("authors: auto loop list approved failed", "err", err)
return 0, 0, 0
}
for _, a := range approved {
checked, credited, serr := sweepAuthor(s, ctx, a)
swept += checked
accrued += credited
if serr != nil {
s.Log.Warn("authors: auto sweep author failed", "author", a.ID, "err", serr)
}
if autoPayoutAuthor(s, ctx, a.ID) {
paid++
}
}
if accrued > 0 || paid > 0 {
s.Log.Info("authors: auto accrual+payout", "swept", swept, "accrued", accrued, "paid", paid)
}
return swept, accrued, paid
}
// autoPayoutAuthor issues a credits payout of an author's FULL pending royalty
// (external → their wallet; the treasury system author → the reserve fund),
// idempotently. It re-reads the author for the freshest pending (the sweep just
// latched); RecordPayout's atomic pending guard makes a zero-pending call a no-op
// (errInsufficientPending → skip), so a repeat tick after the balance is drained pays
// NOTHING. Returns true when a payout was issued this call.
func autoPayoutAuthor(s *cloud.Service[state], ctx context.Context, id string) bool {
a, err := s.State.store.GetByID(ctx, id)
if err != nil {
return false
}
pending := a.PendingCents()
if pending <= 0 {
return false
}
payout, err := issuePayout(s, ctx, a, pending, methodCredits, "auto:"+periodKey(time.Now()))
if err != nil {
if err != errInsufficientPending { // a drained-in-race author is not worth a loud log
s.Log.Warn("authors: auto payout failed", "author", id, "pending", pending, "err", err)
}
return false
}
after, _ := s.State.store.GetByID(ctx, id)
emitAudit(s, ctx, "author.payout", after, map[string]any{
"payoutId": payout.ID, "amountCents": payout.AmountCents, "method": payout.Method,
"reference": payout.Reference, "txn": payout.Txn, "auto": true,
})
return true
}
// ── Hanzo-fork attribution → treasury ("pay ourselves") ────────────────────────
// maintainerOrgFor resolves the first-party org whose maintained OSS templates earn
@@ -879,12 +741,8 @@ func badgeBase(deps cloud.Deps) string {
}
}
// Shutdown stops the accrual+auto-payout scheduler (draining any in-flight sweep) and
// closes the authors store, in that order — so the store is never closed out from
// under a running payout. Idempotent.
// Shutdown closes the authors store. Idempotent.
func Shutdown() error {
stopScheduler()
stopScheduler = func() {}
if mounted == nil || mounted.State.store == nil {
return nil
}
+71 -45
View File
@@ -17,7 +17,6 @@ import (
// test process has no KMS.
_ "github.com/hanzoai/cloud/internal/devmaster"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
@@ -40,7 +39,7 @@ func newFakeCommerce() *fakeCommerce {
func (f *fakeCommerce) configured() bool { return true }
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _ string) (string, error) {
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failDep {
@@ -185,7 +184,7 @@ func req(t *testing.T, app *zip.App, method, path, org string, admin bool, body
hr.Header.Set("X-User-IsAdmin", "true")
}
// Generous ceiling — the fiber default is 1s, which flakes under machine load.
resp, err := app.Fiber().Test(hr, fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
resp, err := app.Test(hr, zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -228,6 +227,26 @@ func connectOrg(t *testing.T, app *zip.App, s *cloud.Service[state], org, login
}
// approve admits an author to earning via the admin route.
// adminSweepAndPay is the HUMAN equivalent of the deleted automatic loop: the admin
// accrual sweep, then an explicit admin payout of each approved author's pending
// balance. Tests that assert attribution/settlement (not automation) drive this.
func adminSweepAndPay(t *testing.T, app *zip.App, s *cloud.Service[state]) {
t.Helper()
req(t, app, http.MethodPost, "/v1/admin/authors/sweep", "admin", true, nil)
approved, err := s.State.store.ListApproved(context.Background(), sweepLimit)
if err != nil {
t.Fatalf("list approved: %v", err)
}
for _, a := range approved {
cur, err := s.State.store.GetByID(context.Background(), a.ID)
if err != nil || cur.PendingCents() <= 0 {
continue
}
req(t, app, http.MethodPost, "/v1/admin/authors/"+a.ID+"/payout", "admin", true,
map[string]any{"amountCents": cur.PendingCents(), "method": methodCredits, "reference": "test"})
}
}
func approve(t *testing.T, app *zip.App, id string) {
t.Helper()
if st, body := req(t, app, http.MethodPost, "/v1/admin/authors/"+id+"/approve", "admin", true, nil); st != http.StatusOK {
@@ -502,9 +521,9 @@ func TestSweepAccruesSpendTimesShareIdempotent(t *testing.T) {
}
}
// TestLazyAccrualOnAuthorRead proves the author's OWN GET /v1/authors runs the accrual
// sweep (self-updating dashboard) and surfaces the badge snippet + repos/deploys.
func TestLazyAccrualOnAuthorRead(t *testing.T) {
// TestAuthorReadIsPureAndSurfacesRepos proves GET /v1/authors accrues NOTHING while
// still surfacing the badge snippet + repos/deploys. Accrual is the admin POST's job.
func TestAuthorReadIsPureAndSurfacesRepos(t *testing.T) {
app, s, fc, fg := mount(t)
// Link the GitHub identity BEFORE connect so the author is identity-verified.
fg.setLinked("orgA", "acmedev", "tok_a")
@@ -539,12 +558,11 @@ func TestLazyAccrualOnAuthorRead(t *testing.T) {
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
const want = 5000 * defaultShareBps / bpsDenom // 1000
if !v.IsAuthor || v.Status != StatusApproved || v.GithubLogin != "acmedev" || !v.Verified {
t.Fatalf("dashboard head wrong: %+v", v)
}
if v.AccruedCents != want || v.PendingCents != want {
t.Fatalf("lazy accrual not reflected: accrued=%d pending=%d, want %d", v.AccruedCents, v.PendingCents, want)
if v.AccruedCents != 0 || v.PendingCents != 0 {
t.Fatalf("a GET accrued: accrued=%d pending=%d, want 0/0", v.AccruedCents, v.PendingCents)
}
if len(v.Repos) != 1 || !v.Repos[0].Verified || v.Repos[0].BadgeMarkdown == "" {
t.Fatalf("repos wrong: %+v", v.Repos)
@@ -555,10 +573,10 @@ func TestLazyAccrualOnAuthorRead(t *testing.T) {
_ = idA
}
// TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard: a credits payout issues
// exactly ONE commerce grant + moves paid; a cash payout is record-only; a payout can
// never exceed pending.
func TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard(t *testing.T) {
// TestPayoutIsRecordOnlyAndPendingGuard: a payout RECORDS a disbursement and moves
// paid — for every method, credits included. It issues no grant and touches no wallet;
// a human settles the recorded row. A payout can never exceed pending.
func TestPayoutIsRecordOnlyAndPendingGuard(t *testing.T) {
app, s, fc, fg := mount(t)
ctx := context.Background()
idA, _ := connectOrg(t, app, s, "orgA", "acmedev")
@@ -585,13 +603,13 @@ func TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard(t *testing.T) {
t.Fatalf("over-pending payout want 400, got %d", st)
}
// Credits payout of firstPay → ONE grant into orgA's wallet, paid moves.
// Credits payout of firstPay → RECORDED, paid moves, wallet untouched.
st, body := req(t, app, http.MethodPost, "/v1/admin/authors/"+idA+"/payout", "admin", true, map[string]any{"amountCents": firstPay, "method": "credits", "reference": "ledger-1"})
if st != http.StatusOK {
t.Fatalf("credits payout want 200, got %d (%s)", st, body)
}
if fc.bal("orgA") != firstPay || fc.depositCount() != 1 {
t.Fatalf("credits payout wallet=%d deposits=%d, want %d/1", fc.bal("orgA"), fc.depositCount(), firstPay)
if fc.bal("orgA") != 0 || fc.depositCount() != 0 {
t.Fatalf("a credits payout MOVED money: wallet=%d deposits=%d, want 0/0 (record-only)", fc.bal("orgA"), fc.depositCount())
}
a, _ := s.State.store.GetByID(ctx, idA)
if a.PaidCents != firstPay || a.PendingCents() != restPay {
@@ -604,17 +622,17 @@ func TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard(t *testing.T) {
Txn string `json:"txn"`
}
_ = json.Unmarshal(pd["payout"], &payout)
if payout.AmountCents != firstPay || payout.Method != "credits" || payout.Txn == "" {
t.Fatalf("payout view wrong: %+v", payout)
if payout.AmountCents != firstPay || payout.Method != "credits" || payout.Txn != "" {
t.Fatalf("payout view wrong: %+v (txn must be empty — nothing settled)", payout)
}
// Cash payout of the rest via wire → RECORD-ONLY (no new grant).
// Cash payout of the rest via wire → recorded the same way.
st, _ = req(t, app, http.MethodPost, "/v1/admin/authors/"+idA+"/payout", "admin", true, map[string]any{"amountCents": restPay, "method": "wire", "reference": "wire-xyz"})
if st != http.StatusOK {
t.Fatalf("cash payout want 200, got %d", st)
}
if fc.depositCount() != 1 || fc.bal("orgA") != firstPay {
t.Fatalf("cash payout moved money: deposits=%d bal=%d", fc.depositCount(), fc.bal("orgA"))
if fc.depositCount() != 0 || fc.bal("orgA") != 0 {
t.Fatalf("cash payout moved money: deposits=%d bal=%d, want 0/0", fc.depositCount(), fc.bal("orgA"))
}
a, _ = s.State.store.GetByID(ctx, idA)
if a.PaidCents != accrued || a.PendingCents() != 0 {
@@ -758,11 +776,12 @@ func TestGitLabVerifyAndLedger(t *testing.T) {
}
}
// TestAutoPayoutDrainsPendingIdempotent is the AUTOMATION proof: the scheduler's
// sweepAndPayout accrues AND pays an approved author's pending royalty in one pass
// (no human sweep/payout call), and a second pass in the same period pays NOTHING more
// — the pending guard makes auto-payout at-most-once, never a double-pay.
func TestAutoPayoutDrainsPendingIdempotent(t *testing.T) {
// TestGetGrantsNothingAndLedgerReceivesZeroDeposits is the inverse of the automation
// proof that used to live here: with the hourly scheduler and the lazy-sweep-on-read
// both gone, the ONLY thing that moves money is a human POST. It drives the read
// surface in the exact state that used to auto-pay, and proves the commerce ledger saw
// no deposit at all.
func TestGetGrantsNothingAndLedgerReceivesZeroDeposits(t *testing.T) {
app, s, fc, fg := mount(t)
ctx := context.Background()
idA, _ := connectOrg(t, app, s, "orgA", "acmedev")
@@ -771,25 +790,32 @@ func TestAutoPayoutDrainsPendingIdempotent(t *testing.T) {
req(t, app, http.MethodPost, "/v1/authors/repos/verify", "orgA", false, map[string]any{"repoUrl": "acme/widgets"})
req(t, app, http.MethodPost, "/v1/authors/deploys/record", "orgB", false, map[string]any{"repoUrl": "acme/widgets", "project": "proj-b"})
approve(t, app, idA)
fc.setSpend("orgB", 10000) // $100 × 20% → 2000c
fc.setSpend("orgB", 10000) // the state that used to accrue-and-pay on its own
// The automatic loop accrues AND pays in one pass — the closed money loop.
sweepAndPayout(s)
const want = 10000 * defaultShareBps / bpsDenom // 2000
// Read the surface repeatedly — the GET is a PURE READ.
for i := 0; i < 3; i++ {
if st, _ := req(t, app, http.MethodGet, "/v1/authors", "orgA", false, nil); st != http.StatusOK {
t.Fatalf("GET /v1/authors = %d, want 200", st)
}
}
a, _ := s.State.store.GetByID(ctx, idA)
if a.AccruedCents != want || a.PaidCents != want || a.PendingCents() != 0 {
t.Fatalf("auto loop: accrued=%d paid=%d pending=%d, want %d/%d/0", a.AccruedCents, a.PaidCents, a.PendingCents(), want, want)
if a.AccruedCents != 0 || a.PaidCents != 0 {
t.Fatalf("a GET moved money: accrued=%d paid=%d, want 0/0", a.AccruedCents, a.PaidCents)
}
// External author → the payout is a credits grant into their wallet, exactly once.
if fc.bal("orgA") != want || fc.depositCount() != 1 {
t.Fatalf("auto payout wallet=%d deposits=%d, want %d/1", fc.bal("orgA"), fc.depositCount(), want)
if fc.depositCount() != 0 {
t.Fatalf("the ledger received %d deposit(s) from a read; want 0", fc.depositCount())
}
// IDEMPOTENT: a second automatic pass (same period) accrues nothing more and pays
// nothing more — pending is 0, so RecordPayout's guard refuses. No double-pay.
sweepAndPayout(s)
// The accrual is not dead — it is just a human's POST now. It accrues, and STILL
// pays nothing until a human asks for the payout separately.
req(t, app, http.MethodPost, "/v1/admin/authors/sweep", "admin", true, nil)
a, _ = s.State.store.GetByID(ctx, idA)
if a.PaidCents != want || a.AccruedCents != want || fc.depositCount() != 1 {
t.Fatalf("double auto-pay! accrued=%d paid=%d deposits=%d, want %d/%d/1", a.AccruedCents, a.PaidCents, fc.depositCount(), want, want)
const want = 10000 * defaultShareBps / bpsDenom // 2000
if a.AccruedCents != want {
t.Fatalf("admin sweep accrued=%d, want %d — the no-deposit proof above would be vacuous", a.AccruedCents, want)
}
if a.PaidCents != 0 || fc.depositCount() != 0 {
t.Fatalf("accrual paid out on its own: paid=%d deposits=%d, want 0/0", a.PaidCents, fc.depositCount())
}
}
@@ -823,7 +849,7 @@ func TestHanzoForkRoutesToTreasury(t *testing.T) {
// orgB spends $100 → Hanzo earns 20% = 2000c, auto-paid INTO the treasury.
fc.setSpend("orgB", 10000)
sweepAndPayout(s)
adminSweepAndPay(t, app, s)
const want = 10000 * defaultShareBps / bpsDenom // 2000
sys, _ = s.State.store.GetByID(ctx, sys.ID)
if sys.AccruedCents != want || sys.PaidCents != want || sys.PendingCents() != 0 {
@@ -838,7 +864,7 @@ func TestHanzoForkRoutesToTreasury(t *testing.T) {
req(t, app, http.MethodPost, "/v1/authors/deploys/record", "hanzo", false,
map[string]any{"repoUrl": "hanzoai/chat-starter", "project": "proj-self"})
fc.setSpend("hanzo", 50000)
sweepAndPayout(s)
adminSweepAndPay(t, app, s)
sys, _ = s.State.store.GetByID(ctx, sys.ID)
if sys.AccruedCents != want {
t.Fatalf("self-deploy accrued to treasury: %d, want %d (self excluded)", sys.AccruedCents, want)
@@ -896,7 +922,7 @@ func TestMount(t *testing.T) {
}
t.Cleanup(func() { _ = Shutdown() })
r := httptest.NewRequest(http.MethodGet, "/v1/authors", nil)
resp, err := app.Fiber().Test(r, fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
resp, err := app.Test(r, zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
if err != nil {
t.Fatalf("Test: %v", err)
}
@@ -1083,7 +1109,7 @@ func TestSettlementIsRecordedNotInferred(t *testing.T) {
req(t, app, http.MethodPost, "/v1/authors/deploys/record", "orgB", false,
map[string]any{"repoUrl": "https://github.com/hanzoai/chat-starter", "project": "proj-b"})
fc.setSpend("orgB", 10000)
sweepAndPayout(s)
adminSweepAndPay(t, app, s)
sys, err := s.State.store.GetByOrg(ctx, "hanzo")
if err != nil {
t.Fatalf("first-party author missing: %v", err)
@@ -1157,7 +1183,7 @@ func TestEnsureSystemAuthorPromotesExistingConnected(t *testing.T) {
t.Fatalf("maintainer author = %+v (%v), want approved — else it can never earn", a, err)
}
fc.setSpend("orgB", 10000)
sweepAndPayout(s)
adminSweepAndPay(t, app, s)
a, _ = s.State.store.GetByOrg(ctx, "hanzo")
if want := int64(10000 * defaultShareBps / bpsDenom); a.AccruedCents != want || a.PaidCents != want {
t.Fatalf("accrued=%d paid=%d, want %d/%d", a.AccruedCents, a.PaidCents, want, want)
+6 -2
View File
@@ -231,12 +231,16 @@ func TestBasisIsPureRead(t *testing.T) {
}
assertStill("before any sweep", 0, 0, 0, 0)
// The dashboard (which sweeps lazily on read) does what the audit read refused to.
// No read sweeps any more — the dashboard is a pure read too.
const want = 10000 * defaultShareBps / bpsDenom
if st, b := req(t, app, http.MethodGet, "/v1/authors", "orgA", false, nil); st != http.StatusOK {
t.Fatalf("dashboard want 200, got %d (%s)", st, b)
}
assertStill("after the dashboard's lazy sweep", want, 0, 1, want)
assertStill("after the dashboard read", 0, 0, 0, 0)
// Only the admin POST accrues.
req(t, app, http.MethodPost, "/v1/admin/authors/sweep", "admin", true, nil)
assertStill("after the admin sweep", want, 0, 1, want)
for i := 0; i < 3; i++ {
if v, _ := getBasis(t, app, "orgA", ""); v.Reconciliation.LedgerEarningCents != want {
+13 -17
View File
@@ -6,36 +6,32 @@ import (
"github.com/hanzoai/cloud/apps/payout"
)
// commerce is the narrow money seam the author royalty loop needs: read a deploying
// org's metered spend (the royalty accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:author). It is an INTERFACE so the
// store/handler logic is testable with a fake ledger; the production binding is
// clients/payout, reached through the thin adapter below.
// commerce is the ONE thing the royalty loop asks of the money plane, and it is a
// QUESTION, not an instruction: what has this org spent? That read is the accrual
// base. It is an INTERFACE so the sweep is testable against a fake.
//
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. An author payout-in-credits still lands in precisely the wallet the
// balance panel reads, indistinguishable from an admin grant except by its
// grant:author tag.
// THERE IS NO DEPOSIT HERE, AND THERE IS NOT GOING TO BE ONE. This seam used to
// carry `deposit`, which is how a GET on this surface came to mint platform credit:
// the capability existed, so a caller eventually reached it. An author royalty is a PAYABLE —
// accrued and recorded here, settled by a human out of band — and platform credit is
// issued only by an admin grant. Re-adding a write method here re-opens exactly the
// hole that was shut, so the SHAPE of this interface is load-bearing and
// TestCommerceSeamIsReadOnly fails if it ever grows one.
type commerce interface {
configured() bool
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom payout.
// errUnconfigured is the shared sentinel a read against an unwired commerce returns,
// so accrual stays honestly pending rather than silently earning.
var errUnconfigured = payout.ErrUnconfigured
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
// delegation, and it delegates exactly one read.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
-86
View File
@@ -1,86 +0,0 @@
package authors
import (
"os"
"strings"
"time"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/cloud"
)
// scheduler.go is authors' in-process money loop: a periodic goroutine that ACCRUES
// every approved author's royalty for the current period AND AUTO-PAYS their pending
// balance — so the creator payout runs with NO human in the loop. It mirrors
// clients/social/scheduler.go (ticker + idempotent stop) and inherits the same
// single-writer guarantee: authors is mounted only on the single-writer cloud pod, so
// exactly one scheduler exists per deployment. The manual /v1/admin/authors/sweep and
// /v1/admin/authors/:id/payout endpoints remain as operator OVERRIDES; this drives the
// DEFAULT automatic path.
const (
// schedulerIntervalEnv overrides the accrual+payout cadence (a Go duration;
// "0"/"off"/"false" disables it, e.g. to drive the loop manually). Default is
// hourly: accrual is month-to-date and idempotent per period, so an hourly cadence
// keeps balances fresh and pays authors promptly without hammering commerce.
schedulerIntervalEnv = "CLOUD_AUTHORS_SCHEDULER_INTERVAL"
defaultSchedulerInterval = time.Hour
)
// schedulerInterval resolves the cadence from env. "0"/"off"/"false" disables; an
// unparseable value disables (fail-safe — never silently pick a surprising cadence).
func schedulerInterval(log luxlog.Logger) time.Duration {
raw := strings.TrimSpace(os.Getenv(schedulerIntervalEnv))
switch raw {
case "":
return defaultSchedulerInterval
case "0", "off", "false":
return 0
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
log.Warn("invalid "+schedulerIntervalEnv+" — authors scheduler disabled", "value", raw, "err", err)
return 0
}
return d
}
// startScheduler launches the periodic accrual+auto-payout loop and returns its stop
// function (never nil, idempotent). The first sweep runs one interval in (the deploy
// path's lazy sweep + recordDeploy already accrue live in the meantime). stop signals
// the loop AND waits for an in-flight sweep to finish, so Shutdown never closes the
// store out from under a running payout.
func startScheduler(s *cloud.Service[state]) func() {
interval := schedulerInterval(s.Log)
if interval == 0 {
s.Log.Info("authors scheduler disabled", "env", schedulerIntervalEnv)
return func() {}
}
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
sweepAndPayout(s)
}
}
}()
s.Log.Info("authors scheduler started (auto accrual + payout)", "interval", interval)
var once bool
return func() {
if once {
return
}
once = true
close(done)
<-stopped
}
}
+1 -12
View File
@@ -47,7 +47,7 @@ import (
// zipdoc lifts the doc comment off each typed op and each In/Out field into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by `make openapi`.
// and the MCP tool list — Go drops comments at compile time. Run by `make describe`.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
@@ -147,17 +147,6 @@ func (o ops) myAuthors(ctx context.Context, _ *noInput) (*payload, error) {
return nil, zip.Errorf(http.StatusInternalServerError, "load author: %v", err)
}
// Lazy accrual sweep for MY deploying orgs (bounded, best-effort — a commerce
// hiccup never fails the page; it simply accrues on the next sweep).
if a.Status == StatusApproved {
if _, _, serr := sweepAuthor(o.s, ctx, a); serr != nil {
o.s.Log.Warn("authors: lazy sweep failed", "author", a.ID, "err", serr)
}
if refreshed, rerr := o.s.State.store.GetByID(ctx, a.ID); rerr == nil {
a = refreshed // pick up any accrual the lazy sweep just latched
}
}
repos, err := o.s.State.store.ListRepos(ctx, a.ID, repoLimit)
if err != nil {
return nil, zip.Errorf(http.StatusInternalServerError, "list repos: %v", err)
+1 -1
View File
@@ -274,7 +274,7 @@ func do(t *testing.T, app *zip.App, method, path, user, org, body string) (int,
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test(%s %s): %v", method, path, err)
}
+1 -1
View File
@@ -181,7 +181,7 @@ type ops struct{ s *cloud.Service[state] }
// zipdoc lifts the doc comment off each typed op and its In/Out fields into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by `make openapi`.
// and the MCP tool list — Go drops comments at compile time. Run by `make describe`.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
+2 -4
View File
@@ -12,7 +12,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
@@ -49,7 +48,6 @@ func newAppMCP(t *testing.T) *zip.App {
}
// zip installs /mcp in prepare(), which Listen would call; a Fiber().Test app
// never listens. Once-guarded, so calling it here is safe.
app.Prepare()
t.Cleanup(func() { _ = Shutdown(context.Background()) })
return app
}
@@ -144,7 +142,7 @@ func req(t *testing.T, app *zip.App, method, path, org string, body any) httpRes
rq.Header.Set("X-Org-Id", org)
rq.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: 0})
resp, err := app.Test(rq, zip.TestConfig{Timeout: 0})
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -162,7 +160,7 @@ func reqRaw(t *testing.T, app *zip.App, path, org string, raw string) httpResult
rq.Header.Set("X-Org-Id", org)
rq.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: 0})
resp, err := app.Test(rq, zip.TestConfig{Timeout: 0})
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
+1 -1
View File
@@ -327,7 +327,7 @@ func Shutdown(context.Context) error {
// zipdoc lifts the doc comment off each typed op and each In/Out field into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by `make openapi`.
// and the MCP tool list — Go drops comments at compile time. Run by `make describe`.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
+1 -2
View File
@@ -20,7 +20,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/goja"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
@@ -58,7 +57,7 @@ func req(t *testing.T, app *zip.App, method, path, org string, body any) (int, [
rq.Header.Set("X-Org-Id", org)
rq.Header.Set("X-User-Id", "u_"+org) // makes principal.Validated true
}
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: 30 * time.Second})
resp, err := app.Test(rq, zip.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+2 -3
View File
@@ -10,7 +10,6 @@ import (
"time"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
@@ -102,7 +101,7 @@ func TestServeCollectionsGate(t *testing.T) {
app.All("/v1/collections/*", func(c *zip.Ctx) error { return serveCollections(proxy, c) })
// No principal → 403, upstream never reached.
res, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, "/v1/collections/tenants/records", nil), fiber.TestConfig{Timeout: 30 * time.Second})
res, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/collections/tenants/records", nil), zip.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("app.Test (anon): %v", err)
}
@@ -116,7 +115,7 @@ func TestServeCollectionsGate(t *testing.T) {
// Validated principal (X-User-Id set) + allow-listed path → forwarded.
req := httptest.NewRequest(http.MethodGet, "/v1/collections/tenants/records", nil)
req.Header.Set("X-User-Id", "u-123")
res, err = app.Fiber().Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
res, err = app.Test(req, zip.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("app.Test (authed): %v", err)
}
+32 -4
View File
@@ -99,12 +99,40 @@ func availableCents(ctx context.Context, org, subject string) (cents int64, ok b
// A void reply is not a zero balance. Nothing was read, so nothing is known.
return 0, true, fmt.Errorf("balance: commerce answered nothing")
}
cents, cerr := out.Amount.Minor()
if cerr != nil {
// Round DOWN, explicitly, because this is a DISPLAY.
//
// plane.Money.Minor() refuses a value finer than a cent rather than round
// behind the caller — right for a debit, and its own doc says a caller that
// wants a rounded figure "should round explicitly, where the choice is
// visible". This view never rounded, so a real balance made the read FAIL:
// live on 2026-08-03 the org held $149,913.078983985999994361 and every read
// answered 502 "billing upstream unreachable" — a misleading label, since
// nothing upstream was involved — which the console rendered as "Unavailable"
// while the money sat there. It worsens as usage accumulates, because a longer
// history makes a sub-cent tail likelier.
//
// Minor() RESCALES, and hanzoai/decimal's Rescale rounds half-away-from-zero
// (decimal.go:145) — it does not truncate. Measured live: the ledger's
// …078983985999994361 came back as 14991308 cents, a tenth of a cent ABOVE the
// true balance. That is acceptable here and nowhere else: this number is a
// DISPLAY, nothing is billed from it, and the gate that admits or refuses
// spend reads the exact decimal itself. A caller that must not overstate —
// any debit — has to round down deliberately rather than reuse this.
//
// (apps/ai carries a comment calling this "truncated toward zero" for the same
// call. That is wrong in the same way and worth correcting there; its gate
// compares > 0, so a sub-cent rounding cannot change its verdict.)
amt, perr := out.Amount.Parse()
if perr != nil {
// The peer ANSWERED and the reply did not parse. That is a real failure, not
// an absent ledger, and it must surface: a corrupt reply rendered as zero is
// a funded account shown as broke.
return 0, true, cerr
return 0, true, perr
}
return cents, true, nil
minor := amt.Minor() // big.Int of cents; Rescale rounds half-away-from-zero
if !minor.IsInt64() {
return 0, true, fmt.Errorf("balance %s %s exceeds int64 cents",
out.Amount.Decimal, out.Amount.Currency)
}
return minor.Int64(), true, nil
}
+1 -1
View File
@@ -45,7 +45,7 @@ func s2sCall(t *testing.T, app *zip.App, path, token, org string) (int, []byte)
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test GET %s: %v", path, err)
}
+3 -3
View File
@@ -11,7 +11,7 @@ import (
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/money"
"github.com/hanzoai/cloud/money"
"github.com/hanzoai/cloud/types"
)
@@ -130,7 +130,7 @@ func TestBalance_SubjectIsTheGateSubject(t *testing.T) {
if tc.userName != "" {
req.Header.Set("X-User-Name", tc.userName)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
@@ -251,7 +251,7 @@ func TestBalance_ReportsTheAccountItRead(t *testing.T) {
if tc.userName != "" {
req.Header.Set("X-User-Name", tc.userName)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
+90
View File
@@ -148,6 +148,35 @@ func (p *commerceProxy) post(ctx context.Context, path, org string, body []byte,
return b, resp.StatusCode, nil
}
// del performs one service-token commerce DELETE scoped to org, returning commerce's
// raw body + status VERBATIM. Same S2S trust as get and post: the caller's OWN org
// rides X-Org-Id and the admin service token authorizes the removal. It carries no
// body and no idempotency key — DELETE of a named resource is idempotent by identity,
// so a retry removes the same card or 404s, and there is nothing for a guard to add.
func (p *commerceProxy) del(ctx context.Context, path, org string, q url.Values) ([]byte, int, error) {
u := p.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+p.token)
req.Header.Set("X-Org-Id", org)
resp, err := p.http.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("commerce unreachable: %w", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
// state is billing's own data; shared deps live in the embedded cloud.Base.
type state struct {
commerce *commerceProxy
@@ -193,6 +222,12 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// the co-resident commerce app could serve it — the console's save-card call
// died there, and with it auto-recharge, which charges the vaulted card.
app.Post("/v1/billing/methods", cloud.Handle(s, createPaymentMethod))
// Removing a saved card, on the SAME router as the save for the same reason the
// save is here: the host claims a prefix for ONE app across every method, so a
// sub-resource this app does not register misses on METHOD (405) rather than
// falling through to anyone else. It did — a customer could ADD a card and never
// REMOVE one.
app.Delete("/v1/billing/methods/:id", cloud.Handle(s, deletePaymentMethod))
// The customer-facing /v1/finance/* PROJECTION of this same commerce plane (the
// finance.hanzo.ai + console Finance surfaces). It reuses this package's commerceProxy
@@ -329,6 +364,20 @@ func init() {
"carrying the processor's own reason — forwarded verbatim, because insufficient funds "+
"and a wrong security code are different remedies for the customer.\n\n"+
"401 without a validated principal.")
openapi.Describe("/v1/billing/methods/:id", http.MethodDelete,
"Remove a saved card from the caller's org",
"Detaches a card on file: the stored reference is removed here AND withdrawn from the "+
"processor's vault, so nothing is left that a later charge could bill.\n\n"+
"The id is resolved INSIDE the caller's own org, so it can only ever name a card "+
"this org can list. Another tenant's id does not resolve and answers 404 — not 403, "+
"because a status that separates 'not yours' from 'not there' turns an id into "+
"something worth guessing.\n\n"+
"Removing the card an auto-recharge or a running GPU lease bills leaves that "+
"arrangement with nothing to charge; it is the customer's call to make, and this "+
"makes it rather than refusing on their behalf.\n\n"+
"401 without a validated principal — the org is the validated owner claim, never a "+
"client-supplied field, so this cannot be pointed at another tenant.")
}
// billingSubjectKeys — every query/body param through which a commerce billing endpoint
@@ -584,6 +633,47 @@ func createPaymentMethod(s *cloud.Service[state], c *zip.Ctx) error {
return c.Bytes(status, body)
}
// deletePaymentMethod → commerce DELETE /v1/billing/portal/methods/{id}: remove a
// saved card. The twin of paymentMethods, at the twin address and for the same
// reason — this app OWNS /v1/billing/methods, so it cannot forward there without
// re-entering itself, and commerce publishes the portal family as the face a host
// may proxy to.
//
// TENANT SCOPE. The org is the VALIDATED principal (principal.Org), never
// readerOrg: readerOrg additionally admits the trusted in-proc service token, which
// is right for a READ the ai gate makes on its own behalf and wrong for a MUTATION
// — the same rule createPaymentMethod and gpuCharge already follow, and the reason
// they do. The org then rides X-Org-Id, which selects commerce's per-org namespace,
// so `id` is resolved INSIDE the caller's own tenant: another org's card id is a
// not-found miss there and comes back 404 (never 403 — an id must not be probeable).
// A caller can therefore only ever delete a method its own org can list, which is
// exactly the scope paymentMethods reads.
//
// The id is a caller-supplied path segment, so it is percent-escaped into the
// upstream URL rather than concatenated raw: the value names a resource, it does
// not get to name a route.
func deletePaymentMethod(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to remove a card")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return zip.ErrBadRequest("a payment method id is required")
}
if !s.State.commerce.configured() {
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
}
body, status, err := s.State.commerce.del(c.Context(), "/v1/billing/portal/methods/"+url.PathEscape(id), org, scopedBillingQuery(c, org))
if err != nil {
s.Log.Warn("commerce remove card failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
}
c.SetHeader("Content-Type", "application/json")
c.SetHeader("Cache-Control", "no-store")
return c.Bytes(status, body)
}
// pinSubjectBody overwrites every commerce billing-subject key on a top-level JSON object
// with subject (the caller's OWN org), so a POST body can NEVER act on another tenant's
// wallet. The keys mirror commerce's edge-auth billing-subject set {user,userId,customerId}
+96 -2
View File
@@ -55,6 +55,7 @@ func (f *fakeCommerce) server(t *testing.T) *httptest.Server {
mux.HandleFunc("/v1/billing/gpu/eligibility", h)
mux.HandleFunc("/v1/billing/gpu/charge", h)
mux.HandleFunc("/v1/billing/portal/methods", h)
mux.HandleFunc("/v1/billing/portal/methods/", h) // the {id} sub-resource
mux.HandleFunc("/v1/billing/methods", h)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
@@ -85,7 +86,7 @@ func call(t *testing.T, app *zip.App, method, path, user, org string) (int, []by
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -230,7 +231,7 @@ func callBody(t *testing.T, app *zip.App, method, path, user, org, body string)
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
@@ -425,3 +426,96 @@ func TestCreatePaymentMethod_Unauthenticated401(t *testing.T) {
t.Fatalf("unauth save card: want 401, got %d", code)
}
}
// TestDeletePaymentMethod_ProxiesPortal_Scoped — removing a card reaches commerce
// at the PORTAL sub-resource (never /v1/billing/methods/{id}, which this app owns
// and would re-enter), carries the caller's OWN org as the trusted selector, and
// forwards commerce's body and status verbatim.
func TestDeletePaymentMethod_ProxiesPortal_Scoped(t *testing.T) {
f := &fakeCommerce{status: 200, body: `{"deleted":true,"id":"pm_1"}`}
app := mountApp(t, f.server(t).URL, "svc-token")
code, body := call(t, app, http.MethodDelete, "/v1/billing/methods/pm_1", "maxpower/dave", "maxpower")
if code != 200 || string(body) != f.body {
t.Fatalf("delete method: want 200 verbatim, got %d (%s)", code, body)
}
if f.gotMethod != http.MethodDelete || f.gotPath != "/v1/billing/portal/methods/pm_1" {
t.Fatalf("commerce call: want DELETE /v1/billing/portal/methods/pm_1, got %s %q", f.gotMethod, f.gotPath)
}
if f.gotOrg != "maxpower" {
t.Fatalf("X-Org-Id must be the caller's own org, got %q", f.gotOrg)
}
}
// TestDeletePaymentMethod_TenantIsolation is the RED-focus test: everything a
// caller can say about WHOSE card this is gets overwritten with the caller's own
// org before the request leaves this process.
//
// X-Org-Id is the only tenant selector commerce honours on the S2S seam, and it is
// taken from the VALIDATED principal — so org A deleting with a forged ?org=orgb,
// a forged subject, or org B's own id can never reach org B's namespace, and the
// id it names is resolved inside org A's, where a foreign card is not found.
// (commerce/api/billing/payment_methods_tenant_test.go proves the far side: org B
// handed org A's id gets 404 and the card survives.)
func TestDeletePaymentMethod_TenantIsolation(t *testing.T) {
f := &fakeCommerce{status: 200, body: `{"deleted":true,"id":"pm_victim"}`}
app := mountApp(t, f.server(t).URL, "svc-token")
// org A ("maxpower") aims a delete at org B ("victimorg") every way the wire allows.
code, _ := call(t, app, http.MethodDelete,
"/v1/billing/methods/pm_victim?org=victimorg&customerId=victimorg&user=victimorg&userId=victimorg",
"maxpower/dave", "maxpower")
if code != 200 {
t.Fatalf("want 200, got %d", code)
}
if f.gotOrg != "maxpower" {
t.Fatalf("CROSS-TENANT: X-Org-Id reached commerce as %q, want the caller's own org", f.gotOrg)
}
if f.gotQuery.Has("org") {
t.Fatalf("CROSS-TENANT: client-forged org reached commerce as %q", f.gotQuery.Get("org"))
}
for _, k := range []string{"customerId", "user", "userId"} {
if got := f.gotQuery.Get(k); got != "maxpower" {
t.Fatalf("CROSS-TENANT: forged %s reached commerce as %q, want the caller's own org", k, got)
}
}
}
// TestDeletePaymentMethod_Unauthenticated401 — removing a card is a MUTATION, so
// it resolves the org with principal.Org (the validated principal ONLY) and not
// readerOrg, which additionally admits the in-proc service token for reads. No
// identity is 401 "sign in", the same answer saving a card gives.
func TestDeletePaymentMethod_Unauthenticated401(t *testing.T) {
f := &fakeCommerce{status: 200, body: `{}`}
app := mountApp(t, f.server(t).URL, "svc-token")
if code, _ := call(t, app, http.MethodDelete, "/v1/billing/methods/pm_1", "", ""); code != 401 {
t.Fatalf("unauth delete card: want 401, got %d", code)
}
// The service token alone is not a customer: a READ would be admitted here
// (readerOrg), a mutation must not be.
if f.gotPath != "" {
t.Fatalf("an unauthenticated delete must not reach commerce at all, got %q", f.gotPath)
}
}
// TestDeletePaymentMethod_IDIsAValueNotARoute — the id is a caller-supplied path
// segment, and the router hands it over STILL PERCENT-ENCODED, so it is escaped
// again on the way out. An id carrying encoded separators must name a (missing)
// resource inside the portal sub-resource, never steer the S2S call to a different
// commerce address — /v1/billing/deposit being the one that would matter, since
// this process holds the service token that satisfies commerce's mint gate.
func TestDeletePaymentMethod_IDIsAValueNotARoute(t *testing.T) {
f := &fakeCommerce{status: 404, body: `{"error":"payment method not found"}`}
app := mountApp(t, f.server(t).URL, "svc-token")
code, body := call(t, app, http.MethodDelete, "/v1/billing/methods/pm%2f..%2f..%2fbilling%2fdeposit",
"maxpower/dave", "maxpower")
if code != 404 || string(body) != f.body {
t.Fatalf("want commerce's 404 forwarded verbatim, got %d (%s)", code, body)
}
const prefix = "/v1/billing/portal/methods/"
rest, ok := strings.CutPrefix(f.gotPath, prefix)
if !ok || strings.Contains(rest, "/") {
t.Fatalf("the id must stay ONE segment under %s, got %q", prefix, f.gotPath)
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/money"
"github.com/hanzoai/cloud/money"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/plane"
+1 -1
View File
@@ -39,7 +39,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/money"
"github.com/hanzoai/cloud/money"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/types"
"github.com/zap-proto/zip"
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/money"
"github.com/hanzoai/cloud/money"
"github.com/hanzoai/cloud/types"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/money"
"github.com/hanzoai/cloud/money"
)
// TestCoResidentUsage proves usage() answers from the finance ledger (never the
+1 -1
View File
@@ -98,7 +98,7 @@ func mountApp(t *testing.T) *zip.App {
func get(t *testing.T, app *zip.App, path string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
resp, err := app.Fiber().Test(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
+2 -3
View File
@@ -32,11 +32,10 @@ import (
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
var wireCfg = fiber.TestConfig{Timeout: 60 * time.Second, FailOnTimeout: true}
var wireCfg = zip.TestConfig{Timeout: 60 * time.Second, FailOnTimeout: true}
// mountBooks brings up the real /v1/books surface over a temp DataDir: the real
// router, the real middleware, the real stores.
@@ -67,7 +66,7 @@ func hit(t *testing.T, app *zip.App, method, path, org string, body []byte) (int
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req, wireCfg)
resp, err := app.Test(req, wireCfg)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
+3 -4
View File
@@ -12,14 +12,13 @@ import (
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
// botTestCfg replaces fiber's Test() default of a 1s WALL-CLOCK deadline on an
// in-process request: under load a correct handler blows it and the test reports
// an i/o timeout, which teaches nothing. The generous bound still fails a hang.
var botTestCfg = fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true}
var botTestCfg = zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true}
// mountBot builds the surface over a registry the test controls, through the same
// routes() the binary calls — so what a test drives is the code that ships.
@@ -56,7 +55,7 @@ func botCall(t *testing.T, app *zip.App, method, path, org string, body any) (in
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req, botTestCfg)
resp, err := app.Test(req, botTestCfg)
if err != nil {
t.Fatalf("Test: %v", err)
}
@@ -138,7 +137,7 @@ func TestNodeRoutesRefuseAnUnvalidatedCaller(t *testing.T) {
// A forged org with NO validated user is the same refusal.
req := httptest.NewRequest(http.MethodGet, "/v1/bot/nodes", nil)
req.Header.Set("X-Org-Id", "acme") // forged; no X-User-Id
resp, err := app.Fiber().Test(req, botTestCfg)
resp, err := app.Test(req, botTestCfg)
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -118,7 +118,7 @@ func call(t *testing.T, app *zip.App, method, path, org string) (int, []byte) {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req, testCfg)
resp, err := app.Test(req, testCfg)
if err != nil {
t.Fatalf("Test: %v", err)
}
@@ -230,7 +230,7 @@ func TestListRefusesForgedOrgWithoutValidatedPrincipal(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/bots", nil)
req.Header.Set("X-Org-Id", "acme") // forged: no X-User-Id
resp, err := app.Fiber().Test(req, testCfg)
resp, err := app.Test(req, testCfg)
if err != nil {
t.Fatalf("Test: %v", err)
}
+2 -3
View File
@@ -1,9 +1,8 @@
package bots
import (
"github.com/zap-proto/zip"
"time"
"github.com/zap-proto/fiber/v3"
)
// testCfg replaces fiber's Test() default of Timeout: 1s (fiber/v3@v3.2.1
@@ -13,4 +12,4 @@ import (
// for reasons unrelated to what it guards teaches nothing, and a tenant-isolation
// guard that is a coin flip is worse than none. The generous bound still fails a
// genuine hang.
var testCfg = fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true}
var testCfg = zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true}
+1 -1
View File
@@ -157,7 +157,7 @@ func post(t *testing.T, app *zip.App, path, body, org string) (int, string) {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req, testCfg)
resp, err := app.Test(req, testCfg)
if err != nil {
t.Fatalf("Test: %v", err)
}
+1 -1
View File
@@ -45,7 +45,7 @@ import (
// zipdoc lifts the doc comment off each typed op and each In/Out field into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by `make openapi`.
// and the MCP tool list — Go drops comments at compile time. Run by `make describe`.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
+1 -2
View File
@@ -20,7 +20,6 @@ import (
"testing"
"time"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
@@ -56,7 +55,7 @@ func send(t *testing.T, app *zip.App, method, path, org, ctype, body string) (in
rq.Header.Set("X-Org-Id", org)
rq.Header.Set("X-User-Id", "u_"+org) // a validated principal (principal.Org gate)
}
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: wireTimeout, FailOnTimeout: true})
resp, err := app.Test(rq, zip.TestConfig{Timeout: wireTimeout, FailOnTimeout: true})
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+4 -4
View File
@@ -101,7 +101,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// zipdoc lifts the doc comment off each typed op and its In/Out fields into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by `make openapi`.
// and the MCP tool list — Go drops comments at compile time. Run by `make describe`.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
@@ -113,7 +113,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// the round detail read and the five deletes (typed.go, whose whole input is one
// path segment), plus the three writes whose bodies are made only of fields the
// bundle reads as strings (writes.go). Every one relays the bundle's own refusal
// bytes through bundleErr.
// bytes through goja.BundleErr.
//
// ELEVEN body-carrying writes stay untyped relays, and the reason is the REQUEST,
// not the response. The bundle validates with COERCING helpers
@@ -133,9 +133,9 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// is harmless (the inner one is what the handler sees).
g.Use(cloud.Bridge())
// Then the bundle's own envelope: a typed op that must answer the bundle's
// 400/404/409/500 returns a bundleErr, and this writes those bytes back
// 400/404/409/500 returns a goja.BundleErr, and this writes those bytes back
// verbatim. Also before the leaves, for the same registration-order reason.
g.Use(bundleEnvelope())
g.Use(goja.Envelope())
// ---- the typed ops (typed.go carries the models and the prose) ----
//
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
@@ -59,7 +58,7 @@ func probe(t *testing.T, app *zip.App, method, path, org string, body any) (int,
// A generous timeout: fiber's 1s default is too tight for the in-memory harness
// under an occasional GC / cold-sqlite pause (the goja dispatch does real DB
// work), which would flake this suite.
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
resp, err := app.Test(rq, zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+6 -52
View File
@@ -14,7 +14,7 @@ package captable
// {success,message,errors} for a validation failure, 404/409
// {success,message}, and the top-level catch's 500. A typed op's error path
// renders zip's {status,code,error} instead, which would drop the `errors`
// list a client renders. bundleErr + bundleEnvelope below close that: the
// list a client renders. goja.BundleErr + goja.Envelope close that: the
// op returns the bundle's status and BYTES, and the group middleware writes
// them back verbatim. So a reachable non-2xx is no longer a reason to stay
// untyped.
@@ -32,7 +32,7 @@ package captable
// So the typed ops BELOW are the routes with NO REQUEST BODY: the eleven
// org-scoped collection reads, the one round detail read, and the five deletes.
// A bodyless route has nothing to coerce — its whole input is one path segment —
// so its In is faithful by construction and its answer relays through bundleErr.
// so its In is faithful by construction and its answer relays through goja.BundleErr.
//
// The three body-carrying routes that ARE typed live in writes.go: `optString`
// leniency is carried, not narrowed, by a verbatim scalar, which is what a
@@ -58,7 +58,6 @@ package captable
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -91,32 +90,6 @@ func tenantOf(ctx context.Context) (string, error) {
// the org IS the address and there is no parameter to bind.
type noInput struct{}
// bundleErr is the bundle's OWN non-2xx answer, carried as a Go error so a typed
// op can return it. The bundle authors envelopes cloud has no vocabulary for —
// 400 {success,message,errors}, 404/409 {success,message}, and the top-level
// catch's 500 — and zip's error path renders {status,code,error}, which has
// nowhere to put the `errors` list a client renders. So the op returns the bundle's
// status and its BYTES, and bundleEnvelope writes them back untouched.
//
// It is not an escape from typing. The op still declares its In and its Out, so
// the document, the MCP tool, the CLI command and the SDK method all exist; what
// it declines to do is invent a SECOND vocabulary for failures the bundle already
// has words for.
type bundleErr struct {
status int
body []byte
msg string
}
func (e *bundleErr) Error() string { return e.msg }
// Unwrap gives the error a status and a message OFF the HTTP path, where there is
// no response to write bytes into: an MCP tools/call and an in-process CLI invoke
// run op.invoke without passing through bundleEnvelope, so zip's own error handler
// renders this instead — the bundle's status and message in zip's envelope, rather
// than a blanket 500 that loses both.
func (e *bundleErr) Unwrap() error { return &zip.HTTPError{Status: e.status, Msg: e.msg} }
// bundleMessage is the human sentence in a bundle envelope, for the Error() string
// an off-HTTP caller sees. A validation failure appends its list, because "Validation
// failed" alone tells a caller nothing. A body that is not an envelope falls back to
@@ -138,28 +111,9 @@ func bundleMessage(status int, body []byte) string {
return "captable dispatch failed"
}
// bundleEnvelope writes a bundleErr back to the client VERBATIM: the bundle's own
// status, its own bytes, under the bare `application/json` the untyped relay beside
// it sends. Anything else propagates unchanged.
//
// It is installed on the /v1/captable group BEFORE the ops it serves — fiber runs
// middleware in registration order, so one installed after its leaves never runs —
// and it is the ONE place a typed captable op answers with prose the bundle wrote.
func bundleEnvelope() zip.Handler {
return func(c *zip.Ctx) error {
err := c.Continue()
var be *bundleErr
if errors.As(err, &be) {
c.SetHeader("Content-Type", "application/json")
return c.Bytes(be.status, be.body)
}
return err
}
}
// call is the ONE response path for every typed captable op: resolve the tenant,
// run the bundle route on that tenant's store, and decode a 2xx body into out. A
// non-2xx is the BUNDLE's answer and comes back as a bundleErr, so the client gets
// non-2xx is the BUNDLE's answer and comes back as a goja.BundleErr, so the client gets
// the same bytes under the same status the untyped relay wrote.
//
// Only a failure of the HOST itself — the engine never ran, or it answered
@@ -177,7 +131,7 @@ func (o ops) call(ctx context.Context, route string, params map[string]string, o
// body-carrying writes in writes.go, which resolve the tenant on their own path
// (a write refuses an oversized body between the two). It runs the bundle route
// on the tenant's store and decodes the answer, so there is ONE place that turns
// a bundle response into either an out value or a bundleErr.
// a bundle response into either an out value or a goja.BundleErr.
func (o ops) run(ctx context.Context, org, route string, params map[string]string, body any, out any) error {
resp, err := o.s.State.host.Dispatch(ctx, org, goja.BaseRequest{Route: route, Params: params, Body: body})
if err != nil {
@@ -185,7 +139,7 @@ func (o ops) run(ctx context.Context, org, route string, params map[string]strin
return zip.Errorf(http.StatusInternalServerError, "captable dispatch failed")
}
if resp.Status/100 != 2 {
return &bundleErr{status: resp.Status, body: resp.Body, msg: bundleMessage(resp.Status, resp.Body)}
return &goja.BundleErr{Status: resp.Status, Body: resp.Body, Msg: bundleMessage(resp.Status, resp.Body)}
}
if err := json.Unmarshal(resp.Body, out); err != nil {
o.s.Log.Error("captable response decode failed", "route", route, "err", err)
@@ -823,7 +777,7 @@ func (o ops) getSummary(ctx context.Context, _ *noInput) (*captableSummary, erro
// also why they are typed while the creates are not: there is no body to coerce.
//
// Every one answers the same {"success":true} the bundle writes, and refuses
// through bundleErr — 404 {success,message} for an id this org does not hold, and
// through goja.BundleErr — 404 {success,message} for an id this org does not hold, and
// for a stakeholder also 400 {success,message,errors} when the holder still holds
// equity. The bytes are the bundle's own either way.
+32 -134
View File
@@ -30,128 +30,26 @@ package captable
import (
"context"
"encoding/json"
"net/http"
"github.com/hanzoai/cloud/apps/goja"
"github.com/zap-proto/zip"
)
// scalar is ONE JSON value carried from the caller to the bundle unchanged —
// the token exactly as it arrived, quotes and all: `"Acme"`, `123`, `null`.
//
// It exists because the bundle's string helpers are LENIENT in a way no Go type
// is. `optString` takes any scalar and calls String(v), so `{"taxId":12345}`
// stores "12345" today; a `*string` field would refuse it with a 400, making the
// route accept LESS. And `reqString`/`oneOf` REFUSE a non-string, so a `*string`
// field that refused it first would answer in zip's envelope instead of the
// bundle's {success,message,errors}. Carrying the token verbatim keeps both: the
// bundle sees what the caller sent and stays the only judge of it.
//
// A Go string is the carrier because it is a string KIND, so every projection
// describes the field as `string` — which is what these fields are. The
// leniency is not in the schema; it is named in each field's own prose.
//
// The zero value means ABSENT — no key was on the wire — which is why every
// field below is `omitempty` and non-pointer. A pointer would collapse the
// distinction the bundle's partial update depends on: encoding/json sets a
// pointer field to nil for an explicit `null` WITHOUT calling UnmarshalJSON, so
// `{"city":null}` and `{}` would arrive identically, and stakeholders.update
// reads them differently (`!== undefined` is true for null, which clears the
// column). A non-pointer scalar records `null` as the four bytes `null`, and an
// empty JSON string as the two bytes `""`, so neither can be confused with
// absent.
type scalar string
// UnmarshalJSON keeps the caller's bytes. It cannot fail: whatever the caller
// sent for this field is the bundle's to judge, so nothing is rejected here.
func (s *scalar) UnmarshalJSON(b []byte) error {
*s = scalar(b)
return nil
}
// MarshalJSON writes the carried token back exactly as it arrived. A value that
// did NOT come off the wire — a hand-built op input, a URL param bound by
// zip's setScalar — is not a JSON token but the string it spells, so it is
// quoted. That keeps the type total: every scalar marshals to valid JSON.
func (s scalar) MarshalJSON() ([]byte, error) {
if json.Valid([]byte(s)) {
return []byte(s), nil
}
return json.Marshal(string(s))
}
// sizedIn is the request-size half of an input, carried by every op below.
//
// The relay capped a body at maxBody and answered 413 (dispatch), and it did so
// AFTER resolving the tenant. A typed op never sees the request, so the size is
// recorded where the bytes are — the input's own UnmarshalJSON — and read back
// after tenantOf, which is what keeps a 403 ahead of a 413 for the caller that
// has both problems. The field is unexported, so it reaches no schema: it is not
// something a caller sends.
type sizedIn struct{ oversize bool }
// fill decodes the caller's object into v, records whether it exceeded maxBody,
// and NEVER refuses the body. It is the one decode path for the inputs below.
//
// A body that is not an object — an array, a bare scalar, `null` — leaves every
// field absent, which is exactly what the relay did: it decoded into `any` and
// the bundle's asObj turned anything that was not an object into `{}`. The
// bundle then answers, in its own envelope, the same "name is required" it
// always did. A type error on ONE key is saved and decoding continues, so a body
// that echoes `"id":123` back at a PATCH still delivers the fields beside it —
// as the relay did, since the URL carries the id and the bundle never read one
// from the body.
func (s *sizedIn) fill(b []byte, v any) {
s.oversize = len(b) > maxBody
// The error is deliberately dropped, and dropping it is the wire: see above.
_ = json.Unmarshal(b, v)
}
// bundleBody assembles the object the bundle validates from the caller's
// verbatim tokens. A field left at its zero value was never on the wire, so it
// contributes NO key — the `undefined` that a partial update reads.
//
// The result is a plain Go value, not bytes, because that is what crosses into
// goja: the same shape the untyped relay handed the host after decoding the
// caller's bytes into `any`. Key order is lost to the map on the way, and cannot
// matter — the bundle reads its fields by name and echoes no request body back.
func bundleBody(fields map[string]scalar) (any, error) {
obj := make(map[string]json.RawMessage, len(fields))
for k, v := range fields {
if v == "" {
continue // absent: this key was never on the wire
}
raw, err := v.MarshalJSON()
if err != nil {
return nil, err
}
obj[k] = raw
}
b, err := json.Marshal(obj)
if err != nil {
return nil, err
}
var body any
if err := json.Unmarshal(b, &body); err != nil {
return nil, err
}
return body, nil
}
// write is the ONE response path for a body-carrying typed op: refuse an
// oversized body with the relay's 413, run the bundle route on the caller's
// tenant with the assembled body, and decode the 2xx answer into out. A non-2xx
// is the BUNDLE's, relayed through bundleErr exactly as the reads do.
func (o ops) write(ctx context.Context, route string, size sizedIn, params map[string]string, fields map[string]scalar, out any) error {
// is the BUNDLE's, relayed through goja.BundleErr exactly as the reads do.
func (o ops) write(ctx context.Context, route string, size goja.SizedIn, params map[string]string, fields map[string]goja.BodyField, out any) error {
org, err := tenantOf(ctx)
if err != nil {
return err
}
// After the tenant, before the work — the order the relay used.
if size.oversize {
if size.Oversize() {
return zip.Errorf(http.StatusRequestEntityTooLarge, "request body too large")
}
body, err := bundleBody(fields)
body, err := goja.Body(fields)
if err != nil {
o.s.Log.Error("captable body assembly failed", "route", route, "err", err)
return zip.Errorf(http.StatusInternalServerError, "captable dispatch failed")
@@ -174,23 +72,23 @@ type captableUpdated struct {
// captableCompanyUpdate is the company details a tenant can set on its cap-table
// root record.
type captableCompanyUpdate struct {
sizedIn
goja.SizedIn
// IncorporationCountry is the ISO country the entity is incorporated in.
// Optional; omitted, null or empty clears it. Any JSON scalar is accepted
// and stored as its text.
IncorporationCountry scalar `json:"incorporationCountry,omitempty"`
IncorporationCountry goja.Scalar `json:"incorporationCountry,omitempty"`
// IncorporationState is the state or province of incorporation. Optional;
// omitted, null or empty clears it. Any JSON scalar is accepted and stored
// as its text.
IncorporationState scalar `json:"incorporationState,omitempty"`
IncorporationState goja.Scalar `json:"incorporationState,omitempty"`
// IncorporationType is the entity kind, e.g. LLC or C_CORP. Optional;
// omitted, null or empty clears it. Any JSON scalar is accepted and stored
// as its text.
IncorporationType scalar `json:"incorporationType,omitempty"`
IncorporationType goja.Scalar `json:"incorporationType,omitempty"`
// Name is the company's legal name. Required, and it must be a non-empty
// string — anything else is refused with the cap table's own validation
// error.
Name scalar `json:"name,omitempty"`
Name goja.Scalar `json:"name,omitempty"`
}
// UnmarshalJSON keeps the caller's tokens and records the body size; it refuses
@@ -198,8 +96,8 @@ type captableCompanyUpdate struct {
func (in *captableCompanyUpdate) UnmarshalJSON(b []byte) error {
type body captableCompanyUpdate // sheds the method, so this does not recurse
var v body
in.fill(b, &v)
v.sizedIn = in.sizedIn
in.Fill(maxBody, b, &v)
v.SizedIn = in.SizedIn
*in = captableCompanyUpdate(v)
return nil
}
@@ -211,7 +109,7 @@ func (in *captableCompanyUpdate) UnmarshalJSON(b []byte) error {
// never creates one.
func (o ops) updateCompany(ctx context.Context, in *captableCompanyUpdate) (*captableUpdated, error) {
var out captableUpdated
err := o.write(ctx, "company.update", in.sizedIn, nil, map[string]scalar{
err := o.write(ctx, "company.update", in.SizedIn, nil, map[string]goja.BodyField{
"incorporationCountry": in.IncorporationCountry,
"incorporationState": in.IncorporationState,
"incorporationType": in.IncorporationType,
@@ -230,36 +128,36 @@ func (o ops) updateCompany(ctx context.Context, in *captableCompanyUpdate) (*cap
// and a key that is present is written as sent — including an explicit null,
// which clears the column. A request that names none of them is refused.
type captableStakeholderPatch struct {
sizedIn
goja.SizedIn
// City is the stakeholder's city.
City scalar `json:"city,omitempty"`
City goja.Scalar `json:"city,omitempty"`
// CurrentRelationship is how the stakeholder relates to the company, e.g.
// FOUNDER, INVESTOR or EMPLOYEE. This route stores it as sent — unlike
// adding a stakeholder, it is not checked against the vocabulary.
CurrentRelationship scalar `json:"currentRelationship,omitempty"`
CurrentRelationship goja.Scalar `json:"currentRelationship,omitempty"`
// Email is the stakeholder's email. This route stores it as sent — unlike
// adding a stakeholder, it is not checked for shape or uniqueness.
Email scalar `json:"email,omitempty"`
Email goja.Scalar `json:"email,omitempty"`
// ID is the stakeholder to update. It is the path segment: the URL is the
// addressing authority, and the org it is resolved in comes from the
// caller's principal, so an id from another tenant is simply not found.
ID string `json:"id"`
// InstitutionName names the institution, when the stakeholder is one.
InstitutionName scalar `json:"institutionName,omitempty"`
InstitutionName goja.Scalar `json:"institutionName,omitempty"`
// Name is the stakeholder's full name.
Name scalar `json:"name,omitempty"`
Name goja.Scalar `json:"name,omitempty"`
// StakeholderType is INDIVIDUAL or INSTITUTION. This route stores it as
// sent — unlike adding a stakeholder, it is not checked against the
// vocabulary.
StakeholderType scalar `json:"stakeholderType,omitempty"`
StakeholderType goja.Scalar `json:"stakeholderType,omitempty"`
// State is the stakeholder's state or province.
State scalar `json:"state,omitempty"`
State goja.Scalar `json:"state,omitempty"`
// StreetAddress is the stakeholder's street address.
StreetAddress scalar `json:"streetAddress,omitempty"`
StreetAddress goja.Scalar `json:"streetAddress,omitempty"`
// TaxID is the stakeholder's tax identifier.
TaxID scalar `json:"taxId,omitempty"`
TaxID goja.Scalar `json:"taxId,omitempty"`
// Zipcode is the stakeholder's postal code.
Zipcode scalar `json:"zipcode,omitempty"`
Zipcode goja.Scalar `json:"zipcode,omitempty"`
}
// UnmarshalJSON keeps the caller's tokens and records the body size; it refuses
@@ -267,8 +165,8 @@ type captableStakeholderPatch struct {
func (in *captableStakeholderPatch) UnmarshalJSON(b []byte) error {
type body captableStakeholderPatch // sheds the method, so this does not recurse
var v body
in.fill(b, &v)
v.sizedIn = in.sizedIn
in.Fill(maxBody, b, &v)
v.SizedIn = in.SizedIn
*in = captableStakeholderPatch(v)
return nil
}
@@ -283,7 +181,7 @@ func (in *captableStakeholderPatch) UnmarshalJSON(b []byte) error {
// can record a value that adding one would have rejected.
func (o ops) updateStakeholder(ctx context.Context, in *captableStakeholderPatch) (*captableUpdated, error) {
var out captableUpdated
err := o.write(ctx, "stakeholders.update", in.sizedIn, map[string]string{"id": in.ID}, map[string]scalar{
err := o.write(ctx, "stakeholders.update", in.SizedIn, map[string]string{"id": in.ID}, map[string]goja.BodyField{
"city": in.City,
"currentRelationship": in.CurrentRelationship,
"email": in.Email,
@@ -305,12 +203,12 @@ func (o ops) updateStakeholder(ctx context.Context, in *captableStakeholderPatch
// captableRoundCloseRequest closes one of the caller org's open rounds.
type captableRoundCloseRequest struct {
sizedIn
goja.SizedIn
// CloseDate is the date to record the round as closed on. Optional: omitted,
// null or empty records TODAY. Any JSON scalar is accepted and stored as its
// text, and the text is stored unparsed, so a caller that wants an ISO date
// sends one.
CloseDate scalar `json:"closeDate,omitempty"`
CloseDate goja.Scalar `json:"closeDate,omitempty"`
// ID is the round to close. It is the path segment: the URL is the
// addressing authority, and the org it is resolved in comes from the
// caller's principal, so an id from another tenant is simply not found.
@@ -322,8 +220,8 @@ type captableRoundCloseRequest struct {
func (in *captableRoundCloseRequest) UnmarshalJSON(b []byte) error {
type body captableRoundCloseRequest // sheds the method, so this does not recurse
var v body
in.fill(b, &v)
v.sizedIn = in.sizedIn
in.Fill(maxBody, b, &v)
v.SizedIn = in.SizedIn
*in = captableRoundCloseRequest(v)
return nil
}
@@ -334,7 +232,7 @@ func (in *captableRoundCloseRequest) UnmarshalJSON(b []byte) error {
// found. Closing a round does not change what was invested in it.
func (o ops) closeRound(ctx context.Context, in *captableRoundCloseRequest) (*captableUpdated, error) {
var out captableUpdated
err := o.write(ctx, "rounds.close", in.sizedIn, map[string]string{"id": in.ID}, map[string]scalar{
err := o.write(ctx, "rounds.close", in.SizedIn, map[string]string{"id": in.ID}, map[string]goja.BodyField{
"closeDate": in.CloseDate,
}, &out)
if err != nil {
+10 -8
View File
@@ -41,7 +41,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/goja"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
@@ -386,7 +385,7 @@ func TestTypedWritesKeepTheBodyCap(t *testing.T) {
// and the zero value means ABSENT rather than a key with an empty value.
func TestScalarCarriesEveryJSONToken(t *testing.T) {
for _, tok := range []string{`"Acme"`, `123`, `1.5`, `true`, `null`, `""`, `"quote\"inside"`, `"ünïcode"`} {
var s scalar
var s goja.Scalar
if err := json.Unmarshal([]byte(tok), &s); err != nil {
t.Fatalf("scalar cannot hold %s: %v", tok, err)
}
@@ -401,13 +400,17 @@ func TestScalarCarriesEveryJSONToken(t *testing.T) {
// A value that never came off the wire is the string it spells, so the type
// is total: every scalar marshals to valid JSON.
b, err := scalar("bare words").MarshalJSON()
b, err := goja.Scalar("bare words").MarshalJSON()
if err != nil || string(b) != `"bare words"` {
t.Fatalf(`hand-built scalar: got %s (%v), want "bare words" quoted`, b, err)
}
// The zero value contributes no key at all.
body, err := bundleBody(map[string]scalar{"absent": "", "present": `"here"`, "nulled": `null`})
body, err := goja.Body(map[string]goja.BodyField{
"absent": goja.Scalar(""),
"present": goja.Scalar(`"here"`),
"nulled": goja.Scalar(`null`),
})
if err != nil {
t.Fatalf("bundleBody: %v", err)
}
@@ -442,7 +445,6 @@ func newAppMCP(t *testing.T) *zip.App {
}
// zip installs /mcp in prepare(), which Listen would call; a Fiber().Test app
// never listens. Once-guarded, so calling it here is safe.
app.Prepare()
t.Cleanup(func() { _ = Shutdown(context.Background()) })
return app
}
@@ -459,7 +461,7 @@ func toolsCall(t *testing.T, app *zip.App, org, op, args string) (string, bool)
rq.Header.Set("X-Org-Id", org)
rq.Header.Set("X-User-Id", "u_"+org)
}
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
resp, err := app.Test(rq, zip.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
if err != nil {
t.Fatalf("tools/call %s: %v", op, err)
}
@@ -523,8 +525,8 @@ func TestTypedWritesAddressThroughArgumentsAlone(t *testing.T) {
holder := addHolder(t, app, "acme", "ada@example.com")
round := addOpenRound(t, app, "acme", "R-mcp")
patch := toolNamed(t, app, "captable_stakeholders", "patch")
closer := toolNamed(t, app, "captable_rounds", "close")
patch := toolNamed(t, app, "captable", "patch_stakeholders")
closer := toolNamed(t, app, "captable", "post_rounds", "close")
// The stakeholder the arguments name must reach the handler.
text, isErr := toolsCall(t, app, "acme", patch, `{"id":"`+holder+`","city":"Paris"}`)
+67 -15
View File
@@ -60,6 +60,7 @@ import (
"github.com/hanzoai/cloud/apps/index"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/projects"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
@@ -218,19 +219,27 @@ func build(b cloud.Base) (state, error) {
// routes registers the lens.
//
// Bridge FIRST, on the subsystem's own prefix and BEFORE the leaf: fiber runs
// middleware in registration order, so one installed after its route never runs.
// A typed op receives only a context, so the validated org reaches it by being
// parked there — never as an In field, which is caller-supplied and would be a
// cross-tenant read the caller asserted for itself. Serve installs one app-wide
// too; nesting is harmless, and this package's own tests mount on a bare app with
// no Serve, so this install is what makes them pass.
// Bridge FIRST and BEFORE the leaf: fiber runs middleware in registration order,
// so one installed after its route never runs. A typed op receives only a
// context, so the validated org reaches it by being parked there — never as an
// In field, which is caller-supplied and would be a cross-tenant read the caller
// asserted for itself. Serve installs one app-wide too; nesting is harmless, and
// this package's own tests mount on a bare app with no Serve, so this install is
// what makes them pass.
//
// The op is declared on the App with its WHOLE path, not on the group with an
// empty leaf: joining "/v1/catalog" with "" yields "/v1/catalog/", a different
// path from the one this API has always served.
// USE, NOT A MIDDLEWARE-CARRYING GROUP — the same correction as auditlog, for
// the same reason. This used to say `app.Group("/v1/catalog", Bridge())`, but
// the op below is declared on the App with its WHOLE path, so no route sits
// beneath that group; middleware over an empty subtree can never run, and zip
// refuses to compose it. Use is the ONE composition verb, and it means the right
// thing through both routers: cloud's scope gates it to this subsystem's
// declared subtrees, and a bare *zip.App treats root middleware as always-live.
//
// The op keeps its WHOLE path rather than moving to a group with an empty leaf:
// joining "/v1/catalog" with "" yields "/v1/catalog/", a different path from the
// one this API has always served, and one that would ship in OpenAPI and the SDK.
func routes(app cloud.Router, s *cloud.Service[state]) {
app.Group("/v1/catalog", cloud.Bridge())
app.Use(zip.H(cloud.Bridge()))
o := ops{s: s}
zip.Get(cloud.ZipApp(app), "/v1/catalog", o.browse)
}
@@ -269,9 +278,6 @@ func loop(b cloud.Base) {
//
// Example: {"origin":"template","language":"typescript","forkable":"true","limit":"20"}
func (o ops) browse(ctx context.Context, in *browseQuery) (*catalogPage, error) {
if !index.Ready() {
return nil, zip.Errorf(http.StatusServiceUnavailable, "catalog: index not mounted")
}
q := strings.TrimSpace(in.Q)
rows, err := read(ctx, PublicOrg, q, "public")
if err != nil {
@@ -298,7 +304,7 @@ func (o ops) browse(ctx context.Context, in *browseQuery) (*catalogPage, error)
// read pulls one corpus out of the index and stamps its scope.
func read(ctx context.Context, org, q, scope string) ([]Entry, error) {
raw, err := index.Query(ctx, org, uid, q, scan, 0)
raw, err := lexical(ctx, org, q)
if err != nil {
return nil, zip.Errorf(http.StatusInternalServerError, "catalog: %v", err)
}
@@ -314,6 +320,52 @@ func read(ctx context.Context, org, q, scope string) ([]Entry, error) {
return out, nil
}
// lexical reads the corpus out of the index, wherever the index happens to be.
//
// IN-PROCESS FIRST, then the plane. Both legs are real: a fused binary that
// mounted both apps has the index right here and a call over the wire would be
// a pointless hop, while the deployed fleet runs one process per app and the
// in-process global is nil for good.
//
// This used to be `index.Query` alone, guarded by `index.Ready()` — and since
// Ready() answers "is the index in THIS binary", the guard was false forever
// once catalog and index became separate plugin rows. Every /v1/catalog request
// answered 503 "index not mounted", which is what hanzo.app's Community page
// rendered as "ERROR: CATALOG: 503": a page that drew perfectly and listed
// nothing, on a fleet where nothing was actually down.
func lexical(ctx context.Context, org, q string) ([]json.RawMessage, error) {
if index.Ready() {
return index.Query(ctx, org, uid, q, scan, 0)
}
// WHICH TENANT THE CALL IS MADE FOR, and why it is not For().
//
// A typed handler's ctx carries the in-flight request, and zip's
// forwardIdentity says an inbound request ALWAYS wins over a stated caller —
// so For() is silently ignored here and the call goes out as whoever asked.
// For an anonymous visitor that is nobody, which is exactly what shipped:
// 500 "index: no org on the call" on the public browse.
//
// As() re-points the tenant on a context with NO request behind it, which is
// the one place zip reads what we stated. The caller's authority still
// travels whole; only the tenant is re-pointed — which is the whole point,
// because the published corpus is read as PublicOrg by everyone, signed in
// or not.
call := cloud.For(ctx, org)
if c, ok := cloud.Request(ctx); ok {
call = cloud.As(c, org)
}
out, err := cloud.Ask[plane.IndexQueryIn, plane.IndexQueryOut](
call, "index", plane.IndexQuery,
&plane.IndexQueryIn{UID: uid, Q: q, Limit: scan})
if err != nil {
return nil, err
}
if out == nil {
return nil, nil
}
return out.Rows, nil
}
// filter applies the exact-match browse axes. An absent param is not a filter.
// Every dimension `facet` counts is filterable here and vice versa: a facet a
// caller can see but cannot act on is a rail that lies about being clickable.
+1 -1
View File
@@ -46,7 +46,7 @@ func do(t *testing.T, app *zip.App, method, url, body string, hdr map[string]str
for k, v := range hdr {
r.Header.Set(k, v)
}
resp, err := app.Fiber().Test(r)
resp, err := app.Test(r)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
+1 -1
View File
@@ -110,7 +110,7 @@ func doReq(t *testing.T, e *testEnv, method, path, org string, admin bool, body
if admin {
rq.Header.Set("X-User-IsOrgAdmin", "true")
}
resp, err := e.app.Fiber().Test(rq)
resp, err := e.app.Test(rq)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
+1 -1
View File
@@ -149,7 +149,7 @@ func TestSendKeepsItsCapAndItsStrictness(t *testing.T) {
rq.Header.Set("Content-Type", "application/json")
rq.Header.Set("X-Org-Id", "acme")
rq.Header.Set("X-User-Id", "u-acme")
resp, err := e.app.Fiber().Test(rq)
resp, err := e.app.Test(rq)
if err != nil {
t.Fatalf("Test: %v", err)
}

Some files were not shown because too many files have changed in this diff Show More