Compare commits

...
369 Commits
Author SHA1 Message Date
antje 67975187d2 lsp: live code intelligence over a repo and its resolved deps
POST /v1/lsp — one door, because there is one value: a language server
rooted at a workspace, asked about a position. code (static index) and
lsp (live server) are two reads of the ONE checkout.

  {repo, rev?, path, line, character, method} → hover | definition |
  references | typeDefinition | implementation | documentSymbol |
  completion | diagnostics

Positions are the LSP's and pass through untouched: 0-based line, 0-based
UTF-16 character. Re-basing them would corrupt every multi-byte line, and
the callers already speak LSP.

Isolation is structural, not checked. The org comes from the validated
principal and is BOTH the pool key and the OWNER SEGMENT of the git URL;
a caller supplies only a repo slug. There is no input from which one
tenant can name another tenant's repository.

Scripts-off by default. A dependency fetch that runs dependency-authored
code is RCE triggered by whatever the caller asked us to check out, and it
buys nothing — servers resolve from source. So: npm ci --ignore-scripts,
cargo fetch (not build), go mod download; the python fetch builds sdists
and therefore does not run. rust-analyzer is additionally told not to run
build.rs or expand proc macros, because otherwise the server does at load
time exactly what the fetch was chosen to avoid. One predicate, one place.
The deployed worker must still be sandboxed — see fetchable's comment.

Language table ported verbatim from hanzo-tools-lsp (same binaries, same
argv, same root markers); install_cmd deliberately dropped — a worker that
can npm install -g at request time is one an attacker can make write to
its own filesystem.

server.go is the testable core: Content-Length JSON-RPC with a SINGLE
reader goroutine demultiplexing responses, server→client requests and
notifications. The python tool reads inline from each call, which drops
every frame that is not the awaited response — which is why it cannot
report diagnostics, and why an unanswered client/registerCapability
deadlocks it. 24 tests drive a fake server over in-process pipes; no
toolchain, no network.

Two bugs the tests found, both real in production:
  - Close() wrote a polite shutdown unconditionally, so a server that had
    stopped reading its stdin blocked it forever — holding a pool slot and,
    at Shutdown, the whole binary.
  - rel() compared a symlink-resolved root against an unresolved target, so
    any data dir with a symlink component (/var on a Mac, a mounted volume)
    made every location "outside" the checkout and handed the caller the
    worker's absolute path instead of a repo-relative one.

Metered, not Free: cost is the COLD start (checkout + fetch + first index),
not the query. Warm point queries are recorded and free, so the pricing
does not teach callers to re-key their workspace. Gate before the work.

apps/code has NO checkout to reuse — it indexes files POSTed to it and says
so. apps/deploy has the only working-tree checkout and is not importable
(it would link k8s into this binary). This is therefore a second one,
following deploy's invocation and hardened env exactly; the fix is hoisting
it into the root cloud package, not made here.

Mount order: apps.Wire() and its integers are gone. manifest.Apps slice
position IS the order, so lsp's row sits after code's, with order_test's
frozen sequence updated in the same commit.
2026-08-05 08:16:59 -07:00
antje 35686f3462 ai: say why deep_research is unwired — it is money, not plumbing
The comment blamed apps/answer for exposing no constructor. That is true and
it is not the reason.

Research carries an explicit 25-cent per-answer fee (apps/answer/mode.go),
charged through Bill.Gate on the request path where a payer has been resolved
and can be refused. A tool call has no payer, so a direct seam to the engine
would be an unbilled 25-cent operation an agent may invoke in a loop — free
inference, reached by the exact route this codebase keeps closing.

That the package makes it awkward is not an accident to route around: Params is
built from request-scoped billing context and Sink's methods are unexported, so
the money gate is structurally hard to bypass. Wiring it properly means an entry
that takes a payer and charges it — a billing decision, not an adapter.

web_search and fetch_url are different in KIND, not merely cheaper: their HTTP
routes gate on AUTHENTICATION, and the agent request reaching the tool was
already authenticated and metered at /v1/responses. In-process use matches how
they are reached over HTTP. deep_research does not.
2026-08-05 04:37:53 -07:00
antje 7921911f93 commerce: the peer-ledger test binds its socket inside the address cap
A unix socket address is capped near a hundred bytes, and t.TempDir embeds
the test's own long name — on darwin the bind failed on a discarded goroutine
error and every dial refused, so the suite reported a phantom (socket never
began listening) instead of the truth. A short anonymous dir keeps the
address inside the cap on every platform; on a Mac without a mounted tmpfs
the tests now fail for the honest reason instead — the pure-Go SQLCipher
codec refuses to decrypt to persistent storage, which is the fail-closed
property it exists for.
2026-08-05 01:53:59 -07:00
antje 628e510e75 commerce: the health probe becomes a typed op, and every survivor names why it stays raw
The probe was a closure marshalling a map — a route and nothing else, in none
of the five projections. It is now a typed op whose answer is byte-identical
to the map it replaces (the struct's field order mirrors the map's sorted
keys), pinned by test, and whose prose rides the handler's doc comment the
way every typed op's does — so its openapi.Describe entry is gone.

Every other raw registration now states its survival in one sentence. The
webhook intake speaks the provider's protocol (HMAC over the raw payload),
the invoice PDF serves bytes, the fail-closed wildcard must shadow every
method under every prefix, and the rest obey the module-handler rule, stated
once above Mount: a handler living in hanzoai/commerce behind unexported
internals cannot ride a typed op, and a typed twin would be a second
implementation of the same money move — the drift payments.go exists to
prevent. The typed door onto a module rail is that file's exported-core
pattern, which is module work.

The eight operations that said nothing about themselves — wire, the crypto
rail, and the saved-card family — now state their gate, their tenant scope
and what they fail closed on, which is what let the commerce subset
regenerate at all. That regeneration also repaired two stale artifacts the
committed subset carried: GET /v1/billing/methods claimed its portal
sibling's operationId (the duplicate the weave gate died on), and the two
saved-card POSTs were absent outright.

The fleet golden and the floor now agree with the module: the deposit proxy
and the webhook relay were removed upstream (deposits are commerce's own
rails; the real receiver is /v1/billing/webhooks/:provider), so the golden
drops those four operations, the commerce floor is hand-lowered to four, and
the dead prose goes with them.
2026-08-05 01:53:59 -07:00
zeekayandhanzo-dev 21652f34be a rail that can be switched off says so
CI/CD / image (push) Successful in 18m11s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m20s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
commerce v1.50.7 added PUT /_/commerce/providers/{name} — the verb behind "a payment
rail can be turned on and off" — and the dep bump landed without prose for it, so
`commerce describe` refused and the app could not project its own document. The
subset is fail-closed, so nothing was written and the drift stayed invisible until
the next regeneration asked.

It gets the sentence, and the same gate its GET twin states: the tenant comes from
the IAM owner claim and nowhere else, so a cross-tenant write is not expressible;
404 for an unknown provider is byte-identical to the cross-tenant probe's answer. The
one thing worth saying that the shape does not: this owns a single bit and never the
credential, which is why a rail can come back with the same stored secret.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:45:40 -07:00
zeekayandhanzo-dev d99e445f2c the tag script is a sibling segment, not a child
analytics' subset caught up in the regeneration and started publishing GET
/v1/event.js — an address the fleet then routed to ai, because a prefix owns a
SEGMENT subtree and "/v1/event" does not cover "/v1/event.js". It fell through to
ai's "/v1" and the router oracle said so: the fleet published a path it delivers
somewhere else.

The address is analytics' own — its binary projected it from its own router. So it
gets its own entry, which is what "deeper than the sibling that currently wins"
means for a path whose distinguishing character is a dot.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:42:27 -07:00
zeekayandhanzo-dev d2cfd1e54c a subset is generated, or it is wrong
The weave was red on main and nothing downstream could move: openapi/weave_test.go
is the SOLE writer of openapi.yaml, so the served document, the CLI, every SDK, MCP
and the docs were all frozen behind

  operationId "get_v1_billing_portal_methods" is claimed by both
  "GET /v1/billing/methods" and "GET /v1/billing/portal/methods"

The derivation was never at fault. operationID(method, path) walks every segment and
yields two distinct ids for those two paths, and it has verified uniqueness inside
From since 2026-07-27. The subset that claimed otherwise was written on 2026-08-04 —
so no run of `commerce describe` at any version could have emitted it. It was
hand-edited: d7024e88 moved the three saved-card verbs to this app and wrote the two
new paths into plugin/commerce/openapi.json by copying the /v1/billing/portal/methods
block, operationId and prose together. The copied description then described itself
("the SERVICE-TOKEN face of the same list a customer reads at /v1/billing/methods",
published AT /v1/billing/methods).

It was hand-edited because it could not be generated: `commerce describe` refused,
and still refused here, with eight operations saying nothing about themselves. So the
fix is the prose, and the file follows from it.

  - Eight operations get the sentence they owe a caller: the customer saved-card
    family (GET/POST/DELETE /v1/billing/methods), its portal POST, and the top-up
    rails nobody had described at all — wire instructions, crypto options, the
    deposit mint and the deposit poll. Five of them were not in the published
    document in any form.
  - The two portal twins stop claiming a proxy that no longer exists. Both families
    are served in this process; they are two addresses because they admit two
    principals, not because either forwards to the other (apps/billing/billing.go
    says the same at the spot the hop used to sit).
  - mount.go said the opposite of the manifest — that /v1/billing/methods belongs to
    billing and "a registration here is unreachable in the fleet", beside a live
    registration of it. manifest.Apps gives commerce both prefixes and withholds them
    from billing. Left alone, that comment invites deleting a route that works.
  - /v1/commerce/{deposits,deposits/:id/confirm,deposits/:id/status,webhooks/:provider}
    lose their prose. The broker-dealer proxy behind them was deleted and the manifest
    stopped naming them; what was left was prose for routes nothing serves, which
    renders nowhere and reads in source as though it were live. openapi/floor.json
    drops commerce 8 → 4 in the same commit, which is where the ratchet asks for the
    reason to be.

AND THE ASSUMPTION THAT LET IT LAND IS NOW A CHECK. Weave did refuse the document —
but a collision inside ONE part reaches it with no app attached, so it could name the
two addresses and nothing else, and which of 123 subsets shipped them was a search.
openapi.Subsets is holding the app's name when it decodes the bytes, so it asks there
whether the part is injective — the same uniqueOperationIDs, one statement of the
rule, asked once per part and once over the composition, because neither fact implies
the other. TestSubsetsRefusesAnAppWhoseOwnIDsCollide fails without it.

Regenerating also lands 13 subsets that had gone stale behind the red gate, and drops
zipdoc entries that were lifting commercemid.RequestContext's doc comment — a
paragraph about mint-gated context locals — as eight routes' descriptions.

plugin/iam is deliberately NOT regenerated: its current projection introduces a
schema "Role" that means something different from framework's ({role, user}), which
Weave refuses and which needs a rename in one of those two apps. That is a separate
defect this one merely uncovered.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:42:27 -07:00
antje 0510f55c00 console pin → 8.5.50: the assistant sends its credential
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
Carries the P0 fix a live-browser pass proved: the assistant's streamed
completion posted with no Authorization header, was refused, and the card
then blamed the user's session. The release routes every self-reading
stream through the client's one authorized door, moves preferences onto
cloud's /v1/prefs, tells the truth on a 401, and mounts exactly one
composer per viewport.
2026-08-05 01:42:12 -07:00
antje 8325da2a60 ai: close the web seam — every Responses-API agent can search
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
ai v1.832.30 declares web_search / fetch_url / deep_research but holds no
backend for the two this host serves. agent/builtin_tool/web must stay a leaf
package (object imports agent, agent imports the registry), and websearch lives
here in any case — so this is where the seam closes, beside the balance and tier
readers, for the same reason they are here: this package links both sides and
the host does not.

In-process, never over api.hanzo.ai. The edge validates a CUSTOMER credential
and answers 401 to a service; routing our own calls back through it is what once
fail-closed every completion at 503 on a perfectly healthy pod.

deep_research is deliberately NOT installed yet. apps/answer builds its Params
from unexported fields and exposes no constructor, so wiring it means giving
that package an entry point rather than reaching into it from here. Until then
the tool reports that it is UNAVAILABLE IN THIS DEPLOYMENT — which is the honest
answer and specifically not an empty result: an agent told "no results"
concludes the web holds nothing on the subject and answers from memory in a
confident voice.

fetch_url needs nothing here — ai installs its own crawl at bootstrap.
2026-08-05 01:40:34 -07:00
hanzo-dev 1720eb20b5 analytics: the hosted tag runs the one identity chain instead of a third copy
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m52s
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/event.js resolved the anonymous id itself, out of localStorage alone. That is
ORIGIN-scoped, so it never saw the cookie @hanzo/event shares across *.hanzo.ai
and never saw the id hz.js had already left behind under its own key: an origin
carrying only this tag was a separate population, and one visitor was two or
three people depending on which snippet a surface happened to load.

anon.js is that chain, vendored BYTE-FOR-BYTE from @hanzo/event
(hanzoai/ui pkgs/event/src/anon.js), and tag.go now serves it with the tag as one
asset inside one wrapper — so the door holds no second implementation, and
neither half leaves a name on the page it is pasted into. tag.js calls hzAnonId
and no longer names an identity key at all.

Resolution is cookie · localStorage hz_anon_id · localStorage hz_id · in-memory ·
mint: every id already in a browser is adopted, and only a browser holding none
is given a new one.

TestTagBehavior now runs the COMPOSED asset rather than tag.js, because the chain
is half of what ships and the other half no longer runs alone.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:31:20 -07:00
zeekayandhanzo-dev f94c232ed2 deps: commerce v1.50.7 — the wire rail reads the address the host writes
CI/CD / image (push) Successful in 19m15s
CI/CD / gate (push) Successful in 1m29s
CI/CD / containment (push) Successful in 1m33s
Hanzo CI/CD / cicd (push) Successful in 1m28s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
The bank details were stored and the rail still answered 'Wire transfer not
configured'. Both doors reach the same KMS store keyed by (path, name, env), so
a read at a path nothing writes is indistinguishable from a bank nobody entered
— it would have stayed silent indefinitely.

cloud writes every in-process secret under /orgs/{org} (apps/destinations,
apps/integrations, credz all build that shape, and the REST surface folds writes
under it from the validated org claim). commerce alone spelled it
/tenants/hanzo/wire. v1.50.7 adopts the host's convention and drops the
hardcoded org: the brand serving the page now decides whose bank is read.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:05:12 -07:00
antje e87012d765 answer: the model may choose a result's SHAPE, not its markup
CI/CD / image (push) Failing after 33m35s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m22s
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 12s
The extension renders structured result widgets (comparison, steps, stats,
timeline, entity, definition) but nothing emitted them: /v1/ask's stream is
status|sources|text|follow_ups|done and the system prompts never mentioned the
format. The client was ready and the producer was missing.

widgetRule teaches the one format both modes share, so search and research
cannot drift apart. It says three things beyond the schema:

- Only when the question's SHAPE calls for one, at most two, most answers none.
  A widget on every answer is a worse document, not a better one.
- The PROSE MUST STAND ALONE. The client validates every block and drops
  anything malformed — a ragged table, an unknown kind, a field of the wrong
  type — keeping the prose. An answer that leaned on a widget to be complete
  would read as a hole exactly when validation refused one.
- DATA ONLY, NEVER HTML. This model reads the open web, so every page it
  fetches is a potential injection source. Markup it authored would be a path
  into the extension's origin, which holds the user's session. The renderer
  owns the shapes and escapes every field; the model owns only content.

widget_rule_test.go is the closest thing to a shared type across the two repos.
The renderer lives in the extension, so a typo here fails nothing: it produces
answers whose widgets silently never appear, which looks exactly like a model
choosing not to emit one. The test parses every example out of the prompt and
holds it to the shape the client accepts — one example per kind, every required
field present, the comparison example rectangular (the client drops ragged
tables rather than padding them), and both modes carrying the rule.

Negative control run: renaming the steps example's field to "items" fails with
`kind "steps" example omits required field "steps"`.

Pre-existing and unrelated: the root package does not build right now
(middleware_spend.go, another session's in-flight work). apps/answer builds and
tests clean.
2026-08-05 00:56:04 -07:00
zooqueenandhanzo-dev 11a6ce27a4 release: a KMS path has no org in it, and the guard that said so never ran
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
No release has pinned since v1.801.454. .455 and .457 built, published, and
were never referenced by anything — the fleet has been serving .454 while
three versions sat in the registry.

The rollout car reads UNIVERSE_PIN_TOKEN from
/v1/kms/orgs/<org>/secrets/deploy/... and the broker has no such route. The
store root is derived from the validated claim, which is exactly what makes
another tenant's secret unnameable rather than merely refused, so there is no
org in a KMS path and never was. Measured against kms.hanzo.ai:

  /v1/kms/auth/login                                 401  (route exists)
  /v1/kms/orgs/hanzo/secrets/deploy/UNIVERSE_PIN_TOKEN  404
  /v1/kms/secrets/deploy/UNIVERSE_PIN_TOKEN             403  (route exists)

fanout reads FLEET_DISPATCH_TOKEN through the same wrong shape and would have
failed the same way the moment rollout stopped failing first.

WHY IT WAS INVISIBLE, WHICH IS THE HALF WORTH FIXING. The step is
`set -euo pipefail` and the read is `TOKEN=$(curl -fsS ... | jq ...)`. curl
-f exits 22 on the 404, pipefail propagates it, set -e aborts the assignment —
so the authored `::error::UNIVERSE_PIN_TOKEN missing in KMS ...` is
unreachable code. Every one of these releases died with a bare `exitcode
'22': failure` and never printed the sentence written to explain it. Three
guards in this file were dead the same way; the two KMS logins are fixed here
too, and `receipt`'s create-or-update on a tag with no release yet is the
third (left alone — it is a different car and a different bug).

A guard that cannot run is worse than no guard: it reads as diagnosis
already handled.

KMS_ORG goes with the paths. The org is the credential's, so a variable that
named it was a knob that decided nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:42:42 -07:00
zooqueenandhanzo-dev 74b743065b catalog: ask projects too — the site half was reaching across the same gap
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 corpus has two sources and both were in-process globals the catalog
process cannot see. The index write was one (previous commit). This is the
other, and it failed more quietly still.

projects.LiveSites answers nil when its package is unmounted, on the stated
reasoning that a deployment which hosts no sites is not an error. That is
true of a DEPLOYMENT and false of a PROCESS: catalog, index and projects are
three separate apps and therefore three separate processes, so in the
catalog process nil never meant "nothing is serving" — it meant "you asked
the wrong half of the fleet". nil and empty are the same answer, so the
corpus was published with no sites in it and nothing anywhere said so.

What that costs is the whole `site` kind: every demo URL, the lineage that
files a remix under community instead of leaving it looking like one of our
starters, and the deployed demos the template lane is mostly made of. The
repos alone would have made a catalog of source with nothing live in it.

  plane      sites_live, and it takes no org — the same shape as
             sites_resolve beside it. This is THE cross-org read; the rule
             that makes it safe (public, live, not hidden) is applied in the
             query by the app that owns the store, so there is no tenant here
             for a caller to widen into.
  projects   Ready(), so a caller can finally tell "nothing is serving" from
             "ask the process that owns the store" — the distinction
             LiveSites cannot make, because it answers nil for both.
  catalog    serving(), the third seam with the same two legs as lexical and
             write.

Same test file, same reason: the split is what production runs, so the test
reaches a real peer over a real socket.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:36:56 -07:00
hanzo-dev 5baf8ca104 risk: the credit door is screened by the model, over the plane
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.SetRiskScorer has held the seam and the whole fail policy since it was
written and has never had a producer. It could not have one: the model is
in-process mutable state, so exactly one binary may hold it, and the pod forks
one process per app. Every gate in the fleet therefore read a nil scorer, took
the absent exemption, and allowed unscored — fleet-wide, silently. The
observability plane's event door was the same shape and learned it the
expensive way; obsevents.go is gone and apps/o11y/obs_rpc.go replaced it.

So the scorer is REACHED rather than linked. apps/risk publishes risk_decide on
its own socket, commerce installs a plane client as the seam's first producer,
and POST /v1/billing/topup/token — the one self-serve credit door, whose mint
authority is a settled card charge — asks it before the charge.

The tenant is minted from the PLANE CALLER, never from the body. RiskDecideIn
carries no org and cannot: a model is trained on one organisation's own
behaviour, so naming which model answers would be the only cross-tenant read
this plane has to offer. The HTTP mint (tenantOf) reads a principal parked on a
request, which a plane call does not have, so it would fail closed on every
call; planeTenant qualifies cloud.Who(ctx).Org with the deployment's own brand
instead, and qualify() refuses an empty org.

The gate states Privileged explicitly. cloud.Privileged() matches IAM and KMS
paths and not this one, so the default would be the fail-OPEN branch — a scorer
outage waving through the one route that mints spendable balance. With the bit
set, a scorer that is present and cannot answer refuses and the caller retries;
a scorer that is NOT DEPLOYED still allows, which is the exemption cloud's fail
policy already carries and the plane client is careful to preserve: over a
socket "not deployed" would otherwise arrive as a failed call and take the
closed branch, so the socket is probed, ErrNoPeer is read as absence, and the
lazy child is woken off the request path rather than inside a 150ms budget.

A REFUSAL CARRIES NO SCORE. The engine assigns one before it checks whether the
model has warmed, so a declining model returns a populated, meaningless number;
publishing it would turn "no opinion" into "this is fine". And an alert is a
REVIEW, never a block: cloud's own vocabulary says a statistical judgement may
reach review and no further on its own, and this model is exactly that.

IT SHIPS IN SHADOW. No org is armed, no regime is changed, and a model nobody
has reviewed is in shadow — where alert is forced false however high the score.
So every legitimate top-up proceeds today and every decision is on the record
with the shape and policy version that produced it. The subject kinds move to
the call contract for the same reason the signal names are there: two spellings
of "account" would not read as a disagreement, they would namespace one subject
into two.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:30:47 -07:00
zooqueenandhanzo-dev a8b5bd5337 catalog: tell the index, because the write never left this process
CI/CD / image (push) Failing after 26m11s
CI/CD / gate (push) Successful in 12s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / containment (push) Successful in 1m31s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The published catalog has been empty since catalog and index became two
plugin rows, and not because a sync failed to run. It ran every hour, read
GitHub and the sites table correctly, assembled the whole corpus — and then
handed it to index.Reconcile, which serves out of the index's own
process-level global. In the catalog process that global is nil and always
will be. So the corpus was never written a single time.

The READ leg was given a plane op when the split happened; the write leg was
deliberately left in-process, on the reasoning that the store has one writer
and it lives where the file lives. That property is right and the conclusion
was wrong: the writer is still one and still the index's, whether the corpus
reaches it through a function call or a socket. Handing it over does not add
a second writer — a second process OPENING that SQLite would, and neither
leg does.

Fixing the read alone therefore could not have shown a row. It turned a
503 into 200 {"data":[],"total":0}: from a page that said it was broken to
one that said the fleet had built nothing. hanzo.app's /community and
/templates have rendered that empty ever since.

  plane      index_reconcile, the mirror of index_query, with the corpus
             relayed verbatim for the same reason the read's rows are raw.
  index      publishes it beside the read; the swap still executes here,
             in the process that owns the file. query_rpc.go -> rpc.go, the
             name the other multi-op peers already use.
  catalog    write(), the mirror of lexical(): in-process first, then the
             plane. Both legs now go through the GENERATED index client, so
             the app name, the op and the In/Out pair are fixed to each
             other by the compiler instead of at run time.

The suite could not have caught this: every existing catalog test mounts the
index and the lens on ONE app, which is the topology this deployment stopped
having — index.Ready() is true there, so the plane leg was never executed by
a test at all. split_test.go models the split instead and reaches a real peer
over a real socket. It fails on the old code with "index: not mounted".

A silent write failure outlives a loud read failure, and it is the harder one
to see: an error names itself, an empty page looks like an answer.

Also commits plane/team, generator output for an op added without a
regenerate in c8b7c31.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:28:38 -07:00
zeekayandhanzo-dev d0e8867117 deps: commerce v1.50.5 — a payment rail can be turned on and off
CI/CD / image (push) Failing after 29m59s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m30s
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 12s
The provider list that decides which rails pay.hanzo.ai offers was readable and
not writable, so card, Apple Pay, Google Pay, Cash App, ACH, wire and crypto were
whatever happened to be in the tenant row and moving one meant editing the
database. v1.50.5 adds the write verb, one rail per call — a whole-list PUT built
from what the admin read projection exposes would write every provider back with
an empty KMS path and silently disconnect all of them from their credentials.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:57:00 -07:00
zooqueenandhanzo-dev 8b89c877b7 commands: the ⌘K bar is the route table's fifth projection
CI/CD / image (push) Successful in 23m6s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m14s
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / rollout (push) Failing after 8s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
GET /v1/commands serves zip.CommandsFromSpec over the rendered document —
the same function the CLI's tree is derived from, on the same bytes every
SDK is generated from. No new registry, no generator, no build step: a
route registered this morning is a command this afternoon.

serve() gains one line, so both document sources light up at once — Mount
for an app binary, MountFleet for the front door that answers api.hanzo.ai.

Total, and unfiltered by caller on purpose. zip has no per-op scope; it has
Authorizer, which decides on the DECODED INPUT of every op. Permission is a
fact about an input, so any list filtered here would be a second static
claim the Authorizer is free to contradict — wrong in the direction that
hides working functionality from people who have access.

Two things the design did not know, both measured here:

  The size. The projection is 2,344,651 bytes, 454,881 gzipped — 1.32x
  smaller than the document, not the 4.5x/107 KB the design quotes. That
  figure was taken from hanzoai/cli's spec/products.json, a different
  artifact with every description, summary and operationId stripped. This
  one keeps the prose, and the prose is most of it (Description alone is
  1,028,067 bytes). Recorded in command.go rather than fixed by forking
  zip.Command into a trimmed wire shape.

  The order. 41 of the 2,323 commands share zip's (Service, Name) sort key
  — `mq streams-delete` is claimed by three — and sortCommands is unstable,
  so the tie fell to a map walk. Two replicas weaving one document served
  identical content under different ETags, which is a full re-download on
  every conditional request that lands on a different pod. order() completes
  the key with (Method, Path).

openapi.Door names what serve registers. Three gates needed exactly that
fact and each had written the one literal that was true when it was written
— Complete skipping a description no app can own, cmd/cloud exempting the
doors from a scoped deployment's surface, manifest refusing an app row that
claims one. The second door made all three wrong the same afternoon.

Pins: every served command is a route the document carries; the served
bytes are CommandsFromSpec of openapi.yaml exactly; two mounts of one
document agree on bytes AND ETag; a conditional request gets 304 and no
body; the projection is smaller than what it projects. Plus the door itself
answering on the host's own spec() mount.

TestFleetIsTheWeaveOfItsApps and two cmd/cloud gates are red on this tree
already, over a duplicate operationId between GET /v1/billing/methods and
GET /v1/billing/portal/methods. Not touched, not absorbed: openapi.yaml
therefore does not yet carry /v1/commands, and the full-fleet version of
the door test belongs in the commit that fixes the weave.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:42:56 -07:00
zeekay ab7c3d0e6b Merge remote-tracking branch 'forge/main'
CI/CD / image (push) Successful in 20m47s
CI/CD / gate (push) Successful in 13s
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / containment (push) Successful in 1m35s
CI/CD / rollout (push) Failing after 7s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:26:47 -07:00
zeekayandhanzo-dev a94b25870a publish the commit to github before claiming a tag on it
The image job reserves a version by creating refs/tags/v<N> AT THE COMMIT on
github.com. A ref can only point at an object that is there, so for a commit
github has never seen the claim answers 404 — 'Object does not exist' — and
refuses to build.

It routinely has not seen it. CI runs on git.hanzo.ai, which is canonical and
where the push lands; github is fed by a push mirror on an EIGHT-HOUR interval,
and the claim runs seconds after the push. The object the tag must name is
normally hours away, so the failure is not a permissions problem that looks like
a race — it is a race that looks like a permissions problem, and it is why four
days of releases stacked up behind one commit.

The commit is now published to github before the claim reads it, on a ref of its
own: main is the mirror's to move and the two lineages do diverge, while this
step's only job is to make the object exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:26:44 -07:00
antje 266e578e83 zip v1.25.1 — zero OTel packages behind the framework
Hanzo CI/CD / cicd (push) Successful in 15s
CI/CD / gate (push) Successful in 15s
CI/CD / containment (push) Successful in 1m41s
CI/CD / image (push) Failing after 16s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
metric v1.10.0 moved the OTel bridge out of its root package, and zip took
the bump, so the framework and everything that inherits it now link no
OpenTelemetry SDK at all. Cloud's own meter pipeline still imports the SDK
directly and deliberately — it feeds the status page until zip's rows are
proven where the gauges read, and it is deleted with that proof, not before.
2026-08-04 22:08:05 -07:00
zeekayandhanzo-dev 49b3651596 Merge remote-tracking branch 'forge/main'
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m42s
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:
#	Dockerfile

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:02:47 -07:00
zeekayandhanzo-dev a6d2d94665 not signed in is 401, and the billing app stops publishing an address it does not serve
Moving /v1/billing/methods in-process (it had been forwarded through a base URL
this deployment never sets, so a signed-in customer got 401 listing their own
cards) quietly changed what an ANONYMOUS caller gets: the proxy answered 401 and
the co-resident chain answers 403, because PinBillingSubject refused both of its
cases with ErrForbidden.

Those two cases are different answers and must not share a status. A service
token is a credential: presenting one and omitting X-Org-Id is an authenticated
request that names no scope, and 403 is right. Presenting nothing is not signed
in. The difference is load-bearing on the customer path — a browser
re-authenticates on 401 and merely reports 403 — so an expired session on the
saved-cards screen showed a permission error instead of sending the customer to
sign in. The billing app it moved from answered 401 on purpose; its test said so
in as many words, and that test was deleted with the proxy.

So the tests move with the route, which is how this surfaced at all:

  apps/commerce gains the assertion the billing app used to carry — anonymous
  GET and POST answer 401, and a 404 fails loudly rather than passing as
  "refused", since an unmounted route also declines every request.

  apps/billing loses seven tests that pinned a proxy that no longer exists, the
  three handlers behind them (paymentMethods, createPaymentMethod,
  deletePaymentMethod — defined, registered nowhere, dead since the move), and
  an openapi.Describe for DELETE /v1/billing/methods/{id}, an address this app
  published and did not serve. That last one is the exact publish/serve
  disagreement manifest.Apps exists to catch.

Also merges the GitHub lineage, which had diverged: 4 commits there (console
embed pinned by semver, platform forge sibling, the ai subscriber-scoring fix)
against 19 here. The version claim mints its tag on github.com, so a commit that
only ever reached the forge cannot be claimed — which is what the image job hit
after the containment fix let it run at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:01:31 -07:00
hanzo-dev c8b7c310da team: one identity seam, IAM lane beside the HS256 arm
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m10s
CI/CD / image (push) Failing after 15s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every team surface resolved its caller by decoding an HS256 token itself, so
"who is calling" was answered in six places against one signing key. This adds
identity (apps/team/account.go): ONE seam that resolves a caller, and the only
place a credential's algorithm is routed on. account, typed, files, billing,
collab and the transactor hold the seam instead of the secret, and three of them
stop importing the condemned package — account.go is the only reader left inside
apps/team.

Verification, tenancy and authorization are three questions, answered separately:

  - VERIFICATION is cloud's own IAM validator, narrowed. A signature from a
    trusted issuer says IAM minted the token, never that it was minted FOR this
    surface — IAM's signer emits the same claims into an access token and an
    id_token but for aud/tokenType/nonce. A session door must say which it
    means, so the lane takes access tokens whose audience this deployment NAMES.
    The boundary's no-audience-gate posture is right for an API door and wrong
    here; the divergence is stated at the pin.
  - TENANCY is the home org from the signed membership set, never `owner`.
    `owner` carries the application's org, so it is chosen by whichever app the
    caller authenticated through, and a lane reading it scopes every store query
    to an org the caller selected. No membership set means no home, which is
    also every machine credential — a team session is a person's.
  - IDENTITY is the `sub` claim, resolved through the store. The canonical user
    id falls back to preferred_username, and an account id derives from a UUID
    verbatim, so a token with no sub whose username is a colleague's account uuid
    resolved to the colleague. Subject-only, confirmed against the rows a login
    created.

An IAM credential never leaves the seam: it is an estate-wide bearer held in an
HttpOnly cookie so page JS cannot read it, and the account RPC echoes a caller's
token back to page JS.

Workspace authorization on the IAM lane is the membership rows (admit) — the
server decides, the caller signs nothing. The transactor keeps its path-borne
workspace token and gains no ambient lane: a WebSocket is exempt from CORS, so a
cookie-borne credential would make the Origin list the data plane's only access
control, and that list no longer carries a wildcard either.

The existing credential answers first on every carrier, so a client that has one
behaves exactly as it did and the new lane serves only a browser holding nothing
else. meet decides a room join and does not own the workspace rows, so team
publishes them on the internal plane and meet asks, off the boundary's own
attestation rather than off headers a client can set. analytics is untouched:
its trust order already resolves a validated IAM bearer ahead of the team token.

The HS256 arm is deleted when login mints IAM-only and front/love/
analytics-collector verify IAM; getWorkspaceInfo is the one surface that still
needs a client change first.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:55:46 -07:00
antje 4c1686e9e1 console pin → sha-f8d8325: reach-first Models, one-lineage main
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m39s
CI/CD / image (push) Failing after 17s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Carries the converged console main: the Recent + Suggested strip on Models,
the topbar slimmed to navigation (environment, theme and alerts fold into
the account menu and drawer; production is the default environment), the
cross-app launcher restored, and the assistant defaulting to Enso. The pin
names the image GHCR serves — probed 200 with a negative control — and the
sha is the forge lineage's, which is the lineage that builds.
2026-08-04 21:54:46 -07:00
antje 12ae6f0dd9 zip v1.25.0 — spans and the request record ship over native ZAP
The framework boundary now exports a server span and a request record per
request to the o11y ears this binary already runs (planesink: 4317 spans,
4318 logs), shaped by zip against the collector's own receivers — no SDK,
nothing any plugin inherits. The address is the switch: O11Y_SPANS_ADDR and
O11Y_LOGS_ADDR turn each signal on, and O11Y_METRICS_ADDR — the name the
manifest already states — is now genuinely read (v1.24.x read a name the
manifest had stopped matching and reached the right ear by loopback
coincidence).
2026-08-04 21:54:46 -07:00
zeekay a7b3ccacfa Merge remote-tracking branch 'origin/main'
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:51:00 -07:00
zeekayandhanzo-dev 5a368bd261 our own modules resolve from our own forge, and the wire rail ships
Hanzo CI/CD / cicd (push) Successful in 10s
CI/CD / gate (push) Successful in 11s
CI/CD / containment (push) Successful in 1m45s
CI/CD / image (push) Failing after 21s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every cloud release since 62590c3 — nine consecutive commits, four days — was
blocked in the `containment` job, and nothing about those commits caused it:

  go: github.com/hanzoai/zen@v1.4.10: invalid version: git ls-remote ...
      remote: Repository not found.

The token was valid and non-empty. hanzoai/zen simply has one collaborator where
its sibling modules — ai, commerce, orm, account — have fifteen, so the build
identity could read every private module in the graph except that one. GitHub
answers 404 for "private and not yours" and for "does not exist" alike, so the
error could not distinguish an ACL from a deletion. An ACL drifting beside the
code stopped the fleet, and the earlier green runs only hid it behind a warm
module cache.

So the build no longer asks GitHub for code that is ours. The module PATH stays
github.com/hanzoai/* — that is the package's name, not its address — while git
dials git.hanzo.ai, which is canonical anyway. go.sum is untouched and still
decides: the forge mirrors the same objects, the zip hashes to the committed h1:
line, and a forge serving different bytes would fail the build instead of
shipping them. Proved cold, with no GitHub credential present at all. GH_PAT
remains the fallback for anything the forge has not mirrored, and the four
modules it was missing (goauthorizenet, namespace, sendgrid-go, commerce) are
mirrored now.

Riding along, because it could not ship until the train moved: commerce v1.50.3
carries the wire rail reading the deployment's bank through the host's secret
plane, the payer reference on the wire memo, and the saved-card and tier guards.
Its work had landed on the lineage the history rewrite replaced, invisible to
the module path everything actually resolves; it is replayed onto the published
history, with thirdparty/square taken from the published side because fitKey()
is applied at all three Square boundaries there and carries tests.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:44:41 -07:00
hanzo-dev 8bdf63c6b9 build(console): pin the embed by semver, not sha
The pin named a sha, then a digest. Both say which bytes built the bundle
but not which console RELEASE it is, so "what console is in cloud
v1.801.N" needed a second lookup, and the last six bumps read like
receipts for nothing.

The builder already publishes both: sha-<sha7>-amd64 on every main push,
and the bare semver on a cut v* tag. v8.5.37 is the tag for console
origin/main's tip.

This SUPERSEDES the digest pinned by #383 rather than reverting it: that
pin shipped the anonymous-visitor entry fix, and v8.5.37 contains that
commit (b1a76fff2a, verified ancestor) plus the toast render-loop fix --
the OAuth return stacked twelve identical cards and now raises one. It is
a strict forward move, not a swap.

Both images were pulled in-cluster with the fleet's own credentials before
this was written, because pinning an image the registry cannot serve has no
rollback path: v8.5.37 is 2383695 bytes, the superseded digest 2375128.

The discipline shifts rather than disappears. A digest cannot be re-pushed
to different bytes; a semver tag can, and :v8.4.118 was. So the rule is now
that a cut tag is never re-pointed -- cut the next patch instead.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:41:58 -07:00
zeekayandhanzo-dev 29ac3d1a96 make compose: ask the kernel for a port instead of owning a namespace
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / gate (push) Successful in 11s
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 recipe handed each app 41000+index*10 and that was accidental complexity.
The question is "does this binary compose". Answering it does not require owning
a port namespace, a stride, or an index.

It also answered WRONG. A second run inside sixty seconds collided with the first
run's sockets in TIME_WAIT — which `ss -lnt` does not show, so the ports looked
free — and reported up to SIXTEEN healthy apps as DIED. A check that invents
failures gets ignored exactly as fast as one that misses them, and this check
exists because fifteen plugins reached production without one.

:0 on all four listeners deletes the bookkeeping, the stride, the TIME_WAIT
window and the concurrency cap in one move. The kernel already allocates ports
correctly; the recipe just had to stop doing it by hand.

Measured on this tree, same command, minutes apart:
  before  >> compose FAILED: 16 of 120   (15 of them "address already in use")
  after   >> compose FAILED:  1 of 120

The one is `kafka`, which fail-closes when no broker answers at
nats://127.0.0.1:4222. That is the app being honest about a missing dependency,
it reproduces identically on unmodified main, and it is not a composition fault.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay 05c82f45c0 the running build states its own commit, on the health payload it already serves
v1.801.426 was pinned, rolled out and served traffic while the job meant to build
it sat Failed. The image had been built from OLDER source: two fixes reported as
shipped were not in the running binary, and nothing detected it, because there
was no way to ask the process what commit it was. /v1/health returned
{"status":"ok"} and nothing else, /v1/version was a 404, and no revision variable
existed in the Go source. Establishing the truth took exec-ing into the pod,
running the image's own /admin binary and reading its panic.

ONE fact, stamped ONCE, read through ONE function:

  -X github.com/hanzoai/cloud.revision=<40-hex>

reported on the health payload every one of these processes already serves —
/v1/health on the product API, and /healthz, /readyz, /health on the ops listener
(CLOUD_HEALTH_LISTEN, the unauthenticated in-cluster read). No new route, no new
port, no new env var. The two surfaces built their bodies separately — one a map,
one a fixed byte string — which is exactly how a field comes to exist on one and
not the other, so both now build from healthBody and a literal can no longer miss
an addition.

MEASURED, on plugin/base linked with the flags the Dockerfile passes:

  :18110 /v1/health  {"revision":"d25b0f5e70f79bfb04ec60b1f535f20db9a62062",...}
  :18112 /healthz    {"revision":"d25b0f5e70f79bfb04ec60b1f535f20db9a62062",...}
  :18112 /readyz     {"revision":"d25b0f5e70f79bfb04ec60b1f535f20db9a62062",...}

and, linked with no -X at all, {"revision":"unknown"} on all three.

UNKNOWN IS A VALUE, NOT A BLANK. Only a full 40-hex lowercase object name is
reported; an unexpanded ${REVISION}, a branch name, "dev", a short sha and the
empty string all read "unknown" — measured on a real binary stamped `main`, which
serves "unknown". A value that is NEARLY a commit is worse than none, because
someone acts on it. That rule is cloud.IsCommit, and it is the rule the BUILDER
already applied before passing build-arg:REVISION, so apps/platform's private
isCommitSHA copy is deleted and calls it: the two ends of that wire cannot drift
into disagreeing about what they hand each other.

THE STAMP NOW REACHES THE STAGE THAT SHIPS. `ARG REVISION` existed already — in
the FINAL stage, feeding the OCI label, invisible to `go build`, because an ARG
is per-stage. The wire was connected at one end. Worse, the one -X that did exist
reaches nothing: cmd/cloud does not link the root package, so
`-X …cloud.Version=` on /cloud has always been dropped — measured, the flag is in
that binary's `go version -m` record and the value is nowhere in its bytes. The
PLUGINS serve /v1/health and they carried no -X whatsoever, so stamping only the
entrypoint would have left the answering process mute. Both facts now go into one
GO_LDFLAGS used by every binary in the image.

`-X` on a symbol that does not resolve is SILENTLY DROPPED, and under the rule
above a dropped stamp reads as the legitimate "unknown" — invisible, exactly like
the image revision LABEL that has read `unknown` in this fleet with nobody
noticing. Two things close it: the image greps its own linked binaries for the sha
(`go version -m` is not a witness — it echoes the flag that was REQUESTED, present
even when the symbol was never set), and version_test.go LINKS a real binary and
asks the process. The first draft of that test read the child's EXIT CODE, and a
deliberately mis-named symbol sailed through it green, because a test that skips
itself exits 0 like one that passes; it reads the child's OUTPUT now, and the
mutation fails it.

The Dockerfile's ARG sits as late as it can, below `COPY . .` — everything from
there down is already re-keyed by any source change, so a per-commit value costs
nothing, while the same value in scope above would re-key `go mod download` and
turn every build into a full one.

`make` builds report "unknown" on a dirty tree, correctly: REVISION comes from
`git describe --always --abbrev=40 --match='' --dirty`, and `-dirty` is not a
40-hex name, so an uncommitted tree cannot name a commit whose source is not what
was built — the same lie, locally.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay a284ccf282 scope, plugins: the three composition failures become tests, and two more are named
Three defects shipped today because the only thing that could see them was a
running binary and nothing ran one. Each is now the smallest program that
reproduces it.

scope_group_test.go — the group, which broke twice in one day, in opposite
directions:

  - TestGroupWithLaterUseComposes. `g := app.Group(p); g.Use(mw)` while the
    routes register through ZipApp on the ROOT. Group returning the raw router
    hangs middleware on a node whose subtree is necessarily empty; zip refuses
    that at boot, which is what crash-looped fifteen plugins. Asserted with
    zip.App.Build — Listen minus the sockets, verdict returned rather than
    thrown. RED against the raw-router Group with the exact production text.

  - TestGroupPrefixesWhatRegistersThroughIt. The opposite kind: a program that
    composes perfectly and answers somewhere else. A child Group must prefix
    what registers through it down BOTH paths — the route methods and OpScope,
    because `zip.Get(app.Group("/v1"), "/bots", h)` is a real idiom here — and
    must leave an absolute path at the subsystem root alone. Asserted on the
    composed route table, since no compose check can see a route that merely
    MOVED. RED separately against each half of the fix.

  - TestGroupUseOutsideThePrefixesFailsTheMount / ...IsAllowed. Confinement
    through the door Group opened, and its limit: middleware at a prefix the
    subsystem does not own installs nothing and fails the mount, while a BARE
    group there is ordinary — a prefix is just a path.

plugin_surface_test.go — the two drifts, DERIVED from the specs the mains
declare (go/ast) and the routes the committed projections hold, so a subsystem
added tomorrow is checked tomorrow:

  - TestHealthOwnershipMatchesWhatIsRegistered. OwnsHealth is a claim with two
    halves. Claimed falsely, one address is declared twice and zip refuses the
    program — reverting plugin/authz/main.go reproduces the crash verbatim:
    `GET /v1/authz/health: declared by "authz" at serve.go:324 and by "authz" at
    serve/mount.go:31`. Claimed while owning nothing, the address silently 404s.
    The second half asks the manifest which health address is this app's rather
    than assuming /v1/<name>/health — plan answers /v1/plans, storage /v1/s3.

  - TestDeclaredPrefixesCoverTheSurface. A grant a main WRITES must cover the
    surface it serves. One-directional on purpose: containment, never equality,
    or deploy's 14-leaf row makes its own /v1/deploy bridge an escape. It checks
    the half a document can answer; middleware has no address, so the other half
    is `make compose`, which runs the binaries.

plugin/bot/main.go — bot declared OwnsHealth: true and registers no health
route anywhere, so the field's only effect was to suppress serve.go's generic
route and leave GET /v1/bot/health answering 404. Measured before: 404. After:
{"service":"bot","status":"ok"}. Nothing changes in the fleet, where /v1/bot/health
routes to `runtime`; this is the standalone binary's own liveness answer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay b13196c728 make help: a target with a digit in its name is still a target
The awk class was [a-zA-Z_-]+, which excludes digits, so `make help` silently
omitted e2e and e2e-ui — two targets that have existed all along and that nobody
browsing help could discover. One character.

Found by running `make help` in console and reading the output against the file,
which is the only way this shows up: the recipe succeeds either way.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay b42e7bf0ec make compose: concurrent, because an hour-long check is one nobody runs
The timeout is the cost and it is paid per app, so one-at-a-time made this most of
an hour for 120 apps. A check that takes an hour gets skipped, and a check that
gets skipped is how fifteen crash-looping plugins reached production in the first
place — the mechanism has to be cheap enough that it is actually used.

Each app already had its own data dir and its own port block, so nothing contends;
xargs -P just stops them queueing. Failures go to files rather than racing onto
stdout, and the summary names how many of how many failed. Measured: 120 apps in
minutes rather than ~50.

First full run on this tree: `>> compose: 120 apps boot`.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay d79b53d396 make: dev and lint, the two fleet-wide names this file was missing
Aliases, not recipes: dev -> run, lint -> vet. Both targets already existed
and already did the right thing; only the names the rest of the fleet uses
were absent, so there is still exactly one recipe behind each.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay b474e5d71d make compose: prove every app BOOTS, because go build cannot
zip refuses to compose a program whose middleware could never run, and it
refuses at BOOT. So on v1.801.425/.426 fifteen plugins compiled, linked, vetted
and passed unit tests, then crash-looped in production. Nothing in this Makefile
could have caught that: the only thing that finds a compose panic is running the
binary.

SURVIVAL is the signal, and it is the only honest one. A compose panic is fatal,
so a process still alive when the timeout kills it (rc 124) composed. Grepping
the log for a success line does NOT work — `"message":"zip new"` is printed
BEFORE composition, and reading it as a pass is exactly how a broken build got
reported as shipped twice in one day.

Each app gets a writable data dir and its OWN four ports, because without them it
dies on `mkdir /var/lib/cloud/orgs` or on binding :8080/:9653/:9090/:8081 long
before it reaches the router — and an early death looks like silence, which reads
as a pass. That mistake is why a "16 binaries, 0 panics" check was worthless: the
binaries had exited before composing.

It reuses `apps`, so there is one way to build an app binary and no second
mechanism. `clean` takes the scratch dir with it.

Proven both directions on this commit:
  GREEN  make compose APPS="admin prefs"   ->  >> compose: 2 apps boot
  RED    inject `ZipApp(app).Group("/v1/prefs/zzz").Use(Bridge())` into prefs
         ->  PANIC prefs
             zip: the group "/v1/prefs/zzz" declares middleware at
             prefs/prefs.go:147 and no routes anywhere beneath it
         ->  >> compose FAILED, make exits 1
(The injection is needed because every real call site is already fixed — deploy's
was corrected by bd2c284e, so it can no longer reproduce the fault.)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
07ff50327a console: pin the embed that stops an anonymous visitor bouncing off a second landing (#383)
hanzoai/console d761fbc. Clicking "Sign in" on cloud.hanzo.ai appeared to do
nothing: console.hanzo.ai/ served a SECOND copy of the Hanzo Cloud marketing page
wearing the byte-identical @hanzogui/shell header, so the click landed on a page
indistinguishable from the one it left and read as a re-render. Reaching hanzo.id
took three clicks, two of them through pages that only asked "did you mean it?".

The console is the application; the marketing face is cloud.hanzo.ai. `/` is no
longer a special surface — an anonymous visitor STARTS the authorize hop.

Pinned by digest, not the usual sha-<sha7>-amd64 tag: that build published to
:latest, which is the moving target this file already warns about. The digest
names these bytes and no other.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-08-04 20:21:49 -07:00
zeekayandhanzo-dev 058e434ac6 A peer call is a NAME, not a URL: generate the typed client from the one registry
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / gate (push) Successful in 12s
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 billing gate died of a peer call that was an HTTP URL. COMMERCE_URL defaulted
to the public api.hanzo.ai edge — which is THIS binary — so the /v1/billing/*
forwarder re-entered itself, and apps/commerce/transport still carries the scar
tissue: a whole app republished as an http.Handler, every edge middleware re-run
per peer read, and a goroutine-keyed depth counter (maxDepth = 8) to stop the
recursion it cannot otherwise prevent. A call by name cannot express that mistake,
because there is no address to point at the wrong thing.

An app already declares everything a caller needs:

    zip.Post[plane.BalanceIn, plane.Balance](cloud.Plane(), "/finance/balance",
        planeBalance, zip.WithOperationID(plane.FinanceBalance))

— the app, the op, the request type and the response type, in one expression.
Cloud already projects that registry as OpenAPI, a CLI, an MCP tool list and a
routing declaration. A typed Go client for a peer call is ONE MORE PROJECTION of
it, which is why it is generated here rather than hand-written once per caller.

    commerce.FinanceBalance(ctx, &plane.BalanceIn{Currency: "usd"})

plane/gen emits one package per peer (14 apps, 28 ops) holding ONLY request and
response types and call stubs. What it buys is a check no care buys today: a
hand-written peer call is four independent facts that must agree at RUN time, and
nothing stops pairing commerce's op with iam's name, or BalanceIn with Txns. The
wrapper fixes all four to each other where they are declared.

THE CLIENT HALF MOVED TO THE LEAF, and that is what makes any of this possible.
package cloud is itself a caller — the edge rate-limiter reads finance_scope_rules
— so a client that imported cloud could never be imported BY cloud, and the one
call that most needed to stop being a URL is the one the mechanism could not have
expressed. Ask and everything under it now live in package plane; cloud keeps the
server half (Plane, ServePlane) because binding a socket reports itself to o11y.
cloud.Ask stays as a forwarder, so the 44 existing call sites do not move and
there is still exactly one implementation.

It does not drag the peer's tree: plane/commerce is 355 packages against
apps/commerce's 1231, and imports zero apps/ packages — one more than the leaf it
needs. An importable client that linked the implementation would have rebuilt the
problem with extra steps.

Generated FROM SOURCE, judged BY THE RUNNING REGISTRY. zipdoc already reads these
same call sites; reading source buys hermeticity a mount cannot (no store opened,
no boot order, no app that must come up before it can be described). zip's rule —
project from the live router, never the AST — is about a host discovering a plugin
it does not build, and it still binds: plane_registry_test.go mounts commerce and
asserts the generated surface IS the live plane registry, so the generator never
gets to be quietly wrong. Reading the AST's index expression alone had already
been quietly wrong once — treasury spells its registration with inferred type
arguments, so its only op was dropped; types.Info.Instances sees both spellings.

Proven against the real thing, not a fake. plane_client_test.go mounts commerce as
a plugin process does, binds its plane socket as Serve does, and calls the
generated function: commerce answers amount="0" currency="USD" over the socket. A
cold peer with no router answers ErrNoPeer naming the app — a named absence, never
a timeout a caller would have to guess at.

Three root-package call sites converted, including both money ops — the prepaid
gate and the meter now reach commerce as commerce.FinanceAuthorize and
commerce.FinanceRecord. Those are the imports that were structurally impossible
before, so they are the proof the direction is real.

Full suite: 133 failing test names / 26 packages, byte-identical to the same
measurement on origin/main. Regression set EMPTY. go vet ./... exit 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:21:45 -07:00
zeekayandhanzo-dev 2f322c4659 merge: explorer — chain indexing named for what it is, /v1/graph freed
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
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 20:19:27 -07:00
hanzo-devandzeekay 7d12120120 explorer: chain indexing is named for what it is, freeing "graph" for the graph layer
apps/graph was never a graph database. Its package doc says so: "chain data:
your block indexers and how far each has caught up, plus the on-chain price
feeds." It proxies luxfi/indexer (explorer REST) and luxfi/graph (GraphQL) --
"graph" here meant GraphQL, not a property graph.

Both upstreams already mount under /v1/explorer (client.go: graphd default
prefix), so the name follows the contract the app already speaks rather than
inventing one.

Wire unchanged: the app keeps /v1/indexers and /v1/oracles, keeps its frozen
mount position, and the woven document moves only x-app and the tag prose.
References to luxfi/graph -- GRAPH_URL, graphQLPath, the upstream log key --
stay, because those name the upstream, not this app.

/v1/graph is now free for the embedded per-org graph layer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:17:44 -07:00
zeekayandhanzo-dev dc84b46df5 platform: a deployment's own forge is a SIBLING of its API, not a child
The self-hosted-git allowance existed, was correct in intent, and could never
match. `selfGitHost` was `deps.Domain` verbatim — "api.hanzo.ai" — while
hostAllowed grants that host or a SUBDOMAIN of it. The forge is "git.hanzo.ai":
a sibling. So every native build was refused with

    repo.url host "git.hanzo.ai" is not an allowed git provider

and the estate fell back to building from GitHub, which is exactly what broke
when hanzoai/cloud moved to hanzo-inc/cloud and the build credential lost access.
The code's own comment already said APEX; only the assignment disagreed.

selfGitHost is now the registrable apex ("hanzo.ai"), so every sibling the
deployment owns — git., ci., cd. — is a trusted build source by construction,
for hanzo.ai, lux.network, zoo.network and any white-label domain, with no
per-brand list to maintain. publicsuffix rather than "last two labels" so a
multi-label suffix (co.uk) yields the registrable domain and not the suffix
itself, which would trust every domain under it.

Proven both directions: TestSelfForgeIsAllowedFromTheApex admits
git/ci/cd.hanzo.ai and still REFUSES git.evil.com;
TestOldDomainVerbatimRefusedTheForge pins the old behaviour as the defect, so
this cannot silently regress.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:29 -07:00
zeekayandhanzo-dev a8b952f473 bot: the control plane and the door to its executor are one product, not two
CI/CD / containment (push) Failing after 10s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
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
apps/runtime was never a second product. It was the transport to @hanzo/bot —
the TS service that executes a run — plus a relay of that service's own ops
paths at /v1/bot/*. What separated it from apps/bots was a LANGUAGE boundary,
Go surface and TS executor, wearing the shape of a product boundary. Both
answer for the same thing: a bot doing your work on a real desktop.

So they merge. runtime.go becomes transport.go, ops.go becomes relay.go,
bots.Mount mounts both faces, and the manifest holds one row for the pair.

THE WIRE DOES NOT MOVE. Every path this fleet serves is the path it served
before — the whole diff to openapi.yaml is `x-app: runtime` -> `x-app: bots`
on the seven relayed operations. No CLI, SDK, MCP tool or doc regenerates to
a different address, because none of them has a different address to go to.

Two things the merge had to earn rather than assume:

  - apps/coding also dispatches to that executor, so it followed the transport
    from apps/runtime to apps/bots. That is not new coupling wearing a new
    name: coding runs its tasks ON the bot runtime, which is what the import
    now says out loud.
  - the typed-or-named gate was TWO gates, one per old package, each blind to
    half of what is now one surface. They are one gate over the whole product,
    and mountWith mounts the relay so the gate can actually see it. Two gates
    stapled together would have kept passing while measuring nothing.

/v1/bot is still shared with apps/bot, whose three deeper prefixes win on it by
specificity. That sharing is the remaining defect and it is not this commit's
to fix: apps/bot's product is connected machines, not a bot, and it is the one
that has to vacate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:26 -07:00
zeekayandhanzo-dev b180a086ff zip v1.24.6 -> v1.24.7: the remote mount is called Proxy now
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
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 exported TWO things named Mount and they were unrelated:

  zip/remote.go   func Mount(prefix, addr string, decl ...Declaration) (*App, error)
  112 subsystems  func Mount(app cloud.Router, deps cloud.Deps) error

The second is this repo's registration contract, now enforced by the compiler
through cloud.MountFunc. The first was a leftover — one of five composition
verbs (Listen/Mount/Add/Graft/Use), the other four dissolved into Use, and this
one survived only by being the one that pointed at another process.

Renamed upstream to Proxy, which is what it BUILDS: every route it registers
runs one handler whose whole body is forward() to the given address. It is also
a noun, which a function returning a value should be. Not Remote — zip.Remote
already exists and is the CALLER's side; this is the SERVER's side, a stand-in
inside this program's own routing table.

Source-compatible here: cloud had no zip.Mount call site, only the one comment
in cmd/cloud, which now names the function that exists.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:11:33 -07:00
zeekayandhanzo-dev 633b692511 the store prologue that 33 apps copied, and the one that got it wrong
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (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
`type Store` is declared 40 times under apps/ and 35 of those are structurally
IDENTICAL — `struct { db *sql.DB }`. That number is a trap and I am not acting
on it: in Go, git.Store and bot.Store are different types because they are in
different packages, each carries its own app's schema in its own methods, and
"consolidating" a one-field handle wrapper would produce `type Store struct{
shared.DB }` — a rename that moves no code and removes no duplication. 35 of 40
share a shape; ~0 share a MEANING. Optimising that number would be pure damage.

The duplication the number was pointing at is one level down, in the
CONSTRUCTOR, and it is exact. 33 stores open themselves with byte-identical
code modulo their own name:

	db, err := cek.Open(namespace.System(), "<app>", dir)
	if err != nil { return nil, fmt.Errorf("open <app> store: %w", err) }
	sqlpool.Single(db)

Those three lines are a PAIR that nothing paired. sqlpool.Single's own doc says
a two-statement read-modify-write (tracker's per-project issue number, agents'
MAX(seq)+1) is atomic ONLY because no second connection can interleave — so the
cap is a correctness requirement, and it was a separate call every caller had to
remember. 33 remembered.

apps/framework did not. It opens a cek database through an engine OpenDB
callback and never capped it, so its DocType store has been running with an
uncapped pool. That is the defect, and it is the one a 33-way copied prologue
exists to produce: the rule holds until someone writes the 34th store.

sqlpool.Open is the rule moved INSIDE the opener, so there is nothing left to
forget — the same argument as cloud.App being the only way to obtain an app.
36 call sites converted, framework included; the stores that open differently
(git takes a *sql.DB from the OrgStore cache, others carry extra migrations)
still do, because they are different and the point is not uniformity.

Measured, not assumed: shapes compared by AST field set, not by name. Of the
other collision families the brief named, Result (9 decls) and Config (10) have
ZERO structurally identical pairs, and Client has exactly one family of 3
(`base string; http *http.Client; token string`). Those are Go's package
qualifier working, and they were left alone.

Regression set EMPTY by name: all 15 failures in the touched packages
(TestDocTypeAndDocumentRoundTrip, TestForkCreatesProjectFromTemplate, the Redeem
family, …) fail identically on forge/main.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:08:31 -07:00
zeekayandhanzo-dev caae99ef8a five apps stop demanding more of the framework than they use
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
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
All five diverged from the canonical Mount signature, and the divergence was
invisible because nothing checked it. Now MountFunc does (see the parent commit),
so these are what the compiler demanded.

  agent, ai, commerce    took *zip.App, so they had to be wired through the
                         old App field — which ALSO granted app-wide middleware.
                         They now take cloud.Router and reach the concrete app
                         through cloud.ZipApp, the named hole for it.

                         agent installs no middleware at all (hanzoai/agent
                         calls Use nowhere) and now mounts SCOPED: it had the
                         whole binary's gate to buy a typed-op registry.
                         ai and commerce genuinely do gate everything —
                         commerce wraps all of /v1 — and now DECLARE it as
                         Plugin.Global at their composition root, where a
                         capability belongs, instead of implying it with a
                         parameter type.

  dataroom               imported cloud as `hcloud`. The alias is cosmetic to
                         the compiler and fatal to every grep-based check —
                         which is how these five stayed invisible. One name.

  treasury/anchor        not a Mount, but the same disease one level down and
                         the cleanest Pike case in the repo: status() took
                         ledger.Backend — ELEVEN methods — to call Root and
                         nothing else. The parameter said the anchor could
                         accrue revenue, seed the reserve, debit a program and
                         rewrite the revenue-share policy. It can do none of
                         those. It now takes a one-method `rooted`, declared by
                         the consumer, which both backends satisfy without a
                         line of change.

RETRACTED: rollingcap's `func Mount(_ cloud.Router, _ cloud.Deps) error` was
reported to me as the fifth violation, possibly dead. It is neither. It installs
the rolling AI-spend cap reader — live, and load-bearing — and `_` is CORRECT Go
for parameters it genuinely does not read. It already IS a MountFunc; the
compiler says so. Naming those parameters to satisfy a text pattern would make
the code worse. Left exactly as it is.

Regression set EMPTY by name (148 failing tests before, the same 148 after).
apps/commerce's TestBalanceCents and TestInProcessClient fail identically on the
parent commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:54:38 -07:00
zeekayandhanzo-dev a6b174e4e4 one Mount signature, checked by the compiler; Router stops restating zip's
Two names in this file each meant two things, and both cost the fleet something
real.

1. Plugin had TWO mount fields with TWO signatures

    Mount MountFunc                    // func(Router, Deps) error
    App   func(*zip.App, Deps) error   // "for a subsystem that gates everything"

App braided a POLICY question (may this subsystem install middleware over the
whole binary) into a TYPE question (what shape is its entry point). Because the
grant carried its own signature, a subsystem could take the grant just to get
the concrete *zip.App — and three did. agent, ai and commerce each held app-wide
middleware authority they never asked for, purely because their Mount named a
concrete type. hanzoai/agent calls Use nowhere; agent's grant bought it nothing
and risked everything after it in the mount order.

Nothing reported this. The field's own doc claimed "apps.TestWireFrozen fails on
a new one"; no such test exists anywhere in this repo — grep it. A contract
stated in a comment and violated five times means nothing is checking it.

Unbraided: App becomes `Global bool`, and every subsystem — scoped or global —
goes through spec.Mount. The grant now decides only WHICH Router arrives (the
bare app, or a scope bound to declared prefixes). One shape, so MountFunc is the
whole enforcement and it is the compiler: at 123 composition roots a divergent
Mount cannot be assigned and cannot link. It found a sixth violator I had missed
by grep on the first build — cloud.MountMetrics, whose doc says "adapts
hanzoai/metrics into a MountFunc" while its signature took *zip.App. It is one
now, and mounts SCOPED (hanzoai/metrics installs no middleware either).

The concrete app stays reachable through ZipApp, the named hole that already
existed for exactly this and reports nil rather than pretending.

2. Router restated zip.Router instead of embedding it

Ten method lines were COPIED here. Copying an interface makes cloud a second
place zip's routing surface is defined, and the two agree only while someone
keeps them agreeing. When zip v1.23 widened one signature (Use took Component,
not Handler), every implementor that had spelled the methods out had to move in
lockstep — which is what stalled v1.19+ adoption across the fleet.

Embedded, a zip routing change costs this file zero edits, and Fiber() +
Plugins() are visibly what cloud ADDS rather than being buried among ten lines
cloud merely echoes. It carries zip.OpTarget along, which is a gain, not a
widening: scope, *zip.App and commerce's mintRouter all already have OpScope,
and a Router that IS an OpTarget is one zip.Get[In, Out] accepts directly.

Regression set EMPTY, compared BY NAME against a pristine worktree at the same
commit: 148 failing tests across 29 packages before, the identical 148 after
(comm -13 on the sorted name sets is empty in both directions).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:54:38 -07:00
zeekayandhanzo-dev e8c34bfa31 ai v1.832.29: paying subscribers on go/dev/max/team/business were scored FREE
commerceTierToLadder allow-listed only `starter`->trial and `pro`/`enterprise`
->paid, and folded EVERYTHING ELSE to free. The plans commerce actually sells
are go ($9), dev ($19), pro, max, team and business — so a subscriber on
go/dev/max/team/business was scored free, then refused every SKU carrying a
trial or paid floor AND throttled by the free-tier flash cap on top. We took
their money and shut the door.

v1.832.29 inverts the default: a plan is PAID unless it is explicitly free
("", "free", or a `*-free` suffix), with starter/trial the one middle rung.

That direction is safe because A TIER IS NOT A PAYMENT. filter_balance.go has
no exemptions and fails closed — "nothing runs on credit it has not been funded
for" — so an unrecognized plan reaching `paid` still cannot spend a cent it has
not been funded. The OLD default was the dangerous one: it silently cost
revenue every time anyone added a plan slug. Now, add a plan and it works.

Both tests had encoded the defect as intent ("developer"->free,
"mystery"->free) and were corrected to the real plan list. 22 packages ok,
0 FAIL at the module.

Also carries the zen seed fix (v1.832.24) and the object TestMain fix
(v1.832.27), which took that package from ZERO tests executing to 153 PASS —
TestMain called os.Exit(0) before m.Run(). That immediately surfaced a
crawl-storage default still naming the retired MinIO Service, a correction that
had been made twice and could never take effect.

Verified: `go build ./...` clean at this pin.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:43:38 -07:00
zeekayandhanzo-dev d7024e88cc saved cards are served in-process, not proxied to nowhere
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / containment (push) Failing after 14s
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
CI/CD / gate (push) Successful in 11s
A signed-in customer got 401 listing their own cards, and the checkout's
prefill failed on every load: cloud's billing app forwarded /v1/billing/methods
to commerce over HTTP, and that proxy is unconfigured here
(CLOUD_COMMERCE_HTTP_URL is unset), so the customer address for saved cards has
never worked on this deployment.

An internal HTTP hop to a service compiled into the same binary is the wrong
shape whatever its config, so the three verbs move to the commerce app and are
served in-process on the same pinned-subject chain as their portal twins — the
gate that keeps a caller inside its own account whatever it sends. The prefix
moves with them, because the router must deliver where the handler lives.

Also drops /v1/commerce/deposits and /v1/commerce/webhooks from the manifest
and the published document: the broker-dealer proxy behind them is deleted, and
a manifest that claims an address nothing serves is how a path silently routes
to the wrong app.

The routing oracle is what found all of it — both halves, in both directions.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:38:27 -07:00
hanzo-dev 62590c3b80 deps: ai v1.832.29 — paying subscribers stop being scored free
CI/CD / containment (push) Failing after 13s
Hanzo CI/CD / cicd (push) Successful in 16s
CI/CD / gate (push) Successful in 16s
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 19:32:14 -07:00
zeekayandhanzo-dev 92be821956 deps: commerce v1.50.1 — the wire rail reads WIRE_* env
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / gate (push) Successful in 11s
CI/CD / containment (push) Successful in 1m7s
CI/CD / image (push) Failing after 16s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Per-org KMS hydration runs only under KMS_ENABLED, unset here, so the rail
answered "not configured" with every field stored correctly. v1.50.1 falls
back to deployment env, which universe now supplies from commerce-secrets.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:27:15 -07:00
zeekayandClaude Fable 5 b2a07c9353 A failed deployment must not take down a site it is not serving
CI/CD / image (push) Successful in 20m22s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m40s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / rollout (push) Failing after 5s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
`p.Status = "error"` on a non-live completion was UNCONDITIONAL, so "this project
is broken" was reachable from any deployment row the org could name — including
one that had already been superseded.

Found by triggering it on hanzo-ai, not by reading it. v5 was live with all 8401
objects correct in the bucket; completing v3 — an older probe deployment left
queued during the Sites-plane migration — as `error` flipped the PROJECT to
"error", and the sites edge stopped serving. Nothing was wrong with the site. The
bytes never moved. A status field on a superseded row took the host down, and
re-completing v5 as live brought it straight back.

The ordinary production shape is the same bug with worse timing: a rebuild that
fails while the PREVIOUS build is still live. The old content is still being
served and the site is up, yet the project gets marked broken — and the "report a
failed build" step that every CI workflow carries (so a dead build cannot leave a
deployment queued forever) is exactly the thing that would send it.

The rule is now named rather than inlined: failureOwnsProject(currentDeploy,
deployID). Error propagates to the project only when the deployment IS the one the
project points at, or when it points at nothing — a first deploy that fails leaves
a project that has genuinely never served, and that one should read as error. The
deployment row is still error in every case and LifecycleDeployFailed still fires,
so the failure stays visible where it belongs: on the deployment, not on the health
of a site that is up.

The `live` branch already recorded p.CurrentDeploy, so the information needed to
make this distinction was there the whole time and simply was not consulted.

TestFailureOwnsProject pins all four cases. Mutation-checked: restoring the old
`return true` fails exactly the two that matter — the superseded deployment and
the failed rebuild — so the test cannot pass against the behaviour it exists to
prevent.

(TestDeleteStays204WithNoBody fails on darwin both with and without this change:
the SQLCipher codec refuses to decrypt without tmpfs, and macOS has no /dev/shm.
Pre-existing and unrelated.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:56:46 -07:00
antje 200dfa6990 deps: ai v1.832.25 -> v1.832.28 — a refusal now names its gate
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
A plan-refused caller was told to request limited-preview access — a
door that cannot open when the plan is the blocker. Each family gate
now speaks its own sentence: upgrade for the subscription floor, paid
capacity for the funding floor, request-access only where a grant is
truly what is missing.
2026-08-04 18:56:32 -07:00
zeekayandClaude Fable 5 8034cd5ad0 Serve docs.html for /docs, so a Next export is hostable on the Sites plane
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 edge resolved an extension-less path to two candidates — the exact key and
`<rel>/index.html` — and Next.js `output: export` writes neither for a normal
route. Without `trailingSlash` it emits the FLAT file `pricing.html`, so the
homepage served and every other route 404'd.

Measured on the hanzo.ai export (759 pages, 8402 objects) published to
hanzo-ai.hanzo.app:

  /                200      /pricing        404   (/pricing.html      200)
  /zen             404      /zen.html       200
  /zen/models      404      /zen/models.html 200

That is the shape of a site that reports `status: live`, serves its homepage,
and is unusable — the failure is invisible from the deploy and from the root URL.

`<rel>.html` is added as a middle candidate. Both spellings are legitimate and
both are now tried: Hugo, Jekyll, Vite MPA and `trailingSlash: true` emit the
directory-index form, Next's default emits the flat form. The alternative was
setting `trailingSlash: true` in every repo, which pushes a server limitation
onto each site and rewrites every canonical URL to work around it — this is one
extra HEAD miss on the paths using the other convention, in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:40:48 -07:00
antje 4ca095ba9b deps: zen v1.4.9 -> v1.4.10 — enso is generally available
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 embedded family catalog no longer gates any enso SKU, so discovery
stops advertising a waitlist and ai serves every caller without a grant.
The app builder's default model refused everyone outside the launch org;
now it answers.
2026-08-04 18:40:46 -07:00
antje 29eaf7a038 deps: sqlite v0.5.0 -> v0.5.1 — encrypted stores become testable on darwin
CI/CD / image (push) Successful in 21m19s
CI/CD / gate (push) Successful in 11s
CI/CD / containment (push) Successful in 1m57s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
The envelope's RAM-backed guard now recognises a native macOS tmpfs
(sudo mount_tmpfs <dir>, then HANZO_SQLITE_RAMFS_DIR=<dir>), so the
cek-backed suites can run on a Mac instead of failing on every commit.
Fail-closed is unchanged: anything statfs cannot verify as tmpfs is
still refused.
2026-08-04 18:18:24 -07:00
antje 2eb40260e1 deps: ai v1.832.25 -> v1.832.26 — the record-chain task can find its column
Hanzo CI/CD / cicd (push) Successful in 1m33s
CI/CD / gate (push) Successful in 1m33s
CI/CD / containment (push) Successful in 3m0s
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
Record.NeedCommit carried a xorm-style `db:"index"`, and dbx reads that tag
as the column NAME — so the column was called `index` while
ScanNeedCommitRecords queries `need_commit`. Live in this pod: 'no such
column: need_commit' every five minutes, and the record-chain commit task had
never committed a record.

Also carries a guard for the class, placed in routers rather than object
because object's TestMain os.Exit(0)s that package without a seeded database —
a test there reports ok without ever running.
2026-08-04 18:15:51 -07:00
antje 9f2a0ec42f Merge remote-tracking branch 'forge/main'
CI/CD / image (push) Successful in 22m25s
CI/CD / gate (push) Successful in 1m23s
Hanzo CI/CD / cicd (push) Successful in 1m23s
CI/CD / containment (push) Successful in 1m55s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
# Conflicts:
#	go.mod
#	go.sum
2026-08-04 18:10:52 -07:00
antje f45676ee09 availability rides the program's own registry
hanzo_service_up is now recorded a second time, through Metrics() — the
registry the framework exports natively — under the exact series name and
label the meter pipeline has always published. Two producers, one probe, one
truth: every rule and reader sees an uninterrupted series whichever road the
sample travelled, which is what lets the old road be retired without a gap
once every reader is confirmed on this one.

The verdict is recorded where it is decided: the probe client's transport,
which already tells each target's reason on change. A nil registry skips
recording — a test that mounts probes without one measures the probing, not
the export.

zip moves to v1.24.6 for Metrics(), which also brings the native span and log
export and the convention that finds a collector with no configuration.
2026-08-04 18:06:20 -07:00
antje e63c4cca04 deps: ai v1.832.24 -> v1.832.25 — /v1/crawl reads through the one crawl
Hanzo CI/CD / cicd (push) Successful in 14s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m55s
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
hanzoai/ai is a library this binary links, not a service of its own, so the
crawl4ai leg it dropped stays live in production until this pin moves. v1.832.25
points /v1/crawl at apps/crawl.Fetch — the same guarded dialer the answer
engine's read stage uses, refusing non-public addresses on every hop including
redirects — instead of dialling crawl.hanzo.svc.cluster.local:11235, a name that
does not resolve and returned success:false for every request.
2026-08-04 17:59:51 -07:00
antje 9b17f02dac answer: a survey grounds on what it gathered, and cites only that
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m44s
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 survey shipped with the loop steerable by the pages it read. Its decision
prompt carries titles and URLs crawled from hosts we do not control, so the move
that comes back is partly authored by them — and nothing checked it. Three
consequences, all reachable by getting one page into the ranked set:

  a `read` URL was fetched whether or not the survey had ever gathered it, so a
  page could point the cluster's egress at any address and carry the user's
  question there in a query string, then have the answer filed in the tenant's
  corpus;

  a round's `queries` list was unbounded — mode.maxQueries bounded round zero and
  nothing else — so one move could issue hundreds of serial meta-searches from a
  shared egress that is already bot-challenged;

  page text was spliced into the numbered source block unfenced, so a body
  containing `[9] Title\nURL\nbody` became a source indistinguishable from a real
  one, and nothing validated that the URLs the answer cited were sources at all.

CONTAINMENT, NOT INSTRUCTION. A prompt is advice to a model. These are properties
of the text:

  `read` is intersected with the pool the survey itself gathered, and `queries`
  and `read` are clipped where the untrusted value crosses into the loop;
  ground.go fences each source with a per-request nonce the page cannot know,
  because it was written before the request existed;
  every markdown link in the answer is checked against the gathered set — on the
  stream and in `done`, through the same function — so a citation always points at
  a page THIS request fetched. An ungrounded link keeps its text and loses its
  target: the sentence still reads.

Two bounds that could not bind now do. tokenCeiling measured the survey's own
subtotal while the plan and the synthesis — the expensive calls — sat outside it,
so research's 400k ceiling was ~50x the reachable total; it now takes the
request's running total. And plan, survey and synthesis shared one 300s clock
with no reservation, so a survey that spent it handed a full corpus to a
completion that could not start and the caller got "the model is unavailable"
after five minutes; the gather now gets 70% and synthesis keeps the rest.

Also fixed, and each one a bug on its own:

  unread MARKED every URL a move named while read FETCHED only the first six, so
  a move naming ten pages blacklisted four without ever fetching one — and the
  round then tripped saturation and ended the survey early, losing evidence on
  exactly the runs working hardest. The cap now comes before the marking.

  Source.Text carries the fetched page and Source.Snippet stays the search
  summary. Reading no longer overwrites what the wire shows, so a research answer
  stops re-sending up to a megabyte of duplicate page text across twelve
  snapshots, and one frame per round is now the whole story.

  A round's queries run concurrently. Serially, at 12s each, a six-query round
  spent most of the answer's wall clock waiting.

  An unreadable move gets ONE stricter reprompt. A model that opens with "Sure!
  Let me look at the JVM next" collapsed research into a single pass, and nothing
  downstream could tell that from a model that judged the evidence complete.

  A client that hung up ends the run: one research answer costs five minutes,
  three dozen fetches and up to eight completions, and a closed tab bought all of
  it. The plan's topic headings now reach the client as a planning detail, which
  is what makes a three-minute wait legible. Crawl and completion failures are
  logged — a degradation nobody can see is a degradation nobody can fix.

The package doc claimed METERED ONCE. It is not true: build.go hands this engine
a metered AI plane, so every internal completion also debits the payer per token
alongside the flat fee. Which layer should price /v1/ask is an open decision,
recorded rather than claimed away.

The SearchEvent union is unchanged — same variants, same keys, same order.
2026-08-04 17:48:05 -07:00
hanzo-dev 17de9132ae deps: commerce v1.49.67 -> v1.50.0 — main pins a version that does not exist
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m27s
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
v1.801.445 failed to build, twice, on `go mod download`:

    github.com/hanzoai/commerce@v1.49.67: reading go.mod at revision
    v1.49.67: unknown revision v1.49.67

v1.49.67 is a PHANTOM. It exists on no remote: github.com/hanzoai/commerce
carries exactly two tag refs (v1.50.0), and git.hanzo.ai/hanzoai/commerce
lists ...v1.49.65, v1.49.66, v1.49.68 — .67 is skipped. It survives only in
warm module caches, which is why .442/.443/.444 built with the same pin: the
BuildKit `cloud-gomod-v4` cache is PER NODE, .444 landed on a node that still
held it (runner-pool-32g-3mn0fk) and .445 landed on one that did not
(runner-pool-32g-3mnls1). The release train was therefore passing by luck of
placement, and any cold node broke it — exactly the failure the Dockerfile
comment above `go mod download` anticipates.

Nothing here can be fixed by retrying. The Dockerfile sets
GOPRIVATE=github.com/hanzoai/* with GOPROXY=...,direct, so hanzoai modules
resolve DIRECT FROM GITHUB, and v1.50.0 is the only commerce version GitHub
has. This is an upgrade, not a workaround: commerce was re-published under
MIT OR Apache-2.0 and cut v1.50.0, and go.mod simply had not caught up.

Verified rather than assumed: `go build ./...` is clean against v1.50.0, and
v1.50.0 carries the same 1875 dirs and 8 billing packages as .67 including the
Cloudflare 502 work (thirdparty/cloudflare, api/billing) — the ~134 fewer .go
files are the tests/enterprise the OSS publish strips.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 17:32:08 -07:00
hanzo-dev a1c656944b deps: ai v1.832.21 -> v1.832.24 — the zen family serves again
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m4s
CI/CD / image (push) Failing after 17s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
v1.832.24 stops seeding an admin/zen provider row that pointed at do-ai's base
https://inference.do-ai.run/v1. familyProvider reads that row as an operator
override of ZEN_URL, and the family paths append their own /v1, so every zen
catalog refresh hit .../v1/v1/models and 404'd — no zen SKU was listed or
served on api.hanzo.ai, once a minute, silently. Sibling enso was never seeded
and never broke.

The seed also self-heals ProviderUrl on every boot and re-creates a deleted
row, so this was not fixable in the database; v1.832.24 drops the seed entry
AND prunes the existing row, scoped to the exact stale shape.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 17:07:25 -07:00
antje 493c2e98aa money: a balance is floored, so a gate never admits spend it cannot cover
money.Amount.Minor() rescales, and hanzoai/decimal's Rescale rounds
HALF-AWAY-FROM-ZERO (decimal.go:145). Both balance call sites carried a
comment saying it "truncates toward zero". It never did.

So a balance of 4.995 USD read as 500 cents. hanzoai/ai's transaction check
is `avail < priceCents`, and apps/billing/gpu_charge.go is
`available < req.AmountCents` — both admit a 500-cent charge against a
balance that cannot cover it. The debit that follows is exact, so the
difference lands as a negative balance nobody authorized. Half a cent, every
time, with no error to find.

plane.Money.FloorMinor is that rounding made once, where the wire conversion
already lives, for every caller that COMPARES rather than debits. Minor()
refusing a sub-unit amount is still right for a debit and is untouched.
big.Int.Div is Euclidean, so a negative balance floors away from zero rather
than drifting back toward solvency the way truncation would, and the scale
comes from the currency exactly as Minor() takes it — cents are a property
of USD, not of money.

The guard that existed for this GREPPED ai.go for `a.Minor()` and for the
sentence claiming truncation, so it confirmed the wrong claim was still
written down. A test that reads the source cannot notice the sentence is
false. plane/money_test.go asserts the arithmetic instead — including the
4.995 case that was admitted, the real prod balance, and both signs. What is
left in apps/ai is the one thing only that package can say: that its gate
still asks for the floored figure.

Also corrects apps/billing/balance.go's comment calling this number "a
DISPLAY, nothing is billed from it". It is `available` on
/v1/billing/balance, which is what ai's balance gate reads over the S2S HTTP
path, and what gpu_charge.go compares against a GPU's price.

Verified: money.Amount.Minor()=500 vs FloorMinor()=499 for 4.995.
apps/billing's TestGPUCharge_* fail identically at origin/main -- they refuse
to run without a tmpfs (/dev/shm), which macOS has not; unrelated to this.
2026-08-04 17:06:22 -07:00
antje 1eff0bc85c answer: research surveys — the evidence gathered under a bound, not in one pass
Deep research was the one thing the answer engine could not do. `research` planned
sub-queries, searched them ONCE, read six pages, and wrote. A question whose answer
is only visible after the first round of reading — which is what "research" means —
got a search with a longer prompt.

SURVEY is the value that was missing: search and read applied to a plan, and
ITERATED. Each round asks the model for one compact JSON move (`next`, `queries`,
`read`, `done`), runs it, and discards the prose that came with it — gathering is
not writing. No tool plane is needed or used, and a model that answers in prose
ends the survey instead of derailing it.

`rounds == 0` is the single pass, byte for byte. search/news are untouched: same
queries, same one-per-host set, same 90s, same 2c. One code path, parameterized by
a mode value — not a second engine, and not a second route. /v1/ask stays the one
door and `mode` stays a value handed to it.

Bounded four ways, because an unbounded agent loop cannot be gated by Bill.Gate
before it runs and an ungated loop on a per-org ledger is a money bug:

  rounds        the mode's budget, hard-capped at maxRounds=8
  deadline      per-mode wall clock (search 90s, research 300s)
  tokenCeiling  per-mode token spend (120k / 400k)
  saturation    a round that found no new source and read no new page

Saturation replaces the `len(srcs) >= maxSources` guard the design called for.
That test cannot do the job it was written for: rank() already caps the set at
maxSources, so it fires on the FIRST productive round and collapses research back
into the single pass the survey exists to iterate. "No new evidence arrived" is
the bound that was meant, it cannot be satisfied vacuously, and it also terminates
a model that keeps proposing a query it has already run.

Every exit lands on the same return, and the caller always goes on to synthesize
whatever was gathered — the envelope reaches `done` from every path.

The contract did not move. status | sources | text | follow_ups | done, four
stages, no fifth: the round's next step rides as `status{planning, detail}`, and
per-page reading progress as `status{reading, detail:host}` — which the union has
always declared and nothing used to emit. `sources` stays a CUMULATIVE snapshot
because all three SDK consumers replace their list on it.

Alongside, four things the port made necessary:

  rank  takes a per-host cap. One page per host is right for a six-source answer,
        where breadth is the value, and wrong for research, where three pages from
        an authoritative domain are the point. hostCap<=1 reproduces the old set.
  rank  cleans `[PDF]`/`(Official Site)` furniture off a title so a citation reads
        as a document name — keeping the original when a title is entirely
        bracketed, which would otherwise cite the page by bare hostname.
  read  takes the url list and the clip from its caller instead of a mode's top-N,
        and reports progress per source. An iterated survey clips to 3000 runes:
        many sources x a long page is the one way this loop could overrun a window.
  synth streams through a joiner that holds a markdown link until its closing
        paren, so a citation never renders as `[Rich Hickey](htt` and rewrite
        itself. Delivery only — the finished text is identical.

research is repriced 10c -> 25c. It now gathers across rounds, decides between
them, and reads more than once; the price follows the work.

Not ported, deliberately: code interpreter, X search, chart artifacts, images, and
every external-SaaS leg (Tavily, Exa, Firecrawl, Notte, Supadata, Daytona). Search
is apps/websearch in-process and keyless; reading is apps/crawl, SSRF-guarded, with
its archive->fetch->headless ladder already standing in for Exa->Firecrawl->metadata.
Provider reasoning tokens are never mapped to `text`: every consumer appends delta
into answer and would corrupt both the answer and the persisted training sample.

Round decisions want temperature 0. cloud.ChatRequest carries no temperature field
and inventing one would fork the AI contract for a single caller, so the prompt
buys the determinism instead. Flagged, not faked.

Tests: survey_test.go proves rounds==0 is the old loop, that the gather actually
iterates (a later round searches and reads what round zero did not, and round
zero's page survives the re-rank), that the plan is carried verbatim into every
round, that snapshots are cumulative, that a round's prose never reaches the
answer, and that each of the five exits leaves the frame sequence intact.
stream_test.go adds the wire ordering invariant and proves the server never emits
the union's `error` variant.
2026-08-04 17:06:22 -07:00
hanzo-dev ed58de9ba1 deps: ai v1.832.21 -> v1.832.24 — the zen family serves again
v1.832.24 stops seeding an admin/zen provider row that pointed at do-ai's base
https://inference.do-ai.run/v1. familyProvider reads that row as an operator
override of ZEN_URL, and the family paths append their own /v1, so every zen
catalog refresh hit .../v1/v1/models and 404'd — no zen SKU was listed or
served on api.hanzo.ai, once a minute, silently. Sibling enso was never seeded
and never broke.

The seed also self-heals ProviderUrl on every boot and re-creates a deleted
row, so this was not fixable in the database; v1.832.24 drops the seed entry
AND prunes the existing row, scoped to the exact stale shape.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 17:05:08 -07:00
zeekayandhanzo-dev 7236d6760e deps: commerce v1.49.68 — the crypto refusal becomes readable
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 rail's "could not generate a deposit address" was a 502, and Cloudflare
replaces an origin 502 with its own HTML interstitial — so the customer read
"request failed" while a clear JSON message sat at the origin, unreachable.
v1.49.68 answers 503 (which passes through, as the wire rail's own 503
already proves) with a sentence naming the rail, saying it is temporary, and
pointing at the alternatives.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 17:03:42 -07:00
hanzo-dev da4b9dd2d0 scope: the idiom that crash-looped ten plugins gets a test
`g := r.Group(p); g.Use(mw)` is the form that panicked — the group carried
middleware, the routes were registered on the root, and zip refused to compose
a guard that could never run. It is fixed, and it was the only one of the three
Use idioms with no test. Eleven production apps write it (books, captable,
commerce, company, compliance, dataroom, framework, git, legal, risk,
validators); the tests covered the other two.

Reverting scope.Group to hand back the raw router reproduces the original panic
verbatim and turns TestGroupThenUseGuardsOnlyItsSubtree red, which is the point:
asserting only that the panic is gone would also pass on a scope that silently
dropped the middleware, and a dead guard is worse than a panic because the panic
is honest. Each case asserts both halves — the guarded path answers 401, its
unguarded sibling answers 200 — across three subsystems, one with declared
prefixes and one nesting a group inside a group.

Addressing is asked separately from gating, because a gated route answers 401
whether or not it exists, so a test that asked both at once could not tell "the
route is where I said" from "the guard refused a 404".

And the residue is written down: a scope installs at the root, so its middleware
runs for what follows it. Use before the routes guards them; Use after them
guards nothing, and it composes either way because the root always has routes.
apps/company depends on exactly that — its fundraise/deck leaf sits above
g.Use(limitBody) on purpose, since the deck is document bytes and a JSON body
cap on it would be wrong. That deliberate exemption rested on an unwritten rule.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:57:13 -07:00
hanzo-dev 006862e859 writer: the pod's lease belongs to the pod's root, not to each of its processes
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m10s
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
INERT IN PRODUCTION. CLOUD_WRITER_LEASE is unset everywhere and this change
does not set it. With it unset every path here is a no-op, the lock file is
never created, and boot is byte-identical to what is running now. The env flip
and the strategy change remain a separate, human-gated step. Nothing to watch
on deploy.

The lease states a POD-level fact — "this pod owns this volume" — with a
PROCESS-level primitive, flock(2) on {DataDir}/.writer.lock. Those are the same
sentence only when exactly one process per pod reaches for the lock, and cloud
stopped being one process: cmd/cloud is a router that spawns every subsystem as
its own child, all sharing one DataDir.

The acquire lived in cloud.Listen — the body every PLUGIN binary runs, and the
one thing the router never calls. So turning the lease on aimed a single-holder
lock at the siblings instead of at the other pod. kms won the flock; pubsub and
kafka waited out the 90s fail-closed budget; nothing bound :8000; the liveness
probe killed the pod and its replacement deadlocked identically. api.hanzo.ai
served 503 from 08:52Z to 08:56Z on 2026-08-04.

The repair after that (aa416cc6) made the children skip the lock and stopped
the deadlock. It also left the lock in nobody's hands, because the router does
not run that code at all — an interlock that logs as armed and holds nothing.
That is the worse of the two states: the deadlock announced itself, whereas a
lock held by nobody is quiet until someone believes the log line and switches
to RollingUpdate. With S3_ADMIN_* armed the hydrate path renames over the live
DB, so the quiet failure is the expensive one.

So the duty is decided once, in internal/writerlease, from the only thing that
can distinguish these processes — their position in the tree:

  Take    the pod root, which takes the lock BEFORE it spawns anything and
          releases it AFTER the last child is gone
  Inherit a child, which is handed the answer and never contends
  Off     no lease configured — today, and correct under Recreate

cmd/cloud takes it before its mount loops and stamps CLOUD_WRITER_LEASE_HELD
with its own pid; zip already spawns children with append(os.Environ(), …), so
each is born knowing the volume is claimed. The stamp carries the pid rather
than a bare flag so a child CHECKS it — it counts only when it names that
child's own parent — which is what stops a stray value in a manifest from
talking a fresh pod root out of taking the lock. cloud.Listen makes the SAME
call: in the fleet it inherits, and run standalone (`hanzo kms` on its own
volume) it is a pod root and takes the lease itself. One rule, one reader of
CLOUD_WRITER_LEASE; the Config bool is gone, since a bool parsed per process
is true in all of them alike, which was the original misreading.

Also: Acquire rechecks that the inode it locked is still the file at that path,
because flock locks an inode and an unlink+recreate under it yields two holders
who both believe they are alone.

Tests run real processes, since the defect was a property of the process tree:

  TestLegacyRule_SiblingsDeadlock  reproduces the incident — 3 subsystems, one
    DataDir, nobody above them: 1 serves, 2 spend the whole budget waiting for
    a handoff that cannot come
  TestFixedRule_SiblingsAllServe   same topology, root holds first: all 3 serve,
    duty=inherit, zero contention
  TestFixed_VolumeStillDefended    the one that fails against main as it stands
    ("a second pod OPENED the volume while this pod holds the lease") — proves
    the fix did not simply disconnect the alarm
  TestStampCannotBeForgedByConfig  a hand-placed stamp cannot disarm a pod root

go build ./... green; go test ./... introduces no new failures (29 packages
fail on origin/main before and after, all unrelated).

Follow-ups NOT in this change, deliberately: flock is a lie on NFS/CIFS, so a
shared-filesystem refusal is still worth having (see blue/writer-ha); and
cto/writer-interlock-honest argues the exclusive-store premise is now stale,
which is a question about whether to keep the mechanism at all, not about
whether it should be correct.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:53:51 -07:00
antje 446ca3bdfe commerce: the tier route resolves an org for the service that reads it
Not crashing was not the fix. The ai router maps ANY non-2xx from
/v1/billing/tier to TierZenFree, so the 400 that replaced the 500 downgrades a
paying customer exactly as the crash did — 60 rpm against 500 for pro, silently,
with no error the customer or we would ever see. The tier has to RESOLVE.

Its caller is a service, not a person, and the two resolve an org by different
doors. IAMTokenRequired admits only a gateway-validated user identity (ownerID
AND X-User-Id AND email) and deliberately falls through on a bare X-Org-Id,
because admitting on that alone once let an off-gateway caller name any victim
org. The router sends precisely that shape — verified in ratelimit.go, a service
bearer plus X-Org-Id and no user — so it arrived with a nil org.

TokenRequired is the door for a caller that IS a credential: it verifies the
service token first and only then calls ensureIAMOrg to resolve the header.
Credential before trust. Same reason the catalog CRUD and the recharge poke each
carry their own TokenRequired rather than riding an IAM chain.

The commerce v1.49.67 handler fix stays and is still right: a tier that genuinely
cannot be read is refused rather than answered Free. This makes it readable.
2026-08-04 16:53:45 -07:00
zeekayandhanzo-dev 9fac0f5c0e commerce: a customer can SAVE a card, not only list and delete one
CI/CD / image (push) Failing after 29m50s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m35s
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 12s
The saved-card family had no way in. A customer could list cards and remove
one; adding required POST /v1/billing/methods, which the billing app
forwards to commerce's /v1/billing/methods — an address commerce does not
serve co-resident, and which the in-cluster `commerce` Service resolves back
to these pods, so the forward re-enters the forwarder. It never got that
far: CLOUD_COMMERCE_HTTP_URL is unset, so the proxy is unconfigured and
every saved-card call, GET and POST alike, answers 501 "billing is not
configured". Measured live on pay.hanzo.ai. Nothing could bill a monthly
plan to a card on file because no card could be put on file.

The portal face is where its siblings already live, so the POST goes there
too: no HTTP hop, nothing to self-dispatch, same gate. commerce's
CreatePaymentMethod vaults the Square nonce as a reusable card-on-file and
stores the billing address with it — that vault is what a renewal charges.

PinBillingSubject is load-bearing here rather than decorative: this handler
takes its subject from the BODY (customerId), and the pin rewrites the
subject keys there while preserving card/type/sourceId, so a caller can only
attach a card to its own account whatever the body claims.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:47:48 -07:00
antje e26a45b5f8 deps: commerce v1.49.67 — tier and credit-grant reads stop panicking on a nil org
Hanzo CI/CD / cicd (push) Successful in 24s
CI/CD / gate (push) Successful in 25s
CI/CD / containment (push) Successful in 2m6s
CI/CD / image (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The route registration landed in v1.801.440 and /v1/billing/tier still answered
500: the chain was never the problem, the handler was. GetTier type-asserted the
organization, and IAMTokenRequired resolves one only from a gateway-validated
user identity — so every S2S caller, which is this route's main caller, arrived
with nil. Fixed upstream where the handler lives, not worked around here.
2026-08-04 16:39:07 -07:00
hanzo-dev 12f5eeb834 cloud: only IAM mints — bearer material is pinned to the authority
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
CI/CD / image (push) Successful in 21m24s
CI/CD / gate (push) Successful in 12s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / containment (push) Successful in 1m52s
CI/CD / rollout (push) Failing after 7s
Outside apps/iam no app file imports what a bearer is hand-rolled from
(crypto/hmac, a JWT library) or joins apps/team/token, except the pinned
entries: foreign auth wires (LiveKit, SigV4, webhook schemes, the mpc ring),
non-identity seals (OAuth state, unsubscribe links, a KDF), and the condemned
team token with its complete reader set, which only shrinks. The root's
deleted second authority failed permissively — key confusion, machine
principals as humans — and apps/ had no guard against growing another.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:24:13 -07:00
hanzo-dev e8f3875d17 cloud: every app mounts its product, and the exceptions are pinned
cloud/apps/<name> is plugin-mode wiring for github.com/hanzoai/<name>; the
product owns the functionality and ships its own standalone daemon. The
boundary now counts itself: an app either mounts its product or holds a pin
naming which debt it is (mismatched / unwired / unextracted), and the lists
only ratchet down. Measured at pin time: 140 apps — 16 mounted, 1 mismatched
(deploy mounts hanzoai/cd), 27 unwired, 96 unextracted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:24:13 -07:00
hanzo-dev 2464a60a06 Merge remote-tracking branch 'origin/main' into fix/product-key-on-the-op
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:23:13 -07:00
antje 5c95fcac81 o11y: the fleet probe tells the truth, from one address registry
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 / reach (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The status page published three incidents for services that were up. Not one
bug — one structural defect: a hand-maintained address table drifted from the
fleet, and the prober faithfully published three wrong addresses. iam was
probed on a path it serves on a different port; kms was probed at a deployment
scaled to zero years after the fold into cloud; hanzo-mpc named a Service that
selects nothing.

fleetTargets is now the one registry of where a service answers, and both
availability reads take their address from it. iam is probed at the OIDC
discovery document — what its own readiness reads, and the first thing every
client fetches, so answering it means a customer can sign in. kms is probed at
cloud's embedded /v1/kms/health, which still fails closed without the master
key, so the API can be up while KMS honestly is not. hanzo-mpc is removed
rather than retargeted: the real ring answers 307 to every path including
absent ones — a probe that can only say yes trades a false outage for a false
all-clear.

The second address table is deleted. productmap synthesized addresses by
convention — port 80, try /health then /healthz — wrong for 19 of 27 products
and burning two timeouts per miss. An unwatched workload now has no address
and is probed not at all.

A failure names its target, address and reason, edge-triggered: one line when
it breaks, one when it recovers, and a changed reason reports again because
that is a different chase.

Verified from inside the live pod: the shipped list answers 20/20. A red test
turned green with it — the scoped-status read that blocked a full second
dialing a service that was never there.
2026-08-04 16:21:21 -07:00
hanzo-dev 28ae459464 Merge remote-tracking branch 'origin/main' into fix/product-key-on-the-op
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:21:18 -07:00
hanzo-dev 01f848aa46 product: the bearer is the op's input, not a subtree's middleware
/v1/search and /v1/vector belong to provisioning; product owns exactly
four routes inside them. Hanging requireKey on the two parent groups
claimed both subtrees, so the confinement gate refused the boot — and it
was right to: in the unified binary that middleware would have gated
provisioning's routes with product's key.

The credential is a REQUEST fact, so each op now declares it: keyedIn
carries the Authorization header as a typed input field (zip's stated
replacement for exactly this middleware) and requireKey opens every
handler. Same statuses in the same order — unset key 503s, wrong key
401s, search and vector keys never cross — and the four addresses are
byte-identical; the document gains only the header parameter each op
always required but never published.

Verified: apps/product suite green, bin/product boots (was the one
compose failure in 120), openapi weave green with no path added, moved
or removed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:21:02 -07:00
antje 8ea5a0abab zip v1.24.4: the framework reports, so the app stops repeating it
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 program now emits its request log natively — method, path, status,
duration, trace and span, the caller when the environment parked one — through
its own logger, so the per-app Logger install is deleted along with the three
test copies of it. The framework also propagates trace context and serves
/metrics from the one registry.

Kept deliberately: the OTel meter pipeline (metrics_http.go, installMeter) and
the span path. The datastore pipeline they feed is what /v1/summary and
availability READ, and the framework's export has not yet been proven to land
where those readers look. Two instruments briefly is safe; a blinded status
page is not. The compiler found the callers a text search missed — the span
middleware records the RED metrics — which is exactly why they stay until the
export path is measured.

Also carries luxfi/metric v1.9.1 transitively pinned by zip — the registry
that records by default.
2026-08-04 16:07:15 -07:00
hanzo-dev 3186567bbc integrations: record why an OAuth callback was rejected
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
failRedirect is the one place a failed OAuth return lands, and it logged
nothing, so every failure was indistinguishable from a request that never
arrived. It now logs the opaque public reason together with a precise
internal cause; the browser still gets exactly one reason, so no oracle is
offered to a caller probing states.

verify() wraps errBadState with that cause. errors.Is still matches, so
control flow is unchanged. The distinction it makes visible is between a
state signed by a different key -- what happens when the signing key is
absent and each boot invents its own, breaking every flow that spans a
restart -- and one that simply expired.

Folds the two ad-hoc warns into the funnel, carrying org in the cause so
no detail is lost.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:03:44 -07:00
zeekayandhanzo-dev d25b0f5e70 commerce: route the wire + crypto top-up rails at the composition root
CI/CD / containment (push) Successful in 1m1s
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) Successful in 20m50s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / gate (push) Successful in 12s
The module-side registrations (commerce v1.49.65+) serve nothing in the
fleet by themselves — commerce's api.Route() bundle is never compiled here,
and the host hands an app only the prefixes its manifest row names. So the
four rails get what every self-service billing address before them got:
co-resident registrations on the pinned-subject IAM chain, and their
prefixes named deeper than the bare /v1/billing stem nobody claims.

- GET  /v1/billing/wire            — the serving brand's receiving bank
  details, caller's billing key in the payment reference (attribution is
  why it sits on the pinned chain; an unpinned wire is unattributable).
- GET  /v1/billing/crypto/options  — chains+tokens from the live MPC
  processor; the pay SPA's asset picker renders exactly this.
- POST /v1/billing/crypto/deposit  — per-payer custody address via the
  signer fleet's /keygen; payer is the PINNED subject, never a body value;
  open intents are reused so a refresh cannot spray keygens.
- GET  /v1/billing/crypto/deposit/:id — caller-scoped intent state.

Nothing mints on any of these: wire settles via the admin wire/credit verb
on bank receipt, crypto via the chain watcher on real confirmations.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 15:42:25 -07:00
hanzo-dev 6c412d9bda risk: model family is a value, so a second family cannot wear the first's parameters
CI/CD / image (push) Failing after 28m27s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m27s
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 11s
geometry is a closed sum and family derives from the geometry's own type, so
"half-space parameters attached to a transformer" does not compile and this
package carries no check for it. family leads the content address, domain
separated, because every term after it is one family's arithmetic — two
families' numerically identical masses can no longer be named as one value.

The detector seam is the six methods the learner already calls. The half-space
counters are the first implementation and behave as before. legacy() is the one
remaining braid, at the disk boundary, and refuses a family the resume column
cannot carry rather than recording a shape for a model that is not one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 15:26:05 -07:00
zeekayandhanzo-dev 83c0d4a1a8 deps: commerce v1.49.66 — MPC keygen speaks the live signer
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.49.65's crypto rail called an MPC API the deployed fleet never served
(vaults routes, all 404). v1.49.66's GenerateAddress speaks the live
luxfi/mpc contract: POST /keygen {"org_id"} → all-chain addresses in one
threshold keygen. Env to arm it: MPC_ENDPOINT + MPC_API_KEY (KMS-synced
into commerce-secrets).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 15:14:04 -07:00
hanzo-dev e084b3cc77 o11y: surfaceApp composes the way the real process does
TestTheThreeAddressesAreToldApartNotShared has been red on main since
"identity is the composer's" moved cloud.Bridge out of every subsystem and into
cloud.App, which installs it at the root ahead of every typed route. surfaceApp
was not updated, so it mounted o11y onto a bare zip.App with no Bridge — and a
typed op with no Bridge has no validated org on its context, so it answers 403
to everything, including the RED read this test asks for its OWN datastore
refusal.

That reads like a live outage of the o11y surface and is not one: the real
process composes through cloud.App. A harness that stops composing the way the
process does does not measure the process, and this one was reporting a
middleware it never installed as a broken handler.

scopeApp already says exactly this, one file over. surfaceApp is where it was
missed. Package goes green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 15:13:05 -07:00
hanzo-dev fcbe3930e7 o11y: the span writer owes the trace summary, and traces have a collection
event.trace is the summary the read plane resolves a trace id against, and it
is fed by the SPAN WRITER — one partial row per trace per batch, folded by an
AggregatingMergeTree — not by a materialized view. hanzoai/o11y's own driver
does exactly this (pkg/datastoretraces/writer.go, traceSQLTmpl). This sink is
the production span writer and implemented event.span without it, so the
summary stayed empty while 710k spans piled up beside it.

An empty summary is not a missing feature, it is a silent outage.
TraceTimeRangeFinder resolves every trace_id predicate against this table
first, and a lookup that returns no row makes the querier SHORT-CIRCUIT THE
TRACE QUERY TO EMPTY (pkg/querier/builder_query.go, narrowWindowByTraceID) —
a missing summary row means "no spans exist", which is the one thing it did
not mean. The detail read, the waterfall, the flamegraph and every funnel
answered empty over a complete span table.

So: traceRowsOf folds the rows the span writer ALREADY built — one row-building
path, not two, with the four column indices pinned by a test so a reorder goes
red instead of corrupting the summary. end is max(start+duration), not
max(start): the longest span need not start last. The summary write fails SOFT
— a derived rollup must never be able to take down the fact ingest it is
derived from.

And GET /v1/o11y/traces, the org's trace LIST: the one address in the family
the module leaves open. It declares the detail (/traces/{traceId}), the field
catalog and three per-trace projections, every one of which needs an id this
read is where you get — the detail was reachable only by someone who already
knew the answer. Claiming the collection and nothing under it keeps that a
composition rather than a second declaration at an address that has an owner.

Typed, declared at the ROOT at its full path so zipdoc can resolve it, org
pinned as a bound parameter on the table's leading sort key, no admin
widening: a trace list is a tenant's records, not a rollup over them. It
aggregates on read because a merge is asynchronous and never a promise —
skipping that reports a BATCH as a trace.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 15:06:59 -07:00
zeekayandhanzo-dev df1d1a2f68 deps: commerce v1.49.65 — native wire + crypto top-up rails
CI/CD / image (push) Successful in 18m24s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m36s
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
Brings GET /v1/billing/wire (brand receiving-bank details, payer-attributed
reference), GET /v1/billing/crypto/options and POST /v1/billing/crypto/deposit
(per-payer MPC custody addresses) onto the served billing surface, plus the
$5 pay-as-you-go floor (topupBounds min 100→500 cents). v1.49.65 is the merge
of the forge lineage (zip v1.24.2, these rails) with the GitHub lineage
(dual-license, billing payment/invoice cores) — both remotes now converge.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 15:03:50 -07:00
hanzo-dev ed984dbedc ci: host-is-light measures the property, not its proxy
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 gate refused ANY apps/* import from cmd/cloud as a stand-in for "links no
subsystem graph". The stand-in went wrong the moment an app's EDGE had to run in
this process: cmd/cloud/sites.go serves <slug>.hanzo.app, and it must, because
the image runs this binary on the public port — a published site mounted anywhere
else is served nowhere, which is the outage that put it there.

MEASURED on origin/main: cmd/cloud links 392 packages, inside the ~395 the gate's
own prose names, and apps/sites pulls 2 cloud packages. Nothing grew. The gate has
been red for a day over a graph that never changed — and because every later car
declares `needs:` on it, that is the entire release train stopped by its own
approximation. Live is v1.801.431 while tags reach v1.801.436.

So the exception is NAMED with its reason, the discipline go-unit's -skip list
already follows, and the property it approximates is measured directly beside it.
Two checks, because they are two questions: the name catches a subsystem leaking
in, the count catches an ALLOWED leaf that quietly grew a graph. A widened pattern
would have answered only the first and silently given up the second.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:36:43 -07:00
antje 9396df7021 identity is the composer's: one constructor, and no subsystem asserts it
CI/CD / image (push) Failing after 33m28s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m38s
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 12s
cloud.App(name, cfg, deps, tools) now builds every program — the host and all
125 plugin programs — and installs the canonical chain through identity in one
place. Identify states the order invariant once: the boundary that mints the
validated org runs before the enrichment that parks it, and neither exists
without the other. Middleware order at the host is proven byte-identical
before and after (18 entries), so a refused request is still audited.

Every subsystem self-install of the enrichment is deleted — 74 sites across 67
packages. A subsystem asserting identity for itself repeats a claim it cannot
check; two of those copies sat on nodes owning no routes, which zip refuses to
compose, and that is the outage that took /v1/o11y and /v1/integrations down.
The childless spelling is gone everywhere: a gate passed TO Group is installed
at the root bounded by its prefix, and the two-step form that slipped past
that (a bare Group then Use on it) is collapsed at its last holdouts —
referrals' three gates and the audit fixtures.

Tests stop rebuilding the composer by hand: each package that mounts on a
bare app owns one compose helper, so anonymous cases still refuse and
principal-carrying cases reach the handler exactly as production does.

Also fixed, found by the constructor's own test: the markdown negotiation
replaced the Vary header CORS had written, so every CORS response on every
program advertised Vary: Accept — a shared cache could hand one origin's body
to a different origin. The later writer is additive now.

Census: zero non-test enrichment installs under apps/, zero childless chains.
Build and vet clean over the whole tree in the shipping mode.
2026-08-04 14:33:13 -07:00
hanzo-dev d5c5b44fcb team: the account store reads through orm, not database/sql
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 workspaces + members tables were 443 lines of hand-written database/sql:
positional placeholders, a hand-rolled scanWorkspace whose Scan matched wsCols by
POSITION, and a prefixed() that rebuilt the projection as a string. They now go
through hanzoai/orm's relational plane — orm.Select/orm.Typed for the reads, the
dbx builder for the writes.

Not orm.Model. That is the document plane over the JSON _entities table, and
these tables are typed and indexed with composite uniqueness ((owner_org, slug),
(workspace_id, user_id)) that a json_extract store cannot hold; orm's own guidance
names this file as a dbx target for exactly that reason.

The handle still comes from cek and orm is ADAPTED onto it. orm's SQLiteDBConfig
carries no master key, so orm.OpenSQLite would have written this store — every
workspace, membership and display name — as a plaintext `SQLite format 3` file.
That is the regression apps/iam documents removing, and it is not reintroduced
here: encryption at rest is a property of who opens the file.

The positional Scan is gone with it. wsCols and the struct now agree by `db` tag,
so a column added without a field is a mapping that does not resolve rather than a
silent shift of every value one position left.

THREE STATEMENTS STAY VERBATIM, each because the builder cannot say the thing that
makes it correct, and each now running through orm's own NewQuery rather than a
database/sql handle — so the file has ONE data path:

  migrate — CreateUniqueIndex emits neither IF NOT EXISTS nor a WHERE, so it can
  express neither the idempotence nor the PARTIAL (owner <> '') index; dbx.Sync
  writes no indexes at all.

  the EnsureWorkspace create — dbx's Upsert only ever emits DO UPDATE SET, and
  there is no seam for a conflict target carrying the partial index's WHERE. As a
  DO UPDATE the meaning INVERTS: the racing loser would overwrite the winner
  instead of yielding to it, and the converge-to-one-workspace property is gone.

  AddMember — Upsert fans EVERY inserted column into the SET list, so a re-invite
  would overwrite joined_at and is_bot. joined_at is the order GuestRank ranks by
  and the guest cap admits by, so that is not a cosmetic overwrite: it silently
  reshuffles which guests keep access.

TestAddMemberPreservesJoinOrderAndBotFlag pins that last one at the four columns
the statement treats differently. Swapping in db.Upsert turns it red on three of
them (joined_at, is_bot, display_name) — checked, not assumed.

The test fixtures moved to the builder too, so no `?` placeholder survives in the
package.

apps/team + apps/analytics + apps/meet green, CGO_ENABLED=0 -tags sqlite_fts5,
against the ten pre-existing zip-compose failures already red on this commit's
parent.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:27:36 -07:00
hanzo-dev 9a6c3e4930 zen: mount the Claim in the one binary that serves the prefix it gates
zen composing was never the same as zen running, and only the first had been
fixed. It is Coresident: it answers no path and works by wrapping ai's "/v1",
routing zen-SKU requests and Next()ing the rest. A middleware cannot be a
separate process, so the light host deliberately skips Coresident apps
(cmd/cloud mount returns early) and plugin/zen exists only for the gen-app-cmds
bijection. plugin/ai linked only ai. The Claim was therefore mounted in NO binary
the fleet runs — which cmd/cloud had already recorded as "zen's child never saw a
request" — so every zen SKU served through ai's catch-all with zen's gate and its
meter never consulted.

zen mounts FIRST in plugin/ai, ahead of the greedy All("/v1/*") ai registers:
zip scopes middleware to the entries that follow it, so a Claim installed after
that catch-all would sit behind the very route it exists to gate.

MEASURED, on the real zen.Mount through the real MountAll:
  zen5   -> 402, reached host catch-all = false   (claimed and gated)
  gpt-4o -> 200, reached host catch-all = true    (falls through untouched)

Both halves matter. The first is the gate doing its job; the second is the proof
zen did not become a wall in front of the whole model API.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:26:03 -07:00
hanzo-dev 2f4a033109 zen v1.4.9: streamed calls meter what actually arrived
v1.4.4 billed every streamed call from an accumulator no chunk had touched.
c.SendStreamWriter hands its closure to fasthttp, which spawns it on its own
goroutine (stream.go:43) and returns immediately, so the recordCtx that followed
read usage.tally() before a byte arrived: completion -> 0, cached -> 0 (the whole
prompt re-billed as fresh) and ResponseID -> "", which is the join key /v1/feedback
and the learning ledger need to tie streamed traffic to its own feedback.

Reproduced here before bumping, on the exact shape: `go test -race` reports the
write from the writer goroutine against the handler's read, and the value visible
at metering time is 0 where the streamed total is 50. It is not merely racy — the
closure genuinely has not run, so a mutex would silence -race and change no number.

Both same-dialect paths were affected (proxy.go stream, sse.go anthropic-native);
streamTranslated, buffered and ultra meter synchronously and were always correct.

The drift runs BOTH ways, which is why this is not just our lost revenue:
answer-heavy shapes under-collect 62-85%, and cache-heavy short answers OVERCHARGE
a live ledger by up to 4x.

v1.4.9 was cut for this — the fix existed on zen's main and sat in no released
tag, so every consumer was still on the defect. MEASURED at the boundary:
v1.4.8 fails `go test -race` at proxy.go:509; v1.4.9 exits 0 with no races.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:17:45 -07:00
hanzo-dev 8eaadd7066 commerce: keep main's error-scope harness, which fixed this a better way
CI/CD / image (push) Successful in 22m4s
CI/CD / gate (push) Successful in 42s
Hanzo CI/CD / cicd (push) Successful in 41s
CI/CD / containment (push) Successful in 2m40s
CI/CD / rollout (push) Failing after 5s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
The rebase produced a program that does not compile — `undefined: sv1`, so the whole
apps/commerce test binary failed to build, a package that was GREEN on main.

Both sides fixed the same zip v1.24 composition rule and picked different halves of
it. This branch dropped the /v1 group and moved the middleware to Use; main kept the
group and moved the ROUTE onto it, so the chain guards something. The auto-merge took
this branch's deletion of `sv1 := app.Group("/v1")` together with main's
`sv1.Get("/store/current", ...)` that still names it.

Main's shape is the better statement of what the test is for: the case is an envelope
that must apply to commerce's own route and must NOT clobber a sibling's, and putting
commerce's route back on the group is what production does (Mount's storeV1 group).
So this file returns to main's version byte for byte — `git diff a4a1f998 --
apps/commerce/errorscope_test.go` is empty — and the branch keeps no opinion about it.

No commerce money logic is touched, on this branch or in this commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:09:03 -07:00
hanzo-dev eb9625dfeb account, marketing, projects: regenerate the lift, and stop at the published document
The zipdoc -check gate at the top of `make test` was red on three packages, so the
suite aborted before a single test ran. The lift is regenerated from source; all 99
directories carrying the directive now answer -check clean.

What the regeneration is, measured rather than assumed:

  marketing   prose only. Operation set identical, field-doc set identical.
  account     prose, plus doc comments for onboardResp.accessKey/accessSecret.
  projects    prose, plus a doc comment for projectsProject.key, plus the lift for
              POST /projects/resolve-key (an internal cloud.Plane op whose file
              landed without a regeneration).

The extra entries are DESCRIPTIONS for fields that already exist: AccessKey and
AccessSecret are account.go:604-605 and Key is projects.go:159, all three already
carrying the comments lifted here. zipdoc supplies prose; a schema property comes
from struct reflection at describe time. So this adds documentation to the surface
and no field to it.

THE PUBLISHED ARTIFACTS ARE DELIBERATELY NOT INCLUDED. Regenerating
plugin/{account,marketing,projects}/openapi.json from this same source does NOT
come out prose-only, and it is not this commit's business to land it quietly:

  - plugin/account: onboardResp gains accessKey + accessSecret as published
    properties, and THREE operationIds are renamed — post_v1_orgs -> v1.post_orgs,
    post_v1_keys -> v1.post_keys, delete_v1_keys -> v1.delete_keys.
  - plugin/projects: projectsProject gains a published `key` property.

The rename is zip's scheme change, not ours, and the fleet is mid-migration: the
committed account subset still carries the old form while marketing and projects
already carry the new one. Every SDK is generated from these files, so completing
that migration renames methods for callers and is one deliberate fleet-wide act
with an owner behind it, not a side effect of unblocking a test gate. The same
change is what TestTargetOpsProjectEverywhere has been red about (it asserts
post_v1_agents_targets and zip now emits v1.agents.post_targets).

Verified identical on a pristine a4a1f998 worktree, so none of the drift above is
this branch's: it is the published document catching up to source that already
landed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 13:57:20 -07:00
hanzo-dev c4038831f5 risk: drop the interim pin the refactor superseded
main answered the red ratchet by RECORDING apps/risk/policy_wire.go in
allowedRequestUses (f345ac70). This branch answered it by removing the second call
site instead — caller() moved beside ops.gate, which already reads that same
X-User-Id header, so the package reaches for the raw request in one file and the
pin stays one entry.

Both landed, so the rebase left both: the entry describes a call site that is no
longer there. The gate catches that in the direction people forget — it walks
allowedRequestUses and fails on any file that no longer calls cloud.Request, so a
pin cannot outlive the code it justifies. Removing it is what makes the ratchet
green, not an exemption.

Net effect against main: the escape hatch does not grow. apps/risk/typed.go states
both reasons on the one entry it already had.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 13:43:46 -07:00
hanzo-dev 1b9b5fd704 zt: one Bridge, path-bounded, so the collection root is not the one route without it
GET /v1/networks answered 403 "X-Org-Id required" to a validated caller. Bridge was
installed on the group, twice — ng.Use and mg.Use — and middleware on a group wraps
what is composed beneath it. The two collection ROOTS are declared on the App with
their whole path on purpose: joining "/v1/networks" with an empty leaf yields
"/v1/networks/", a different address from the one they have always served. So the
group's Bridge covered /v1/networks/routers and /:id and missed /v1/networks itself,
the op read no parked org, and the tenant gate refused a request that was fine.

One Use replaces both. On a scope it is bounded by the prefixes the manifest already
declares for zt (/v1/networks and /v1/mesh/services), which is every route here and
nothing else; on a bare app — what this package's tests mount on — it is app-wide,
which is what the tests want. The groups stay as what they are, path prefixes.

TestBridgeIsInstalledOnEveryPrefix already stated this and had been red: it asserts a
validated caller is SERVED on all four routes and an anonymous one still refused.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 13:42:07 -07:00
hanzo-dev bd2c284e4a agents, deploy: the subsystem's door is the router it was handed, not a node inside it
Two more of the shape the previous commit fixed in nineteen places, found by the
gate rather than by reading: apps/deploy panicked the fleet's own surface-check
(`the group "/v1/deploy" declares middleware at deploy.go:316 and no routes
anywhere beneath it`), and apps/agents failed 54 of its own tests.

deploy is the clearer one. deploy.go built app.Group(dashPrefix) to hold
cloud.Bridge + bounce, and dashboard.go builds ANOTHER app.Group(dashPrefix) to
hold the routes. Group returns a new definition per call, so those are two nodes
at one path: the middleware sat on the empty one and NEITHER the bridge NOR the
sign-in bounce ever ran for a single route of that surface.

agents is the same mistake with a subtler tell, because its group is not empty —
/metrics, /activity and the :ref leaves ARE beneath it, so nothing panicked. The
rest of the surface is not: the collection root, mountSessions and mountTargets
all register on the Router by absolute path. So Bridge parked no org for
/v1/agents/targets or /v1/agents/sessions, and every op under them answered 403
"X-Org-Id required" to a request that carried one.

It only ever showed up in tests, and that is the part worth keeping in mind:
Serve installs a Bridge app-wide, so serving was unaffected and only a bare Mount
could see the hole. TestHTTPTargetRejectsOversizeGPUList is the example — it
asserts 400 for an oversize GPU list, got the 403 first, and so had never once
exercised the bound it is named after. It passes now for its stated reason.

No bound widens. Every prefix either subsystem declares is under its own group's
path (manifest/apps.go), so a scope confines this to exactly what the group named
and the plugin binaries serve nothing else.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 13:40:46 -07:00
hanzo-dev 82f3b7af3e risk: the request seam is one file, so the escape hatch stays one pin
TestRequestEscapeHatchIsPinned was red on main: apps/risk/policy_wire.go called
cloud.Request from a second file in a package that already had one, and the pin knew
nothing about it.

The fact caller() needs is the validated user id (X-User-Id) a policy version is
recorded against, and nothing parks that in the context — principal parks the org and
validated-ness, so principal.OrgFrom and principal.ValidatedFrom cannot answer it, and
an attribution the caller could state in a body is not an attribution. So the request
is genuinely required. What was not required is a second call site for it: ops.gate,
in typed.go, already reads that exact header for the meter's actor.

caller moves there, beside gate. Two functions, one seam, one pin — which is the shape
the map's own entries describe for wallets, tools, deploy, usage and o11y, and the
reason it is a per-FILE list. The pin's existing apps/risk/typed.go entry now states
both reasons; no entry is added, and the escape hatch does not grow to make a test
green. The function itself moves byte for byte.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 13:40:45 -07:00
hanzo-dev 83cd93019c apps: middleware wraps a subtree it is composed into, not a prefix it names
Nineteen sites carried the same defect scope.Use had, at the other door: a group
created with middleware, its routes registered on the App with their whole paths.
Middleware on a group wraps what is composed BENEATH that group, so every one of
those groups was empty — the middleware never ran, and since zip v1.20 the program
does not compose at all, which is a panic at mount for the plugin and an aborted
test binary for the package.

TWO GATES HAD SILENTLY STOPPED RUNNING, and that is the part that matters more than
the panic. apps/referrals put requireOrgOnWrite on a /v1/referrals group and
requireAdmin on each of the two /v1/admin/referrals boards, and registered all three
leaves on the App: the write gate never ran on POST /v1/referrals/claim and the admin
gate never ran on EITHER board. apps/team installed Bridge on one /v1/team group
while every file builds its own group from the same constant — the same routing
subtree, a different definition — so the bots, files, blob and cookie planes ran with
no validated org and answered 403 to valid requests (eight tests, all of which were
unreachable behind the package's own panic).

The identity and error-shape middleware moves to Use, which a scope bounds to the
prefixes the manifest declares for the subsystem and which is app-wide under a bare
Mount — admin, admission, auditlog, catalog, guide, integrations (x2), label,
marketing, prefs, projects, team, templates. referrals keeps three different bounds
and states each as the predicate a group prefix was standing in for (under), the same
shape apps/commerce already uses for its error envelope. o11y installs on its own
App, which its routes are already beneath. In team the install moves ABOVE the group
it used to sit on: fiber runs middleware in registration order, so one added after a
subtree never wraps it.

Five test harnesses mirrored the invalid shape on a bare app and move the same way.
No address changes and no gate widens: every bound here is the one the group prefix
named.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 13:40:39 -07:00
hanzo-dev a4a1f99852 event: the published contract says what the door does
CI/CD / image (push) Successful in 23m20s
CI/CD / gate (push) Successful in 1m16s
CI/CD / containment (push) Successful in 2m39s
Hanzo CI/CD / cicd (push) Successful in 1m16s
CI/CD / rollout (push) Failing after 7s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
The door description still promised "NO CREDENTIAL IS ALSO ADMITTED ... filed
under the reserved $public tenant" — and it goes into the OpenAPI document
customers read. The code has refused since the key work landed: 401
ingest_key_required with nothing presented, 403 ingest_key_unknown for a
credential that names no project. A contract that promises the opposite of the
code is worse than no contract, because a client writes against it.

The projection survives for the reduced principal it was narrowed for — a
workspace token writing into its own org.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:49:29 -07:00
hanzo-dev f345ac70ae pin the two cloud.Request uses that were leaving main red
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 escape-hatch ratchet has been failing on origin/main, naming
apps/risk/policy_wire.go and then apps/commerce/invoices.go. Both are
legitimate; neither was recorded, so the guard could not tell them from a new
one and main stayed red.

A red main is not a cosmetic problem here. Three of today's near-misses got as
far as they did because "the tests pass" had stopped meaning anything, and the
next reader has to re-derive whether each failure is theirs. So the fix is to
answer the pin, not to loosen it.

  apps/risk/policy_wire.go   caller() reads c.User() for an attributable policy
                             record. No ctx helper answers it — OrgFrom gives
                             the tenant, not WHO changed the appetite bounds.
                             Off the HTTP path it returns empty and plane.enact
                             refuses, so a change is never recorded anonymously.

  apps/commerce/invoices.go  eventsFrom/kmsFrom lift two request-scoped side
                             channels out of c.Locals(), which no ctx helper
                             exposes. Both optional by design: a missing
                             analytics collector must never fail a money move,
                             and a missing KMS client is the dev/test posture.

The root package is green again.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:34:11 -07:00
hanzo-dev 6ca28c2e5e merge main
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 12:25:38 -07:00
hanzo-dev 282f13b2cd reference: the device aggregate names its signal
event.fact is not a rename of event.event, it is a merge — five signals in one
table. Moving the source without naming the signal counted errors and spans as
device observations, and this is the one cross-tenant reader, so a phantom
identity inflates both k-anonymity floors: 425 identities unfiltered against
423 real ones on the live plane.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:25:27 -07:00
hanzo-dev 024e9bacc2 one name for the CORS allowlist
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
config.go read CLOUD_CORS_ORIGINS and fell back to GATEWAY_CORS_ORIGINS, the
pre-rename gateway spelling, "shared with the gateway so both trust boundaries
agree on one list". Measured across every chart in universe, GATEWAY_CORS_ORIGINS
is set ZERO times — by cloud's chart, by the gateway's, by anyone — so the shared
name agreed on nothing. cloud's own values file sets CLOUD_CORS_ORIGINS.

Two spellings for one security-relevant allowlist is a way to half-configure it:
set the dead name and the browser silently gets no ACAO. No test pinned the
fallback, and the direction of failure on removal is restrictive — an origin that
is not listed is refused, never admitted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:25:18 -07:00
hanzo-dev f4335f09bc drop the consolidation queue: it routes new work into an architecture that is gone
docs/consolidation.md was the execution queue for merging the standalone Go
services into one binary. Nothing references it, and every mechanism its Method
section instructs the next wave to use has since been deleted:

  apps/apps.go            no such file
  apps.Wire               no such symbol
  cloud.Register          no such symbol (only unrelated seam registrars remain)
  "order < 150"           there are no orders; manifest/apps.go is the table
  clients/<svc>/Mount     an app is plugin/<name>/main.go, one binary per app

Its inventory has drifted the same way — "37 native apps/*" against today's 120
manifest rows, three of the apps it names (paassvc, console, prompt) no longer
exist, "Wave 1 — THIS build" shipped long ago, and its CGO_ENABLED=0 "production
parity" rule is the opposite of what the Dockerfile does for plugins
(CGO_ENABLED=1 + libsqlite3 + sqlite_fts5 + sqlite_math_functions). A doc that
tells the next engineer to write code against a registration mechanism that does
not compile is worse than no doc.

The durable half — which tiers stay out of this binary and why — is not lost: the
edge/data-plane split is in README.md, and the isolation reason for identity is
stated where it binds, in apps/iam/iam.go. If that table is wanted as a fleet-wide
statement it belongs in LLM.md, which is the one doc this repo maintains, not in a
migration queue.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:25:12 -07:00
hanzo-dev 4fbb3c8727 one way to boot-and-probe: drop the third, uninvoked smoke script
scripts/smoke-runtime.sh said it was "intended for CI smoke gating", and CI
never called it — nothing in this repo, in .hanzo/workflows, in the Makefile or
in universe named it. The role it claimed is already served twice over, by
things that ARE invoked: `make smoke` runs plugin/smoke, the release gate's
functional prober, and `make e2e` runs e2e/run.sh, which builds the host and its
plugins and boots them for the Playwright suite.

It was also the weakest of the three. Five hardcoded probes against
plugin/smoke's one read per subsystem, with none of the contract that makes that
prober worth running — no 402-on-a-read rule, no 5xx rule, no authed/tolerant
distinction. And it reached for `curl -fsS`, the flag that turns a failing probe
into a silent one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:25:12 -07:00
hanzo-dev 77d7a5529c drop the Python-era Postgres migrator, and the docs describing a build that is gone
migration/ + plugin/migrate-pg-to-sqlite ported a `hanzo_cloud` PostgreSQL
database into per-org SQLite. That database belonged to the deployed cloud-api
PYTHON service, which this repo replaced; the runbook the tool cites
(CLAUDE_PG_TO_SQLITE_MIGRATION.md) no longer exists, no Makefile target, CI job,
compose file, chart or sibling repo invokes it, and it is absent from
manifest/apps.go so the image never built it. Its only two mentions were the
generator's exemption list and a comment naming it as an example. A one-shot
import for a service nobody runs is not part of v1.

The bijection test derives tool-ness by PARSING for a cloud.Listen call rather
than matching a name list, so it needed nothing; only gen-app-cmds' notApps map
did.

LLM.md's release section described an architecture that was deleted with the
fused binary: a `hanzo.yml binaries:` lane publishing ./cmd/cloud to S3, every
app resolving to "the multi-call binary with a different --enable", pinned by
TestRemote_DedicatedBeatsMultiCall. hanzo.yml now states NO binaries lane and
gives the reason; remote() looks up exactly name/os/arch with no multi-call
fallback; and the cited test was replaced by TestRemote_NoMultiCallFallback,
which asserts the opposite of what the doc claimed. The line numbers it quoted
had come to land on the comment saying the lane was removed. Corrected to what
the code does, and CLOUD_PLUGINS named for what it is: a supported input with no
producer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:25:08 -07:00
hanzo-dev 4203c7f284 templates: every source in the gallery now resolves
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 2m25s
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 catalog shipped 66 entries and 45 of them pointed at repositories that do
not exist. github.com/hanzo-templates/<slug> 404s for all of them, authenticated
or not, and their demo URLs 404 too — a gallery where two thirds of 'fork this'
leads nowhere.

The work was never missing, only misfiled. Those templates live in hanzo-apps
under a template- prefix, which is the org convention for a static site; the
catalog was written against a hanzo-templates layout that only 21 of them ever
moved to. 66 template-* repos in hanzo-apps, 66 entries here — the sets match.

  21 kept   real in hanzo-templates (expo-*, flutter*, swiftui*, desktop-*)
  42 fixed  repointed to hanzo-apps/template-<slug>
   3 dropped innovise, kalli, unfixed — no repo in any org, and a catalog entry
             that cannot be forked is worse than an absent one

All 63 remaining sources verified 200 against the GitHub API before this landed,
not assumed from the naming rule.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:23:53 -07:00
hanzo-dev cf8c847661 readers: name the signal, not just the table
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:14:47 -07:00
hanzo-dev 2d728e488d read the live event plane, and say which signal
The plane's product-event table was renamed event.event -> event.fact. Writers
moved; these four readers did not, and nothing errored because the old table
still exists — it just stopped receiving rows on 2026-08-02. A frozen table
answers every query, so the failure was stale numbers, not an outage: the GTM
funnel was still reporting $13.37 of revenue from July.

event.fact holds every signal in one table (act, clip, error, log, span), so
the rename alone is not the fix. Each read pins signal='act', the predicate
apps/analytics already carries in scope(). Without it the funnel counts 85
visitors where 83 acted, and risk folds a person's error rows into things
they did.

risk binds the signal as a literal rather than a positional arg: all four
rollups share one 5-arg storeExec call, and the SQL already spells 'person'
and 'session' the same way.

Verified against the live warehouse — every query returns growing data where
it returned frozen, and apps/risk tests pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:06:49 -07:00
hanzo-dev 30fb768e7b o11y: tell the three addresses apart instead of taking them
CI/CD / image (push) Successful in 25m2s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 3m2s
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / rollout (push) Failing after 7s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
hanzoai/o11y went native and now declares all 367 of its routes by name.
There is no wildcard left for a host route to shadow, so the three
addresses cloud also declared stopped being a silent precedence artefact
and became a refusal to compose — the plugin crash-looped and every
/v1/o11y read 503'd.

o11y.Claimed(...) made it boot, but a claim SUPPRESSES the module's
declaration. It did not resolve the collision, it just picked a winner and
wrote the choice down: each of the three quietly cost the fleet the
module's real read at that address, and one of them was cloud's forward
into a runtime path that no longer exists. What the addresses needed was
to be told apart.

  GET /v1/o11y/logs — DELETED. Not a stub (it was a real two-view read
  over event.log/event.span) but it had NO caller: the console reaches
  logs through the query engine, and nothing in cloud calls it either.
  An address nobody calls is not a contract. The module's real
  log-record read answers there now.

  GET /v1/o11y/metrics — MOVED to /v1/o11y/product/metrics. The module's
  is the metric-NAME CATALOG; ours is one product's RED window keyed by
  ?product=. Two questions, so two names — ours says which one it
  answers, and the module keeps the bare name. Live in the console
  (App Platform drawer + canvas sparklines); patched there on the same
  branch.

  POST /v1/o11y/query_range — DELETED, along with POST /v1/o11y/query and
  the whole of query.go. Both pinned /api/v3/<resource> to reach the v3
  engine. The runtime registers every route at its full public path and
  dropped prefix-stripping, so it serves no /api/* route at all, and
  queryRangeV3 has no caller left — the forward reached the terminal /*
  catch-all, not an engine. A route that forwards to an address nothing
  serves is dead. The module's v5 querier answers there now.

Cloud therefore claims nothing: o11y.Mount(a) takes no options and the
boot log reads claimed:0. A host that has to name the addresses it takes
is a host that took addresses it did not own.

STILL OWED, and it is a console change: the trace/log explorers send a v3
composite that v5 refuses with unknown field "queryType". They were
already broken before this (the v3 pin reached the catch-all), so nothing
regressed — an opaque non-answer became an honest 400. The v5 migration
is recorded in apps/o11y/LLM.md.

The route table is unchanged in size: 389 method+path pairs before and
after. Only two lines move — /v1/o11y/product/metrics arrives and the dead
POST /v1/o11y/query leaves — because the module reclaimed exactly the
addresses cloud vacated.

openapi.yaml is the whole-fleet weave and cannot be regenerated per app,
so it also catches up on two other lanes' already-committed subsets
(billing +5 paths, payments +2) that it was stale against.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:05:54 -07:00
hanzo-dev ee911cd961 iam v1.34.20 — a refused front-door call no longer reads as a completed one
Hanzo CI/CD / cicd (push) Successful in 14s
CI/CD / gate (push) Successful in 15s
CI/CD / containment (push) Successful in 2m1s
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
IAM's error envelope rode on HTTP 200, so an SDK checking the transport before
the body read a refused signup as a successful one. The envelope is unchanged;
the status now agrees with it.

cloud's own IAM client is unaffected by construction: iamClient.do parses the
envelope whatever the status and decides on status != "ok", so it reached the
same verdict before and after. The bump is what makes the two agree at the wire
as well as in the body.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:57:27 -07:00
hanzo-dev c6f88a5d15 errmap: an error that decided nothing says nothing
The unclassified branch rendered err.Error() verbatim as the 500 body, so a
customer's first fault told them about ZapDB migrations, CLOUD_KMS_MASTER_KEY_REF,
http://iam.hanzo.svc and features that are "not yet implemented". That text is
written for an operator; putting it on the wire published our internals to
whoever tripped it.

The rule is provenance, not status. An *HTTPError or a *fiber.Error was
constructed by a handler that chose a status AND a sentence — a decision the seam
does not second-guess, which is what keeps "Billing temporarily unavailable"
readable. An error that chose neither now renders a stable sentence naming the
request id instead.

The detail is not lost, it is relocated: ErrorHandler logs every 5xx whole, with
method, path and the X-Request-Id the response carries, so the log line and the
response a caller holds identify each other. The wrapped chain reaches the
operator, which is more than the client ever saw, since the client only ever got
the outermost sentence.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:57:27 -07:00
hanzo-dev fd661747d7 account: a landing is not a tenancy, and a minted credential is revealed once
Federated sign-up files a brand-new user under the sign-up application's own
organization (iam internal/oidc/federation.go: `org := app.Organization`), so
the first request a new customer ever makes already carries an X-Org-Id. onboard
read that as "already has an org": the first org they asked for took the
ADDITIONAL branch, which creates an org and leaves the founder outside it, and
`personal: true` answered 409 "you already have an organization" — true of the
landing org, and useless to someone thirty seconds past signing up.

The orgs a sign-up can land in are exactly the ones this package already refuses
to hand to a customer (onboarding.go's reservedOrgs). One list, one fact, asked
twice: an org no customer may CREATE is one no customer can be said to OWN.
Naming the set rather than a single brand constant keeps a white-labelled
deployment correct, where the landing org is that brand's own.

Standing beats the landing. A SuperAdmin IS a member of the reserved `admin`
org, so treating that as a landing and moving them out would strip the privilege
it defines; an org admin therefore always counts as owning their org, read from
the authoritative IAM row rather than a header, since the answer is the one that
MOVES a user. A caller already in a real tenant is spared the read entirely, so
an invited member creating a second org is never yanked out of the team that
invited them.

The provisioning response now carries the credential it minted. IAM stores the
argon2id digest and blanks the plaintext, so the secret is readable exactly once
— in the answer to the call that mints it. Dropping it left a customer holding
an account whose credential had been issued and could never be obtained; a
replay, which mints nothing, reveals nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:57:27 -07:00
hanzo-dev 4f6adecac7 reconcile: the forge main into the analytics landing
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 11:55:56 -07:00
hanzo-dev d9220cf140 analytics: retire $public in the readers the door no longer feeds
Merging the three analytics branches leaves publicTenant named where it no
longer exists: the heatmap tests normalize under it, and five comments describe
a lane the key work deleted. The tests take a real org like the rest of the
file; the comments say what the door does now — the org is the reduced
principal's own, resolved from its credential.

apps/reference read event.event, which stopped receiving rows on 2026-08-02
while the plane moved to event.fact. Columns are identical, so the device
aggregate was grouping a frozen table.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:54:57 -07:00
hanzo-dev 03b86fc5e3 openapi.yaml: re-weave for the union of the two mains
CI/CD / image (push) Successful in 20m51s
CI/CD / gate (push) Successful in 13s
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / containment (push) Successful in 2m26s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The golden is DERIVED from plugin/*/openapi.json, and the merge of origin/main
into forge/main brought a subset the forge-side golden predates: 1969abfc gave
POST /v1/runner's description its release: true SUPERADMIN paragraph. Weaving
the committed subsets reproduces every other byte, so the whole delta is those
three lines of prose.

No route moves — 1695 paths before and after, none added, none removed, and the
floor ratchet is untouched at 179 products. TestFleetIsTheWeaveOfItsApps and
TestTheServedDocumentIsTheArtifact both read this file and both go green.

Only the weave step ran. `make describe` also regenerates the subsets from
code, and that step is broken on BOTH mains independently of this merge — forge
fails first in zipdoc on apps/o11y/annotation_queues.go (which origin's dc013156
fixes), and past that the authors app is refused for installing middleware at
/v1/admin/authors, outside the /v1/authors prefix it owns. Neither is this
merge's to decide, and neither touches the derivation performed here.
2026-08-04 11:53:17 -07:00
hanzo-dev f4dca1e8f1 merge main
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:52:59 -07:00
hanzo-dev 3d995822c6 reconcile: origin/main into forge/main
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:46:05 -07:00
hanzo-dev e74c684dae merge main into the zip/o11y bump
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:39:55 -07:00
hanzo-dev 00db565e17 zip v1.24.3, o11y v1.5.58
zip removed App.Shadow — the verb that let an op be declared in one scope and
answered in another. Cloud never referenced it, so nothing here changes shape.
o11y v1.5.58 carries the same removal plus one narrowing of under().

Proven route-neutral: the woven fleet document is byte-identical before and
after (1695 paths, 3676621 bytes, cmp clean), so no SDK repo sees a route move.
Suite: 161 packages ok, 143 failing tests, and the failing set is byte-identical
by NAME to main's — regression set empty.
2026-08-04 11:39:25 -07:00
hanzo-dev 0fad078443 event: serve the tag that feeds the door, and make it inert without a key
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:29:35 -07:00
hanzo-dev 2900f6fa0b analytics: a logged-out click may carry where it happened
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:29:31 -07:00
hanzo-dev 422375dc5f analytics: a project mints its key, and the door refuses what it cannot attribute
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:29:23 -07:00
hanzo-dev 1969abfc18 platform: cutting the fleet's own binary is platform authority
`release: true` on POST /v1/runner publishes releaseImage — ghcr.io/hanzoai/cloud,
the binary every service in every org runs. The gate read

        principal.IsSuperAdmin(c) ||
          (imageInOrgRegistry(releaseImage, org) && principal.IsOrgAdmin(c))

and the second term is not a narrower platform predicate, it is a self-service one
wearing a registry map. `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 `hanzo` is the deployment's brand org in every deployment, and the one
orgRegistryNamespaces gives `hanzoai`. So the far side of the gate chose its own
callers, and what it chose them for was the tag the whole fleet rolls onto at the
next reconcile: iam, kms, gateway and every customer app, in every tenant.

Owning the registry namespace is the right bound for an ordinary PUSH, and it stays
there. A push lands ONE tenant's artifact in the namespace that tenant owns, so the
caller's own org is exactly what should bound it. A release lands OURS on everyone,
so no property of the caller's own org can be what admits it. The two lanes part
company at that one fact, and nowhere else on this endpoint.

SuperAdmin <=> `owner == "admin"` is the ONE platform predicate, so the gate is now
cloud.Super and nothing conjoined to it:

        func mayRelease(c *zip.Ctx) error {
                if !cloud.Super.Admits(cloud.AuthorityOf(c)) { ... }
        }

Validated is part of that scope, which collapses the MACHINE arm into the same
expression rather than a second rule beside it: PLATFORM_BUILD_CALLBACK_TOKEN mints
no principal, so a leaked build token still enqueues an ordinary build and still
cannot cut a release — the property TestRunnerRelease_SharedTokenCannotRelease
already pinned, now held by the scope itself.

READING a release took the same org-scoped gate (mayReadReleases), mirrored by
comment. Both are now the one function, so "the 202 hands back an id its caller may
ask about" is true by construction. Nothing was widened: the published contract for
both GETs already read "SuperAdmin only — this is the platform's own publishing
record, not a tenant surface", and the code did not do that. The /v1/runner prose
said cutting a release was "IAM's decision alone", which was true of the credential
and silent on the scope; it now names SuperAdmin.

The production release path does not go through this gate at all — a merge to
cloud's own main dispatches launchRelease in-process (push.go, isReleasePush), so
what tightens here is only the hand-cut, and the CLI cannot even send the flag
(cli.BuildReq has no Release field).

TestReleaseSurfacesTakeOneAuthority drives the REAL handlers across all three doors
— cut, list, read-one — over the role axis, and asserts they agree principal for
principal. Against the parent the brand-org ADMIN case fails with the escalation
verbatim, on every door:

    cutting a release: admitted=true, want false (HTTP 502 — resolve main: ...)
    listing releases: admitted=true, want false (HTTP 200 — {"data":[]})
    reading one release: admitted=true, want false (HTTP 404 — no such release ...)

The 502 is the tell: the seams are stubbed to 500, so a request only reaches the
pipeline by having been authorized. It holds the SuperAdmin row on all three doors
too, so merely disabling the path would not pass, and TestRunnerRelease_-
OwningOrgAdminRefused pins the cut on its own.

It replaces TestReadingAReleaseIsNotStricterThanCuttingOne, which read runner.go
and release.go as TEXT and asserted both mentioned the same predicate NAMES.
Spelling was all it could ever see: it was green while both surfaces contradicted
the published contract, and it would have stayed green had the two admitted
different callers under the same names.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:29:01 -07:00
hanzo-dev dc01315626 o11y: declare at the root the way zipdoc can SEE, and unblock the build
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m57s
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 release since v1.801.427 has failed. The image build runs
'go generate -run zipdoc ./...' and it exits 1:

  zipdoc: apps/o11y/annotation_queues.go:94:10: zip.Get: cannot resolve the path
  prefix of the router this op registers on

That is why seven plugins — templates, integrations, admin, guide, marketing,
prefs, o11y — answer 503 'no instance running' in production while the fix for
them sat on main unable to ship.

The cause was under{}, introduced to declare o11y's typed ops at the ROOT so
their ids stay unqualified and their schemas keep the app's origin. The goal is
right; the mechanism was a composite literal, and zipdoc resolves a router's
prefix only from a .Group() call or a variable assigned from one. A custom
OpTarget is invisible to it, and zipdoc treats what it cannot resolve as an error
rather than assuming an empty prefix — correctly, since a wrong prefix files
prose under the wrong identity.

Same intent, statically visible: register on the app with the full path,
o11yPrefix+"/reviews". The router is the *App (root, no prefix) and the path is a
constant expression the type checker folds, so zipdoc reads both. Declaration
stays at the root, so the ids and schema names under{} protected are unchanged.

Addresses are byte-identical — the regenerated zipdoc_gen.go changes by pure
ADDITION: GET /v1/o11y/sessions is documented now, because zipdoc could not reach
it before. under.go is deleted; nothing else used it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:24:35 -07:00
hanzo-dev 07f8e717b8 docs: the ingest door's first refusal is admission, not a capture flag
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:17:51 -07:00
hanzo-dev 688ac687ae analytics: a site's events are attributed by the key its project minted, or refused
A project mints a publishable key at create — the ONE thing that attributes its
site's events. The key resolves to (org, project), so it names the site as well
as the tenant, and a key no project holds resolves to nothing: delete the
project and recording stops, structurally.

Removes the two attribution paths that were not that:

  - the keyless lane. A beacon carrying no credential was ACCEPTED into a
    reserved `$public` tenant and answered {"accepted":1}. No org could read
    that partition, so every such caller lost everything it sent behind a 200.
    Three first-party properties shipped keyless without one failed build.
    handle now refuses: 401 ingest_key_required with no credential, 403
    ingest_key_unknown with one that names no project.

  - the site-host carve. A POST to <slug>.hanzo.app routed into the anonymous
    lane with a host-derived tenant — a second mechanism, and the one that could
    not be checked, since that middleware runs before the identity boundary. A
    site's beacon carries its project key to the ingest door instead.

The projection, its bounds and the opt-out gate survive for the reduced lane (a
team guest writes into the org that invited it, at reduced capability).
CLOUD_ANALYTICS_PUBLIC_CAPTURE gated the deleted lane and is gone with it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:16:36 -07:00
hanzo-dev 5e9194f448 scope: a Group is a BOUND, not a place
Group handed back the raw zip router, so `g := app.Group(p); g.Use(mw)` walked
past every gate in this file and hung middleware on a node whose subtree is
necessarily empty — typed ops register through ZipApp, on the ROOT, so a
subsystem's routes never land beneath the group. zip v1.24 refuses to compose
that, correctly, and crash-looped fifteen plugins on v1.801.425/.426.

This file's own header said that idiom "needs no policing: the group already
bounds it". It bounds the MIDDLEWARE and says nothing about where the ROUTES
went, which is the half that mattered.

Group now returns a child scope, so all three idioms are ONE install — at the
root, gated by path — and no node is left that can be empty. The child carries
`at`, its path prefix, because a group PREFIXES what is registered through it:
without that, `zip.Get(app.Group("/v1"), "/bots", h)` (bots, entitlements)
registers /bots — a route silently MOVED, which still composes, so no compose
check could have caught it. OpScope carries the same prefix for the same reason.

Also, three subsystems that were escaping their bounds silently, because a bare
Group used to skip the check entirely:
  - team  answers /collaborator; the manifest says so and the plugin did not.
  - label answers /v1/risk/labels; same.
  - zt    installed one bridge at /v1/mesh, a level ABOVE the only path it
          serves there. One app.Use, gated by scope to what zt declares.
And OwnsHealth on authz/domain/experiments/metrics, which serve their own
health — so serve.go's generic liveness route was a second declaration of it.

Verified three ways, because a weaker check let a broken build reach production
twice today. The binaries were exiting on `mkdir /var/lib/cloud/orgs: permission
denied` BEFORE composing, and I read that silence as a pass:
  1. survival — a compose panic is fatal, so rc 124 under timeout is the only
     honest signal; "zip new" is logged before composition and proves nothing.
     17/17 affected plugins survive, each with private ports and a writable dir.
  2. route projection — `describe` diffed before vs after across 34 plugins:
     34 identical, 0 changed. deploy/label/referrals now project where they
     previously panicked. This is what caught the moved-route defect above.
  3. go test -run 'Scope|Mount|Prefix|Route' green.

o11y is NOT fixed here: its three routes (logs, metrics, query_range) are
duplicate declarations against upstream o11y@v1.5.55, and zip dedupes on the
resolved path, so moving the node cannot help. Separate change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:16:14 -07:00
hanzo-dev 397f0aed5b feat(analytics): a logged-out click may carry where it happened
The anonymous lane projected the property bag down to the @hanzo/observe
annotation and dropped everything else, so a $click crossed it naming WHICH
element was clicked and never where on the page it sat. Element identity cannot
be drawn as a heat map, and logged-out traffic is the bulk of what one is made
of — so the position survived only on the signed-in lane, for a minority of
clicks.

The position is the second declared family. It does not get the annotation's
free ride: nothing lifts these into a column, so each really does add a key to
the attributes dictionary. What bounds it is that the set is CLOSED and spelled
by this server — five keys, never a caller's own vocabulary, values that are
numbers and a boolean, so the dictionary grows by five and stops.

boundedPosition filters rather than clamps, for the same reason
boundedAnnotation does: a clamped coordinate is a click somewhere the visitor
did not click, and a heat map is a picture of exactly that.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:09:30 -07:00
hanzo-dev ada2cb6a6d event: serve the tag that feeds the door, and make it inert without a key
GET /v1/event.js is the install path for a surface with no bundler:

    <script defer src="https://api.hanzo.ai/v1/event.js" data-key="pk-…"></script>

One line, and the same line for a Hanzo property and for a customer's own
page. It autocaptures pageviews (initial and SPA) and uncaught errors onto
the canonical {batch:[…]} wire, carrying the key as a bearer on fetch and as
?ingest_key on the sendBeacon drain that cannot set a header. Identity uses
@hanzo/event's own storage keys and session TTL, so a page carrying both
clients resolves to one person.

WITHOUT A KEY IT SENDS NOTHING. The keyless beacons this replaces named
their site in a body field and carried no credential, so their events were
accepted 200 into $public — a reserved tenant the owning org cannot read.
The tenant comes from the publishable key, never from a body, so an unkeyed
page is silent rather than reporting success into a tenant nobody reads.

It is served beside the door because a tag that drifts from its wire is a
tag that 400s: asset and ingest ship in one binary and version together.

openapi grows the response half it had deferred until a route asked: Bytes
declares a body under the media type the handler sets, mirroring Binary on
the request side, so the document states JavaScript instead of claiming
JSON. Register copied the body half field by field and would have dropped
it; the copy now carries it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:04:51 -07:00
hanzo-dev 71ef141068 Merge remote-tracking branch 'origin/main' into HEAD
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 2m54s
CI/CD / image (push) Failing after 20s
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 11:02:25 -07:00
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 512938e6cf openapi: the duplicate-route fact zip v1.24 replaced, and the golden it left stale
Hanzo CI/CD / cicd (push) Successful in 15s
CI/CD / gate (push) Successful in 16s
CI/CD / containment (push) Successful in 1m44s
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
TestMergedAndChainedAreIndistinguishable... pinned a fact so that a zip bump
which changed it would fail here. A zip bump changed it, so it failed — this is
the test working, not breaking.

The old fact: a duplicate registration and a middleware chain produce the SAME
observable route, so the generator must never read the handler count. From zip
v1.24 the duplicate half is not constructible at all — a second registration of
one pattern is REFUSED at composition time instead of merged, and the refusal
arrives as a panic out of Registry(), which took the whole package down before
any assertion could run.

The conclusion is unchanged and now rests on something stronger: a handler count
above one can ONLY be a chain, because a collision can no longer reach the
registry. Both halves are still pinned — the chain projects to exactly one
operation, and the duplicate is still refused — so if either moves, the
generator's assumption is forced back into review.

The chain's shape is now MEASURED rather than assumed. zip registers a GET as
GET plus an automatic HEAD companion, so one registration is two route entries;
the old test asserted one and would have failed on that alone. The GET is what
carries the chain and the HEAD is fiber's own, dropped from the projection.

openapi.yaml is regenerated because this branch adds six payment/invoice path
keys. Nothing is removed and no other app's operations move.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:36:15 -07:00
hanzo-dev 09f368d341 commerce: taking a payment becomes an operation, not just a route
`tools/list` at api.hanzo.ai answered 570 tools and not one of them could take
money. Searching payment / charge / checkout / invoice returned nothing; the only
"pay" hit was an admin payout. An agent could incorporate a company, issue its
cap table, open a data room and sign a contract, and then had no way to be paid
for any of it.

The rails were never the problem. Square is live in the commerce module compiled
into this binary — official SDK, real charges, per-org KMS credentials, first in
the fiat priority list — and POST /v1/billing/topup/token has been charging cards
through it all along. What was missing was SHAPE: that route is a raw
func(*zip.Ctx) error, so it is a route and nothing else. No registry entry means
no OpenAPI operation, no MCP tool, no SDK method, no CLI command. The same was
true of the whole invoice lifecycle, of which this binary mounted only the list
and the PDF — an org could read invoices it had no way to create.

So commerce v1.49.64 lifted the money logic into cores that take values instead
of requests, and this adds seven typed ops over them:

  takePayment      POST /v1/payments
  getPayment       GET  /v1/payments/:id
  raiseInvoice     POST /v1/billing/invoices
  getInvoice       GET  /v1/billing/invoices/:id
  issueInvoice     POST /v1/billing/invoices/:id/issue
  collectInvoice   POST /v1/billing/invoices/:id/collect
  voidInvoice      POST /v1/billing/invoices/:id/void

They are TYPED, which is the whole point: each publishes a real JSON Schema with
per-field prose lifted from the handler's doc comment, down to a $defs for an
invoice line item. A payment tool with no parameters is useless to an agent — it
can be listed and never called — and this estate already has 469 untyped writes.
These add none.

THERE IS STILL ONE WAY TO TAKE A PAYMENT. Every op delegates to the same core
commerce's own HTTP handlers now delegate to, so the server-side amount bounds,
the idempotency derivation, the processor selection and the ledger credit are
shared rather than reimplemented. A second charge path would be a second set of
bounds to drift and a second idempotency key to disagree, which is a double
charge waiting for the right retry.

IDENTITY IS NEVER AN INPUT. The paying org is read from the validated principal
cloud.Bridge parks on the context; there is no org field and no subject field on
any of these ops, so a caller cannot steer money to an account it did not prove.
Mode is not an input either — sandbox versus live follows the org's credentials,
and the answer STATES which bucket it credited, so a sandbox receipt can never be
read as live money. Tests pin both: the schemas must not publish org/subject/test,
and every op must refuse a caller with no validated org rather than defaulting.

Also fixes errorscope_test's own empty group node — it declared middleware on a
/v1 group and registered every route on the app, so from zip v1.24 the group
guarded nothing and Registry() panicked before any assertion ran. The store route
moves onto the group, which is what production does and what makes the test a
valid program; its address and every expectation are unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:33:36 -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
hanzo-dev 29741204c0 dataroom: prove the agent can DRIVE the room, not just see the tools
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m36s
CI/CD / image (push) Failing after 17s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
tools/list proves an agent is TOLD a data room can be opened. It does not prove
one can open it, and the gap between those is a real failure mode with its own
shape: over MCP there is no URL, so zip passes the arguments object as the body
with a NIL path map, and an op whose address reaches it only from the path is
addressable over REST and NOWHERE else. Every existing test here drives HTTP,
where the path always binds, so all of them would stay green while the agent got
not-found — and three of these ops carry an id.

So the demo is driven the way the agent will drive it: open a room, grant a party
access naming the room by argument alone, read it back by id, and list it. The
negative half is what makes the positive half mean anything — the same read with
no id must NOT resolve a room, or 'found' proves only that something answered.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:11:34 -07:00
hanzo-dev 2f033107b8 dataroom: strike the typed-op backlog entry, and say what the kit move bought
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
Tranche D listed dataroom 17. It is 10 typed and 7 refused now, and the note
records the two things a later reader needs: that the shared bundle-backed kit
(Scalar, SizedIn, BundleErr, Envelope) moved to apps/goja on its SECOND use
rather than becoming a second copy, and that ScalarList exists because a wrong
type on an access-control list is a SILENT failure — the room discards a
non-array and reports success, so a link meant for one investor would admit
everyone.

The tranche's remaining-count column is left alone: it already disagrees with
its own row (104 declared, 51 remaining after pricing/ml/automations/compliance
were struck without it), so reconciling it here would be a second lane's edit
wearing this one's commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:09:27 -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 4c169bd630 merge blue/ml-dataset-fix: a dataset version names the exact bytes it trained on, bounded per tenant and across the process
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m54s
CI/CD / image (push) Failing after 12m53s
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 10:18:48 -07:00
hanzo-dev 5dab5f610b Merge remote-tracking branch 'origin/main' into blue/ml-dataset-fix
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:18:21 -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 b6463c6392 dataset: register the metered surface in the billing tree main's gate checks
plugin/dataset declares Price: cloud.Metered. TestMeteredSurfacesRequireStanding
(added on main after this branch forked) fails on a metered surface missing from
meteredApps: it would spend a provider's money with no standing check. The scan
that materialises a set is priced per source row read.

Also carries the wove openapi.yaml for the merged surface.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:17:48 -07:00
hanzo-dev bce12beed1 merge main: dataset after risk, the brand header beside main's principal import, and main's higher floor
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 10:08:50 -07:00
hanzo-dev 0ddbd872e1 deps: orm v0.6.19, which strips what a json field path cannot hold
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 2m12s
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
This binary links orm/db, whose toJSONFieldName lowercased the first rune of
each dot-segment and returned the rest untouched — so an all-lowercase payload
passed through byte for byte into json_extract(data, '$.%s'), a SQL string
literal a quote ends.

v0.6.19 keeps only identifier characters and drops the rest, rather than
escaping them: there is then no escaping to get wrong and no dialect to be right
about. It fails closed by construction — a name that had illegal characters
becomes a field that does not exist, so a filter on it matches no rows and an
ORDER BY on it sorts every row equally.

Bump only — no cloud source changes. 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 10:07:32 -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 c48d062968 merge blue/risk-hold: a cross-tenant reclaim is lossless and counted, and now measured
CI/CD / image (push) Failing after 29m3s
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 1m24s
CI/CD / gate (push) Successful in 1m24s
CI/CD / containment (push) Successful in 2m13s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 09:57:54 -07:00
hanzo-dev d2c47fc270 Merge remote-tracking branch 'origin/main' into blue/risk-hold
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 09:57:28 -07:00
hanzo-dev 703ef7db3b risk: a cross-tenant reclaim is lossless and counted, and now measured
The resident bound is a count of tenants with an LRU over it, so one
organisation's arrival drops another's residency. Two properties make that a
capacity decision rather than one tenant unlearning another: the victim's state
is saved before the residency goes, and the reclaim is counted on the probe.
Both were prose. Removing either left the whole suite green.

Losing the save also leaves the slot the eviction took in p.opening held, so the
victim is locked out for the life of the process as well as returned to its last
save.

TestRings_SurviveAnEviction names the lossless property but reimplements it —
it deletes the map entry, bumps the counter and calls save itself — so it cannot
fail when the plane stops doing any of the three. The two new eviction tests
drive plane.resident past maxResident and read only what an operator can read.

Two further tests state the per-field byte cap and the forgotten-subject count
from the direction an operator reads them. Those bounds were already covered
(TestField_IsRefusedAtTheDoorAndNotTruncated,
TestRings_TheCeilingIsMeasuredNotAsserted) and no gap is claimed for them.

Test-only. Nothing is armed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 09:57:05 -07:00
hanzo-dev d127853860 deps: ai v1.832.16 and commerce v1.49.53, both carrying sort-field fixes
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 21s
CI/CD / containment (push) Successful in 1m44s
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 injections landed upstream today and this binary was still pinned behind
both of them, which is the only part that decides whether they are fixed in
production.

commerce v1.49.50 is the one that matters. Its generic REST list handler put
`?sort=` into ORDER BY json_extract(data, '$.%s') — and into data->>'%s' on the
postgres side — with no check. `a') , (select 1) --` closed the path and the
call. Ordering by a conditional turns ROW ORDER into a one-bit oracle, so it
read another kind's rows across the per-kind authorization boundary a column at
a time. The `data` column is a BLOB, which is why it read as harmless: a bare
LIKE against it matches nothing, and only cast(data as text) shows the leak.

ai v1.832.14 is the original sortField fix in GetDbQuery, and v1.832.15 covers
the two siblings that had copied the same unguarded ORDER BY into chat and
provider listing.

Bump only — no cloud source changes. money v0.2.5 rides along transitively. 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 09:53:03 -07:00
hanzo-dev 3a34617a4c fix(gates): a failing step prints why, instead of telling you to read what it discarded
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 21s
CI/CD / containment (push) Successful in 2m21s
CI/CD / receipt (push) Skipped
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
surface-check piped every step to /dev/null and then, on failure, said
'see the message above' — but there was no message above, because it had just
been thrown away. The weave's diagnostic names exactly which schema collided in
which two apps; discarding it is what made the o11y collision expensive to
diagnose.

Proven by the first run after the change: the failure had been reported as
'account cannot project its own document — an app that cannot describe itself is
the bug', which points at apps/account. The captured output says '/bin/sh: go:
not found'. The app was never the problem; the gate was blaming it for a missing
compiler.

Three sites, same shape: both describe loops and the weave. Output is captured
and printed only on failure, so success stays quiet.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 02:52:13 -07:00
hanzo-dev 52ed642801 deps: o11y v1.5.52, which quotes the key beside the value in the older builders
CI/CD / containment (push) Successful in 2m21s
CI/CD / gate (push) Successful in 26s
Hanzo CI/CD / cicd (push) Successful in 26s
CI/CD / image (push) Failing after 51s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The v1.5.51 bump covered the v5 statement builders. This covers the v3/v4 ones,
which are the live path for the older query API and were raw in three positions
rather than one: the ORDER BY direction (a bare string, so
"asc,(select count() from system.tables)" was a whole extra ordering term and
needed neither a quote nor a backtick), the column, and the attribute key in the
single quotes of the map accessors — that last one landing in the WHERE clause,
where it is a predicate the caller writes rather than an ordering oracle.

On the lines that build a filter the VALUE already went through
QuoteEscapedString and the KEY beside it went in bare, which is the same
asymmetry the original sortField bug had.

Bump only — no cloud source changes. 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-02 21:52:22 -07:00
hanzo-dev 9265f8dc0b deps: o11y v1.5.51, which quotes the field name the query plane puts in SQL
CI/CD / image (push) Failing after 52s
CI/CD / gate (push) Successful in 1m39s
Hanzo CI/CD / cicd (push) Successful in 1m39s
CI/CD / containment (push) Successful in 1m43s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The embedded o11y query plane serves /v1/o11y out of this binary, so the
injection fixed in o11y 25bb4acfb0 was live here on v1.5.50: a telemetry field
key name — caller JSON on the query-range door, and replayed out of stored
dashboards and alert rules — reached ClickHouse unquoted in two positions a
bound parameter cannot occupy, the backtick-quoted identifier (SELECT alias,
GROUP BY, ORDER BY) and the single-quoted attribute accessor.

A metrics group-by name of

  le`,(select/**/grouparray(name)/**/from/**/system.tables)/**/as/**/`x

emitted that subquery as an additional SELECT column, and its rows came back in
the response body. Metrics is the reachable one because a metric label is
free-form: an unrecognised name falls back to the labels column by design, so
there is no key allowlist for it to fail against.

Bump only — no cloud source changes. 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-02 21:39:09 -07:00
hanzo-dev ad15df5781 openapi: regenerate the subsets — the ai door renamed three resources and gave permissions back to IAM
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 1m13s
CI/CD / image (push) Successful in 18s
CI/CD / rollout (push) Failing after 11s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
plugin/ai/openapi.json was last written at 3b50091c, with go.mod pinned to
hanzoai/ai v1.832.10. d5d768f1 took v1.832.12 for an unrelated key fix and
754fb821 took .13; neither regenerated a subset. Since then the published
document has been describing a router the binary no longer has.

v1.832.12 rewrote the ai door's resource table. Three resources moved address and
one was deleted:

	/v1/ai/applications      -> /v1/ai/deployments        8 operations
	/v1/ai/sessions          -> /v1/ai/signin-sessions    7
	/v1/ai/users             -> /v1/ai/usages/user-names  1
	/v1/ai/users/table-infos -> /v1/ai/usages/by-user     1
	/v1/ai/permissions          deleted                   6

The six deleted are GET and POST /v1/ai/permissions and GET, PUT, PATCH and
DELETE /v1/ai/permissions/{owner}/{name}. Every handler behind them was an iam.*
call to the IAM server — controllers/permission.go held no rows of its own — so
the address was a second door onto /v1/iam/permissions, which apps/iam serves and
still serves. Upstream deleted the controller with the routes and says so in the
same edit.

So openapi.yaml has been advertising 23 addresses that answer 404 and hiding 18
that are served: the 17 renamed above plus POST /v1/iam/oauth/device/info, whose
prose was written in b5d8f927 and has been waiting for a regeneration to reach the
document. Every SDK, the CLI's command tree and docs.hanzo.ai are projections of
this file, so a client generated from it calls /v1/ai/applications and gets
nothing.

floor.json comes down by six, which is the act it is designed for: the deletion is
upstream, deliberate and already shipped, so the number moves in the commit that
carries it rather than being absorbed by a re-measure. paths and operations follow
(-1 and -5 — the six net against iam's one), and iam goes up by one.

Forty-two other subsets change prose and schema only: a summary now leads with
what the operation does rather than with the Go identifier that implements it, and
visor's cluster list publishes the degraded field its response type already
carried.

Nothing in the router changed here. Regenerating a second time reproduces all 119
generated files byte for byte.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 20:39:35 -07:00
hanzo-dev 01d3586493 openapi: a declaration belongs to the app that owns its address, not to the product segment
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m13s
CI/CD / image (push) Successful in 17m59s
CI/CD / rollout (push) Failing after 12s
CI/CD / receipt (push) Failing after 1s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
Complete judged an orphan by Product() — the first path segment after /v1/. A
product is not an app, and fourteen of them are answered by more than one,
because the manifest separates nested prefixes exactly as the router does.
/v1/s3 PROVISIONS an s3 resource (provisioning) while /v1/s3/buckets is the DATA
plane (storage), so storage was charged with provisioning's POST /v1/s3 — a
declaration provisioning both serves and describes — and could not project its
own document. surface-check died there, on an app with nothing wrong with it.

Attribute by manifest.OwnerOf instead: the longest-prefix rule the host itself
routes by, so the gate blames the app the request would actually reach. Asked
rather than re-derived, the two cannot drift.

It is passed IN rather than imported. manifest's own tests read this package to
pin the spec door's address, so an openapi that imported manifest back makes
manifest's test binary an import cycle — the compiler says so. nil is refused
rather than defaulted, because judging nothing is a gate that reports success on
every defect it exists to catch.

The host's own door is declined explicitly. owner answers a MANIFEST question,
and for /v1/openapi.json the manifest's answer is precisely the misroute
cmd/cloud's static route exists to correct; it is served by the host, mounted by
no app, and renders in the fleet document. Product() hid that by accident,
returning "" for any segment holding a dot.

The gate is unchanged where it matters. A declaration misfiled inside an app's
own prefix still fails — pinned here, and mutation-proven against apps/storage,
where an added GET /v1/s3/buckets/:bucket/files takes the run red while the
false positive stays gone.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 20:14:04 -07:00
hanzo-dev 6adbafcfb6 one definition of what counts as recurring revenue
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 21s
CI/CD / containment (push) Successful in 1m48s
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 admin read counted "active" and "trialing" alike when summing mrrCents off
commerce's subscriptions wire. commerce's own rollup counted active only, and
the warehouse SQL behind the SaaS board counted everything except trialing --
so past_due and unpaid were revenue there and nowhere else. Three definitions,
three different MRR numbers for the same account depending on which board you
opened, and no test anywhere asserted the two agreed.

commerce now owns the answer: subscription.Status.CountsTowardMRR. A trial is
not revenue, because nobody has been charged and the trial may end in a cancel
-- counting it lifts the board exactly when a promotion drives signups. This
surface asks that function instead of matching status strings.

Revenue and entitlement are separate questions and the loop still answers both.
A trialing subject is subscribed and still names its plan; it just contributes
no money yet.

The metrics SQL cannot call Go, so it spells the same predicate as
status = 'active' and says so where a reader will find it. That also drops
past_due and unpaid from the board's run-rate, which the Go side never counted.

Picks up commerce v1.49.48, which also stops overstating a quarterly plan by 3x
-- MRR now divides by IntervalCount, so the mrrCents this reads is already
right and nothing here re-derives it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 20:02:59 -07:00
hanzo-dev 19db4e6eb9 skills: the app is called skills, because that is what it serves
CI/CD / containment (push) Failing after 12m55s
CI/CD / gate (push) Successful in 20s
Hanzo CI/CD / cicd (push) Successful in 20s
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 package, its directory, its plugin and its manifest row all said
"agentskills" — a compound naming the AUDIENCE (agents) alongside the thing
(skills). The thing is skills; every reader of this catalogue is an agent, so
the qualifier distinguishes it from nothing.

Renames the token only. The wire is untouched on purpose: the served addresses
stay /.well-known/agent-skills/... and the document schema stays
hanzo.agent-skills/v1, because those are an external discovery convention we
implement rather than a name we own. The manifest row's Prefixes are unchanged,
so nothing moves address — only the app's own name, its import path
(apps/skills), its plugin binary and its file names change.

Also fixes two comments still pointing at clients/agentskills, a path that
stopped existing when the subsystems moved to apps/.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 19:54:58 -07:00
hanzo-dev a324d398c0 skills: the catalogue's own integrity check, recomputed after the edit
ed4fbe5b corrected the auth line in all three brand catalogues — hk- is not a
key, so the sentence pointed a reader at a credential that resolves to nobody.
The correction is right. But SKILL.md is a GENERATED file whose bytes are
attested by a sha256 in index.json beside it, and the hand-edit changed the
bytes without recomputing the digest. All three brands went stale at once.

That is not cosmetic: the digest IS the integrity contract a client checks
after fetching a skill document, so every reader that verified was told the
catalogue had been tampered with. TestServeSkillDigest and TestCatalogIntegrity
were failing on main for exactly this, and they were right.

Recomputes the three digests and nothing else — the served bytes were already
correct, only the attestation had fallen behind them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 19:35:40 -07:00
hanzo-dev 3e368f4bc9 o11y: graft the surface, so its types are named o11y.* and the fleet weaves again
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 2m1s
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
`make -f mk/fleet.mk openapi-weave` has refused since 593aa309 regenerated the
o11y subset:

    schema "Service" means different things in "ingress" and "o11y"

Six names carried two shapes across the fleet — `Service` and `TLSConfig` against
ingress, `Account` against books, `Channel` against content, `Event` against
analytics, `Host` against plugins — all six from hanzoai/o11y's internal type
packages. The weave is right to refuse: a generated SDK binds whichever shape the
merge reads last. But openapi.yaml is regenerated by ONE command for the whole
fleet, so the refusal did not merely hold o11y's typed ops out of the document —
it meant nobody could add or change ANY api surface and prove it.

Renaming the six upstream only reveals the seventh. They are ordinary words, five
other apps use them, and a type name that has to stay unique against every app in
the fleet is a name nobody can choose safely. The seam is the defect, not the
nouns.

apps/iam already answered this and there is exactly one mechanism: zip.Graft. A
grafted op carries Origin = the child's AppName, and zip qualifies every named
type it reaches as "<origin>.<Type>" — unconditionally, not on collision, so a
published name is never a function of who else is in the room. That is why
identity's 95 schemas are iam.* and have never collided with anything.

MountO11y now builds one zip.App{AppName: "o11y"}, mounts the whole observability
surface on it — cloud's own org-pinned reads and hanzoai/o11y's relay table
together — and grafts that into the host. ONE origin for the product: splitting it
down the module seam would namespace half of o11y's types and leave the other half
bare, which is two conventions for one thing.

    make -f mk/fleet.mk openapi-weave      ok
    …and again, byte-identical                    (idempotent)

THE PUBLISHED SDK SURFACE MOVES. Schema names are part of the contract:

  - 777 o11y schema names go from X to o11y.X. Against the openapi.yaml published
    today, 24 of those are renames of names it already carries; the other 753
    appear for the first time, because the weave has refused to publish them since
    they arrived.
  - NO other app's schema names change. Each of the six formerly-colliding bare
    names stays with the app the published document already binds it to —
    Service/TLSConfig ingress, Account books, Channel content, Event analytics,
    Host plugins — with the same shape.
  - Nothing outside apps/o11y changes to keep compiling. These are generated
    schema keys, not Go identifiers; no test in this repo pins one of o11y's.
  - The MCP tool list is untouched: plugin/o11y/mcp.json is byte-identical.

The rename is PURE, and measured rather than eyeballed. Against the subset that
PRISTINE main regenerates from the same source: all 389 (path, method,
operationId) triples identical, and all 389 operation objects byte-identical once
the o11y. prefix is undone — parameters, bodies, responses, tags, prose and x-app
included. The woven document resolves 2987 $refs with zero dangling.

Two facts the graft made local, both load-bearing:

ALL /v1/sentry/* stays on the HOST. zip.App.Declaration drops HEAD and OPTIONS
unconditionally — they are the shadows fiber generates for a GET and for CORS — so
a door opened with All cannot cross a graft intact, and OPTIONS is a method that
proxy genuinely answers and publishes as an operation. It is registered at the
same point in the same order, and costs nothing there: a wildcard proxy declares
no typed op and contributes no schema.

cloud and the module BOTH claim GET /v1/o11y/{logs,metrics} and POST
/v1/o11y/query_range. scope.go is the one owner — it pins the caller's org
server-side — and first-registered is what makes it the half that answers. That
used to depend on the host's global mount order; both halves now register on one
app, two adjacent lines apart. TestHostRoutesStillWinTheThreeSharedAddresses is
the gate, and it is mutation-proven: put o11y.Mount first and all three go red.

openapi/floor.json rises with the document — 1410 → 1679 paths, 1979 → 2333
operations, o11y 28 → 363, sentry 7 → 24 — and no product falls, which is what the
ratchet checked before writing it.

One document defect SURVIVES and is unchanged: at POST /v1/o11y/query_range the
router answers cloud's untyped builderQueryHandler while the document publishes
the module's typed contract, because only the module's half has a registry entry
to publish. It predates this and goes away with the console v3→v5 migration that
deletes cloud's half (apps/o11y/LLM.md).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 17:21:45 -07:00
hanzo-dev b5d8f9273b iam: the device-approval lookup says what it does
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 1m44s
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
POST /v1/iam/oauth/device/info arrived with hanzoai/iam v1.34.0 -> v1.34.5
(85f5697c) and reached the router with no prose anywhere. It is an UNTYPED route
inside a GRAFTED app, so neither seam that normally carries prose reaches it:
zipdoc lifts doc comments off TYPED ops, and the upstream module ships no lift
for this one, so there is nothing in zip's extraction for a host to read.

openapi.Complete then refused the whole document — "1 operation(s) say nothing
about themselves" — so apps/iam could not describe itself at all, and the drift
gate stopped there without reaching any app after it.

openapi.Describe is the seam that refusal names for exactly this case. It is
additive metadata on a route the router already carries, so it cannot add, move
or rename an operation; it renders nothing if the address is not live. The
sentence is the upstream handler's own, restated where a host can reach it, and
it says the three things a caller cannot guess: the fields come off the pending
code's own application, the read is a POST because the user_code is a secret,
and every refusal is the same opaque one so a 40-bit code cannot be hunted.

The sentence belongs upstream, on the operation. When github.com/hanzoai/iam
gives the op its own prose, this goes away.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 16:01:20 -07:00
hanzo-dev aee62d7d6c zipdoc: regenerate the 53 lifts the extraction change left stale
zap-proto/zip went v1.18.16 -> v1.18.23 (c516e5a3, then 85f5697c) and the doc
extraction changed with it: a lift no longer keeps the leading Go identifier,
so "RevokeKey revokes the caller's own API key" became "Revokes the caller's own
API key". The committed lifts were never regenerated against it, so `zipdoc
-check` — the per-package gate `make test` runs before the suite — was red in 53
of the 95 directories that carry the generate line.

This is `go generate -run zipdoc` and nothing else. Parsing both trees' ASTs
rather than diffing their text, it moves:

    377  Description lifts losing their leading identifier
      8  Fields lifts catching up to a doc comment their source already changed
     86  routes gaining a lift at all — the bump also reads an UNTYPED route's
         prose, which is the whole point of c516e5a3

and, in the same comparison:

      0  Examples changed
      0  routes lost a lift
      0  Descriptions changed in any other way

A lift carries Description, Fields and Example and nothing else, so it can only
ever move PROSE — it cannot add, remove, rename or retype an operation. The
router remains the only thing that decides what is published.

51 of the 86 new lifts are the doc comment of cloud.Handle, not of the handler
it wraps: the extraction follows the Service-scoped binder rather than the
function passed to it. Every one of those routes already declares its prose with
openapi.Describe, which outranks a lift, so none of it reaches a published
document — verified: the string "Binds a Service-scoped handler" appears zero
times in plugin/*/openapi.json. It is inert, and it is what the generator emits;
editing a generated file to remove it would only turn the gate red again.

The published documents are deliberately NOT regenerated here. Doing so moves
real API surface that is nothing to do with this bump — plugin/ai alone renames
23 operations and adds 18 behind the ai v1.832.12 bump — and openapi-weave
refuses to run at all while six schema names mean two different things across
apps. Both need their own review.

Regenerating a second time produces zero further change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 16:01:20 -07:00
hanzo-dev 754fb82173 deps: ai v1.832.13 — a cert is read from its application's partition
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m44s
CI/CD / image (push) Failing after 28m32s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
GetCert qualified the certificate id with the caller's tenant while
GetApplication used the platform partition, so a deployment whose IAM_ORG was not
"admin" resolved its application and then could not find that application's
certificate. With no cert there is no key to validate a bearer against, so the
process authenticates nothing — and the message named a missing certificate
rather than a lookup pointed at the wrong tenant.

It surfaced the moment identity moved in-process on devnet: /v1/models 503 with
`read cert "cert-hanzo" ... does not exist`, while the same cert answered 200 at
id=admin%2Fcert-hanzo. Production never hit it because it reads the standalone
IAM, whose store has been converged for months.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 15:36:34 -07:00
hanzo-dev 20236d178d merge: main, which retired the committed MCP catalogues under this work
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m41s
CI/CD / image (push) Successful in 20m30s
CI/CD / rollout (push) Successful in 5m18s
CI/CD / reach (push) Failing after 35s
Hanzo CI/CD / cicd (push) Successful in 19s
main moved again while the suite ran. One conflict, modify/delete on
plugin/gateway/mcp.json: main deleted all 116 committed tool files because the
catalogue is a query now — the host asks each child over its ZAP socket rather
than reading an array some earlier build wrote down — and this work had
regenerated the gateway one.

The delete wins, and it takes plugin/risk/mcp.json with it. That file is the
same artifact class main just retired: nothing embeds it any more
(plugin/embed.go), nothing writes it any more (mk/plugin.mk, describe.go), and
leaving it would put back one of the 116 second sources whose whole defect was
that they could only be stale or accidentally correct.

Still nothing armed and nothing routed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:52:00 -07:00
hanzo-dev 11516dfb6b crypto: two more HKDFs of our own, where cek was already the one derivation
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 2m17s
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
internal/org and apps/mpc each turned the master into a key themselves, so the
estate had three answers to one question and no test compared them.

The snapshot cipher derived HKDF(master; salt="hanzo/org-db/v1"; info=orgID) by
hand while the local file it snapshots was opened by cek. The two were supposed
to agree and nothing checked that they did — a comment's promise, which is the
same shape of bug as base's deleted core/encryption.go, where the claim was
simply false. Worse, keying on the org alone meant every database an org owned
sealed under ONE key: its settings snapshot opened its ledger. cek binds the
subsystem, so Seal/Open now take the name (ns, subsystem) the file was opened
under, and TestCipherKeyIsCEKDerived opens a sealed blob with a key derived
independently through cek — the equality is now a test, not a sentence.

apps/mpc ran HKDF(master; salt=orgSlug; info="cek-aes256gcm"), inlined verbatim
from hanzoai/kms/sdk/go. That repo is archived and read-only, so this is the
only live copy and there is nothing left to stay byte-identical with. Argon2id
stays exactly where it was: stretching a guessable passphrase and deriving a key
for a named thing are different jobs, and cek does the second one. The client
takes a namespace instead of a slug — cloud has one door where a string becomes
a tenant, and TestOnlyOrgnsBuildsANamespace is right that this package is not it.

Also drops the NewReplicator/WithEncryption aliases. Nothing in cloud shipped
through them; they only survived to let one test seal through replica.Cipher,
whose string-shaped Seal a namespace-keyed cipher cannot satisfy. That test is
replaced by TestDurableShipsCiphertext, which asserts the same property against
the path that actually ships: Sync writes a blob the marker does not appear in,
and a successor reads the row back out of it.

Nothing migrates. A database is born under its key or it does not exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:39:53 -07:00
antje dcb43cbfd9 billing: a Free surface under a metered prefix is not billable, and one comment was fiction
CI/CD / rollout (push) Successful in 6m28s
CI/CD / gate (push) Successful in 20s
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / containment (push) Successful in 1m47s
CI/CD / image (push) Successful in 18m23s
CI/CD / reach (push) Failing after 1m0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Prefixes NEST. provisioning is metered and routed /v1/vector and /v1/search;
product is Free and routed the more specific /v1/vector/collections,
/v1/vector/stats, /v1/search/indexes and /v1/search/stats. Billable scanned the
metered trees with a bare HasPrefix, so all four of product's surfaces billed on
provisioning's standing — a Free product gated behind a balance.

The router resolves by longest prefix, so ownership does too: manifest.OwnerOf
answers which app actually serves a path, and if that app is not metered then
nothing is spent and nothing is owed. No new list to drift — the manifest already
owns routing, meteredApps already owns price.

Also deletes an invented history. The comment claimed "/v1/provisioning/, which
provisioning does not answer" had been in this file; `git log -S '"/v1/provisioning/"'
-- spend.go` returns exactly one commit, the one that wrote the claim. The real
defect was assuming provisioning's tree was /v1/provisioning when the manifest
routes it elsewhere, which is what the corrected text now says. A comment
describing a defect that never existed is the same failure the commit was
punishing.

Negative-controlled: removing the ownership check fails the new test on all four
paths. The 19 TestAudit_* failures in this package are pre-existing (verified by
stashing) and untouched.
2026-08-02 14:37:13 -07:00
hanzo-dev 954e00fe77 o11y: retire the last VictoriaMetrics reader, keep the one thing it measured
vmproxy.go was the final VM reader in the repo. It spelled a vendor into the
route table (/v1/o11y/vm/{query,query_range}), admitted 19 exact PromQL
strings, and returned that store's Prometheus envelope byte-for-byte. The store
is gone, so the route named for it and speaking its wire is gone too — keeping
it would be a dependency's vocabulary outliving the dependency, which is the
thing metricsgauge.go already refused when it declined to keep the envelope.

Of the 19 queries, exactly 3 are still MEASURED. `up`, `sum(up)` and `count(up)`
were the platform-health board asking how much of the fleet is up now and
lately, and the fleet prober has been recording hanzo_service_up every 30s the
whole time. So that question is asked plainly instead: GET /v1/o11y/availability,
a typed op, platform-sudo as before, answering the instant inventory and the
trend in ONE read where the board used to make three PromQL round-trips.

The number changes and the endpoint does not hide it. `count(up)` counted every
scrape target VM federated — hundreds. `total` counts fleetTargets, a couple of
dozen services we chose to knock on. Per-replica identity stays gone, for the
reason status.go already recorded: a Service address is not a pod.

The other 16 have no native producer and are DELETED rather than answered
empty — verified, not assumed:

  node_memory_*                node-exporter, scraped per node
  container_memory_working_set kubelet cAdvisor, scraped per pod
  kube_deployment_*            kube-state-metrics, scraped
  kube_statefulset_*           kube-state-metrics, scraped
  lux_validator_* (4)          ghcr.io/luxfi/monitoring /validator-exporter —
  lux_network_* (6)            binds :9101, renders a Prometheus exposition,
                               no push/OTLP/egress at all; a lux-side vmagent
                               scraped it and remote-wrote to the hub. That
                               write had already POSTed into a 404 for 1369
                               consecutive retries in July, so the board was
                               dark before this change.
  ALERTS{alertstate="firing"}  vmalert's remote-write of its own state, and
                               nothing else.

availability.go's header is the ledger: each dead producer by name, and what
would have to be MEASURED to get the panel back. Two of them should not come
back as metrics at all — the replica grid is the API server's object status and
should be watched at the source, and the firing set is rows in o11y's ruler
store, so it is a projection of rule state rather than a series to re-collect.
The lux boards come back when that exporter pushes to the ZAP metric receiver
the way this process does; gaugeSeries then works on those names unchanged.

Five of the console's 18 declared queries were already dead weight — the four
freeze-detection signals and the alert rollup were allowlisted here and fetched
by no panel.

Console impact. The client reads data.result[].value and never checks `status`,
so a reshaped 200 would parse to [] and paint a board of zeroes; a deleted route
throws and renders its error card. That is why this is a deletion.
  MetricsModule/InfraMetrics (/metrics)  KEEPS everything, move to the new op
  StatusModule (/status)                 KEEPS everything but the Endpoint col
  LuxNetworkModule (console.lux.cloud)   goes fully dark; nothing can serve it

Also retires the prose VM left behind: telemetry.go and metrics_http.go claimed
metrics were collected by scrape because "every metric READER queries
VictoriaMetrics" — both false since the :9464 exposition was deleted. Every
reader now goes through metricsgauge.go. serviceUp is shared with summary.go
rather than copied, since it is the same value.

Gates: build, vet, ./apps/o11y/ + root tests, and cmd/cloud deps — all pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:35:38 -07:00
antje 4e23753340 deps: ai v1.832.12 + commerce v1.49.47 — the two key shapes reach the binary
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 2m2s
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
Both changes were merged and neither was reachable: v1.832.10 and v1.49.46
predate them, so the fused binary kept resolving hk- through the old
dispatch no matter how many times main was rebuilt.

ai v1.832.11 makes isIAMApiKey recognize sk- and asks the provider table
first, so a retired prefix falls through to IAM and comes back key_unknown
with the one sentence a stranded holder can act on. v1.832.12 stops that
sentence's sibling — the pk- refusal — pointing at /api/models, which 404s.
commerce v1.49.47 drops the same dead prefix from its edge comments.

The dispatch order moved, so the hk- refusal has to be re-verified against
the rolled image rather than assumed: it reaches IAM by a different road now.
2026-08-02 14:31:42 -07:00
antjeandhanzo-dev d5d768f121 deps: take ai v1.832.12, where an sk- key reaches IAM at all
A valid, funded sk- key could not buy one token of inference. IAM resolved it
correctly — get-user?accessKey returned {"status":"ok","data":{"owner":…}} —
while POST /v1/chat/completions answered 401 "invalid API key" for the same key
in the same second.

Both answers were right. They were answering different questions, because the
inference path never asked IAM.

ai v1.832.10 is what this module pinned, and there isIAMApiKey(token) meant
strings.HasPrefix(token, "hk-"). Once hk- was retired estate-wide, IAM minted
only pk-/sk-, so every key a customer can hold missed that branch and fell to
the dispatch default — which read sk- as an UPSTREAM VENDOR key, looked it up in
the provider table, missed, and returned the bare authError("invalid API key").
The bare string with no ": %s" suffix is the fingerprint: it is the one refusal
on that path that carries no cause, because a provider-table miss has none to
give. IAM was never consulted, which is exactly why querying IAM directly
disagreed with the endpoint.

ai v1.832.11 fixed it by asking the STORE rather than the spelling: the provider
table first (an exact lookup, so it can never claim a key it does not hold), and
anything it misses is put to IAM, whose refusal names the cure where a provider
miss can only say "invalid". That fix has been tagged and unreachable since —
this module still pinned .10, and so did v1.801.375, so rolling that tag would
have shipped the same 401.

Nothing here weakens the door. The two shapes and their guards are unchanged and
still covered: pk- authenticates nothing (KeyWrongDoor, never a principal — it
ships in client JS), sk- keeps its same-tenant pin (KeyForeignUser still fires on
a cross-tenant reference), and an hk--shaped string is simply not a key and takes
the generic key_unknown path that renders "mint a new one at cloud.hanzo.ai/keys"
— no branch of its own, so the third family cannot come back.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:31:29 -07:00
hanzo-dev c03c143f03 merge: the risk lifecycle work onto main (red-cleared, shadow, unrouted)
main moved eight commits while this was building, so it is integrated here
rather than pushed past. One conflict, in spend.go, and it is the good kind:
both sides were right about different things.

main replaced the hand-written meteredTrees path list with meteredApps — a set
of app NAMES resolved through manifest.PrefixesFor — because a copied routing
table goes stale, and that one had in six ways. This branch had added
"/v1/risk/" to the list main deleted.

The mechanism is main's and the fact is this branch's. plugin/risk declares
Price: cloud.Metered, and main's new TestMeteredSurfacesRequireStanding fails
in BOTH directions — a Metered surface missing from the list is a surface that
charges and cannot be gated. So risk is carried across as a name, and that test
passes because of it, not in spite of it.

Nothing is armed and nothing is routed. apps/risk is still mounted nowhere;
/v1/risk appears only as an abuse-gate exemption, a manifest entry and now a
standing requirement. The abuse gate stays shadow per org — edge.Store.Mode
returns live only on an exact match and is not inherited. /v1/ml is untouched.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:26:42 -07:00
hanzo-dev 3b50091c77 mcp: the catalogue is a query — delete the 116 committed tool files
The fleet's agent door answered from plugin/<app>/mcp.json: the tool array each
app's binary projected when it was BUILT, embedded by plugin/embed.go and handed
to zip as Plugin.Tools. 116 files, 49,865 lines, and a second source for a fact
every child already knows.

A second source can only be stale or accidentally correct. This one was stale in
the way no gate in this repository could see: o11y's 353 missing ops live in
github.com/hanzoai/o11y, so a go.mod bump in ANOTHER repo invalidated an artifact
in this one with nothing in the diff to say so. Regenerating it more often is not
the fix — a generator on a hook is still two sources with a race between them,
and the trigger is in a different repository. (593aa309 did regenerate it, which
is why the file reads 365 today. The next cross-repo bump silently un-fixes it.)

So the host asks. POST /v1/mcp is the HOST's own handler now (zip's is Disabled,
so exactly one handler holds the address). A tools/list forwards the CALLER's own
message to every composed subsystem's own /mcp over its private ZAP socket, in
parallel, and unions the replies — zip.App.Start resolves a cold child on the
same single-flighted path a prefix request takes, so the first list pays one
start per app and nothing after it does. A tools/call goes to the app that listed
the name, verbatim; the child's own registry decides whether the tool exists.

A SUBSYSTEM THAT DOES NOT ANSWER IS NAMED, in result._meta["hanzo.ai/unavailable"],
because a silently-short list and a stale file are the same defect: the caller
cannot tell an app that serves nothing from one that did not answer. Measured on
the built binaries — host + real o11y child, kms pointed at a dead address:

    tools=364, unavailable=[{kms, connection refused}]

364 and not 365 because o11y projects get_v1_o11y_logs twice; the door serves the
first and logs the collision. THAT DUPLICATE IS WHY cmd/cloud's tests were red on
main — zip refused the Load ("tool is already served by plugin o11y"), a boot
failure. One name still has one owner; it is no longer fatal to the fleet.

Also gone with the mechanism they configured: manifest.App.Open and zip's
one-open-plugin rule. The host forwards the caller's own request to EVERY
subsystem now, so each answers for this caller out of its own rows, and being
asked per caller is no longer a privilege one app holds.

The release gate moved with the door. Car 3 compared the live tool count against
`jq -s length` over the committed files — both sides were the same bytes, so it
proved only that the image carried its own tree, and it passed while o11y's
catalogue held 12 of 365. It asks the better question now: did every subsystem
answer. A broken deployment used to match the files exactly.

plugin/<app>/openapi.json SURVIVES, for the one reason the catalogue could not:
the weave carries each subsystem's prose, and that prose is lifted from the app's
SOURCE at describe time (openapi.Synopsis). A running child has no comment to
read and would answer with its deployment's brand blurb, which the weave would
publish as the description of every product tag. Deleting that half waits on the
synopsis becoming a declared value.

Tests are against RUNNING subsystems (fleet/mcp_test.go): real zip children on
real ZAP sockets, exact sets, bodies never status codes. Mutation-checked three
ways — unmount a child, silence the outage report, ask only the first app — all
three go red.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:22:10 -07:00
hanzo-dev ffbd1cbd7e auth: the lifecycle branch still called hk- a key
Nothing textually conflicted, so the merge could not see this: the branch was
written when hk- was one of the key shapes, and main dropped it from
APIKeyPrefixes in the meantime. isAPIKey("hk-...") is now false, so a validated
caller presenting one falls through credentialClass to session rather than
secret, and TestCredentialClass_ReadsTheCredentialNotTheClient failed on the
one subtest that asserted the old taxonomy.

Main is right and the branch is stale, so the three sites the branch
reintroduced are re-cast the way main re-cast every other one. The fixture is
kept rather than deleted, restated as the negative it now proves: an hk- string
contributes nothing to the classification, so putting hk- back into
APIKeyPrefixes turns this red.

No behaviour changes. Both lanes involved are attributed lanes — secret maps to
agent, session to human — and neither is the bot lane, so nothing is granted or
refused differently. The authentication path is untouched: hk- reaches no key
door and resolves to no principal, which is main's point.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:21:38 -07:00
hanzo-dev adf0622b0c deps: take hanzoai/cek v0.2.3, off the release that cannot build
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m57s
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 pinned cek v0.2.1. The releases after it were a trap: v0.2.2 shipped a
sidecar.go written against sqlitedrv APIs that hanzoai/sqlite v0.5.0 removed,
so anyone bumping cek and sqlite together got a module that does not compile —
and cloud now takes sqlite v0.5.0.

v0.2.3 is the release where the sidecar states its own scheme instead of
reaching into the driver for it, which is why it builds against v0.5.0 at all.
Verified: cek at v0.2.3 builds and its own suite passes on sqlite v0.5.0, and
cloud's suite is unchanged by the bump — the same 7 packages fail before and
after, none of them cek's.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:11:19 -07:00
antje 5e9dbe66cd billing: two metered surfaces charged without ever asking, and the standing list named paths nothing answers
tools and automations both debited a unit on the way out and never gated on the
way in. An org at $0 ran either one unbounded and the ledger simply went
negative — the rate budget automations already had bounds the SHAPE of the spend,
never the amount. Both now gate the same unit they meter, from the same knob, so
what is authorized and what is charged cannot drift. automations gates in
startRun, the one choke point all four run-start paths pass through (manual, MCP,
trigger, cron), so the gate is not something three entrypoints walk around;
engineErr stops folding a 402 into "engine: …" 500, which told a customer their
automation was broken when they had run out of credit.

meteredTrees restated the fleet's routing table by hand and had gone stale in six
ways, each one a surface that charges and could not be gated: it said
"/v1/provisioning/", which nothing answers — the manifest routes provisioning at
/v1/{datastore,docdb,kv,search,sql,vector}, so every vector/sql/kv/docdb create,
the exact set the non-LLM billing gap was opened for, was not a billable path at
all. projects answers /v1/sites, venue answers /v1/cloud, tools answers
/v1/skills and /v1/plugins, and ten Metered surfaces were missing outright. It is
a set of NAMES now, resolved through manifest.PrefixesFor — which paths an app
answers is the manifest's fact, read from there rather than copied.

spend.go has cited TestMeteredSurfacesRequireStanding since the day the list was
written. It did not exist. That is the third comment in this repo caught
describing a gate nobody built, which is worse than no gate: it reads as
enforced, so nobody looks. It exists now, reads Price out of every
plugin/<name>/main.go the way TestPriceDeclared does, and fails in both
directions — a Metered surface missing from the list, and a listed surface that
is no longer Metered.

Also deletes automations' meterUnit, which had no callers.
2026-08-02 14:09:14 -07:00
antje ea7bad91ab health: /readyz, because a 200 with the product API absent is not a health check
On 08-01 api.hanzo.ai/v1/models and /v1/chat/completions answered 503
{"error":"mount /v1: no instance running"} for ~30 minutes while the pod
reported Ready with 0 restarts.

Nothing in that sentence was a bug in mount(). o11y v1.5.41 seized
:4317-:4319 from the `ai` child, the child's listen failed, and the host
degraded it to absent — which is the 2026-07-29 fix working exactly as
designed. The defect is that absence was reported in a FIELD and the probe
reads the STATUS CODE. `ai` owns the greedy "/v1", so every specifically
mounted prefix (/v1/sentry, /v1/o11y, /v1/commerce/tenant, /v1/admin/*)
kept answering from its own subsystem and only a path falling THROUGH to
`ai` showed it.

So split the question the two probes were sharing:

  /healthz  liveness. 200 while the process routes, unchanged. Restarting
            cannot help — the cause is in the image or the config, so the
            replacement fails identically — and it would take the console,
            the log stream and 111 healthy siblings with it.
  /readyz   readiness. 503 when draining, or when a VITAL subsystem is
            absent. New route; it did not exist on :8000 at all.

App.Vital is the other half of App.Required, kept separate because they
answer different questions. Required asks "may this process run", and its
own doc names "a pod that never goes Ready" as the one thing aborting buys
before rejecting the whole package. Vital buys exactly that and nothing
else: the pod stays up, keeps serving its siblings, and can still say why.
Required's bar is "serving without it is UNSAFE"; Vital's is narrower —
"serving without it is POINTLESS". Exactly one app qualifies, and "/v1" is
why: it is not a subsystem's prefix, it is the product API's remainder.

The timing is what makes a 503 cheap: a rollout whose image cannot start
`ai` never gets a Ready pod, so the Deployment stalls and the OLD pods keep
serving — the bad config stops at the first replica instead of reaching all
of them. Fleet-wide, endpoints empty and the product is down, but it was
already down; now kubectl says so.

A non-vital absence stays READY and is still reported. Pulling a pod
because one minor subsystem died turns a partial failure into a total one,
which is the same mistake as aborting, made later.

Also closes a contract drain.go describes and nothing delivered: it states
that SIGTERM flips /readyz to 503 so K8s marks the pod NotReady, and the
root package implements that on the ops listener at :9090 — but both k8s
probes point at :8000, where /readyz 404'd. The drain signal guarding the
M3 writer-election handoff was never read by anything. The host keeps its
own flag rather than borrowing cloud.SetDraining(): it does not link the
root package, and it is a different process anyway.

Tests reproduce the outage with the real app name and the real child-exec
path (/bin/false), and fail without the fix:
GET /readyz = 200, want 503.

The chart still probes /healthz for readiness and MUST NOT be repointed
until an image serving /readyz is rolled — a 404 fails readiness exactly as
hard as a wrong port, which is its own recorded outage.
2026-08-02 14:00:23 -07:00
antje ed4fbe5b4c skills: the auth line named a prefix the estate no longer accepts
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m58s
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
All three white-label catalogs told agents an hk- key was accepted. It is
not — it resolves to nobody — so the sentence sent a reader to a credential
that cannot work. Names the secret sk- instead, and says plainly that a
publishable pk- identifies an org and never authenticates.

Found by a binary-safe grep: these files sit beside committed binaries whose
string tables contain the prefix, and the default grep skips those paths, so
a normal sweep reports the tree clean.
2026-08-02 13:59:12 -07:00
antje 57bf9ba4b7 feat: iam-import — move the identity store into the one the embedded IAM reads
cloud already embeds IAM (apps/iam, grafted in-process) and prod already enables it,
but /v1/iam/.well-known/jwks answers {"keys":[]} because the store it opens is empty:
/var/lib/cloud/orgs/_platform/global.db, 4096 bytes. The real identity graph — 391
users, 134 orgs, 286 applications and the 10 signing certs — lives in the standalone
pod's /data/iam/iam.db. That is why every service still points at iam.hanzo.svc, and
why a single pod failing readiness takes identity down for the fleet.

It is not a file copy, because the stores differ three ways at once. The standalone
writes PLAINTEXT — the literal "SQLite format 3" header, which is the exposure
apps/iam/openStore exists to remove. The embed writes through cek, keyed from the
process master. And it opens exactly one namespace-derived path, so a file dropped
beside it is not a file it reads.

So the rows move, not the bytes: read the source with a plain driver (read-only and
immutable, so a live pod's WAL is never touched), write through the same cek.Open the
subsystem itself uses. One transaction — identity half-moved is worse than not moved.
Columns and DDL are read from the source rather than restated here, so a schema that
grows cannot silently lose its newest field. It refuses a source with no certs, and
refuses a non-empty destination unless told, because merging two identity stores is a
decision rather than a default.

Rehearsed against a verified 96MB snapshot of prod on a Linux box (the cek codec needs
RAM-backed scratch, so not on macOS): 5745 rows across 15 kinds written and verified
kind-by-kind, certs=10, and the destination reads back as ciphertext rather than
"SQLite format 3".
2026-08-02 13:56:54 -07:00
hanzo-dev 0935eef646 o11y: close the scrape port — nothing is coming to collect
The :9464 Prometheus exposition existed for exactly one caller: a scraper that
filled VictoriaMetrics, because VictoriaMetrics was where every reader looked.
The readers moved to the telemetry store and the measurements now push
themselves there, so what is left is a listener whose only client no longer
exists. That is not a way out, it is an open port.

The registry STAYS. It stops being a published surface and becomes the buffer
the meter provider renders into and the push drains — which is why the otelprom
reader is untouched while its HTTP face is deleted. The exporter was never the
scrape; it is the encoder, and it is the one that already speaks the family
model the datastore receiver takes.

cloud.Metrics() goes with the listener it served. MetricGatherer stays and is
now the only door onto the registry: read what was measured, never register.

Note the :9090 ops /metrics is untouched — it is a hardcoded `cloud_up 1` stub
for liveness, not the registry, and it answers a different question.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:56:36 -07:00
hanzo-dev 35f7ac4fcb merge: risk: the credential is the lane a stolen key travels (red-cleared, shadow)
The lifecycle defense: an edge traffic sensor, a per-credential abuse gate and
the identity boundary's own attestation. Compiled in and mounted, SHADOW per
org — edge.Store.Mode returns live only on an exact "live" match, is absent
from the inherited base, and is never set here, so nothing is armed by landing
it. /v1/risk stays unrouted; /v1/ml is untouched.

Four conflicts, each resolved on its merits rather than by side:

middleware_identity.go — an import collision where both sides were right.
main added namespace.Sanitize (OrgHasUnsafeRune, the cross-org fold refusal),
the branch added principal.Mint. Both symbols are live in the merged body, so
both imports stay; either blanket resolution drops a defense and the build.

middleware_ratelimit.go — the branch's code, main's reasoning. The branch
tests the ROUTER's path (RoutePath + underPrefix) instead of the raw spelling,
which is what stops /v1/billing/../v1/ai/chat from prefix-matching its way
into a rate-limit exemption. Its rationale, though, describes an in-process
HTTP self-dispatch that main has since replaced with a typed ZAP op, so the
comment kept is main's, which is the one that is true here.

plugin/o11y/main.go — complementary, not competing. The branch installs the
identity boundary and the abuse gate in this hand-written main; main added
cloud.ErrorHandler so a propagated refusal renders as its own status instead
of 500. Those two middlewares are precisely what emits refusals, so the
handler matters more after the branch lands, not less. Both kept.

openapi/floor.json — the pointwise maximum, which is what Floor.Raise means:
taking the lower side would silently undo the other's ratchet. Then
regenerated from source, so the regeneration is the truth.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:52:56 -07:00
hanzo-dev 85f5697c79 admin, deploy: read the number, don't keep a copy of the rule
Two duplications, both of the same shape: cloud held a second copy of a fact
that another service owns.

PKCE (iam v1.34.5)
------------------
apps/deploy/login.go had pkceChallenge, whose own comment said it was
"byte-identical to IAM's own pkceChallenge", and apps/integrations had
twitterChallenge -- the same two statements again under a third name. iam is
the authorization server; it decides what a code_challenge is. It just moved
that derivation out of internal/oidc into pkg/pkce so a client can import it,
so both copies are deleted and both call sites use pkce.Challenge. The
iam-facing one also sends pkce.Method rather than its own "S256" literal, so
this client cannot ask for a method that server refuses. Twitter's call keeps
its own literal: that is X's protocol requirement, not iam's.

twitterVerifier stays exactly as it was -- it is a different thing (a
per-app-constant verifier, with the reasoning for why that is safe only for a
confidential client), and only the challenge transform was duplicated.

MRR (commerce v1.49.46)
-----------------------
apps/admin/commerce carried monthlyNormalized, a third copy of commerce's
interval normalization. It agreed with commerce's arithmetic and still got the
answer wrong, because it never read quantity at all: a 10-seat plan at
$20/seat reported $20 here and $200 in commerce's own rollup. commerce
invoices Price x quantity, so $20 was never the revenue -- it was the unit
price wearing the name MRR.

commerce now puts mrrCents on each subscription in /v1/billing/subscriptions,
computed by the one definition that also feeds its rollup and its event
stream. This is a display surface, so it reads that number. Price and Interval
are dropped from subscriptionsWire entirely -- not left in place unused, which
would invite the next reader to normalize them again.

Tests
-----
- deploy TestPKCEChallenge: deleted. It pinned the RFC 7636 Appendix B vector
  against the local function that is gone; the vector is pinned in iam's
  pkg/pkce, which also now checks the encoding is unpadded base64url. The
  authorize-redirect test still proves the published challenge derives from
  the stored verifier -- that part is this package's own and is kept.
- integrations: the "challenge must be the HASH of the verifier" assertion is
  kept verbatim, repointed at pkce.Challenge.
- admin/commerce TestMonthlyNormalizedCents: deleted with the function it
  pinned; that arithmetic is pinned in commerce's api/billing. Replaced with
  five tests over a stub commerce that prove Plan SUMS mrrCents, ignores
  canceled subscriptions, returns an honest pay-as-you-go zero, decodes the
  mrrCents tag, and -- the case the old code failed -- reports 20000 for a
  subscription whose price says 2000.
- apps/admin cockpit/finance stubs now serve mrrCents, because that is what
  commerce serves. Their expected MRR figures are unchanged; the stubs were
  simply a wire shape behind.

Failing-test set unchanged: the same 7 packages (apps/ai, apps/iam, apps/plan,
apps/pricing, cmd/cloud, manifest, openapi) fail on origin/main before this
change and after it. cek stays at v0.2.1 deliberately -- v0.2.2 adds a
sidecar.go built against hanzoai/sqlite v0.4.0 APIs and does not compile
against the v0.5.0 this module already pins.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:52:43 -07:00
hanzo-dev af8cd947f2 o11y: read availability from the store we keep, not the one we removed
The measurements were never the problem — the prober has recorded
hanzo_service_up for as long as it has existed. The problem was that the only
way to READ it was a PromQL instant query, so /v1/summary (PUBLIC,
unauthenticated) and /v1/o11y/status each held a VictoriaMetrics client, and VM
could not be removed without both going dark.

latestGauge answers one question — the newest value of a gauge, per label set —
against event.metric joined to event.series on the fingerprint, because a sample
carries no labels and a series carries no values. argMax over the window, not an
average: a service down for four of the last five minutes and up now must read
1, and any aggregate answers 0.8, a number true of nothing. Asking it in ONE
place is what stops the two callers growing two dialects of the same SQL.

⚠️ status.go loses per-replica identity, and that is a real loss rather than one
to paper over. It read `up{service=…}` — the SCRAPE's own metric, one series per
target, so instance/pod came free because something had visited each pod to
produce it. Nothing scrapes anything now. hanzo_service_up is recorded per
SERVICE: the prober asks a Service address whether the service answered, and a
Service address is not a replica. So the inventory is one row per service, keyed
by what was actually measured. Synthesising a pod name would invent a fact no
measurement supports. Wanting per-replica health back means MEASURING it — the
prober resolving endpoints and probing each — not deriving it.

Source says "datastore" now because it is. The dead instant-query decoder and
its envelope go with the caller that needed them; vmproxy.go is the last VM
holdout and is SuperAdmin-only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:49:15 -07:00
hanzo-dev abc239841f merge: risk: the strained state is a measurement, not a field (red-cleared, unrouted)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:44:49 -07:00
antje 4b030b7716 auth: two key shapes, pk- and sk- — hk- is not a key
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 1m43s
CI/CD / image (push) Failing after 4m19s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
APIKeyPrefixes drops hk-, so a string with that prefix no longer reaches
isAPIKey and never travels to IAM's get-user door. It resolves to no
principal, exactly as any other unrecognized bearer does.

pk- is unchanged: it is still recognized, still short-circuited to nil in
validatedPrincipal, and still resolves through resolve-key to an org and
nothing else. Nothing here lets it authenticate.

The analytics free-text scrubber loses its hk alternative for the same
reason — the prefix names no credential, and the bearer/entropy/query-param
branches still cover every real one.

Fixtures that used an hk- value as a working key are re-cast to sk-;
enumerations of the family throughout the comments now read pk-/sk-. Those
enumerations also carried fw_ and hz_, which isAPIKey has not recognized
since they were dropped as never-minted entries, so they go too — a comment
that claims to describe isAPIKey should describe it.

plugin/dns/openapi.json is regenerated from apps/dns; openapi.yaml takes the
identical string. The remaining hk- in openapi.yaml and plugin/{iam,ai} is
projected from hanzoai/iam and hanzoai/ai and has to be ripped there.
2026-08-02 13:42:47 -07:00
hanzo-dev 6b7cc06287 o11y: metrics take the ZAP road too, so nothing has to come and scrape them
Prometheus is gone. Metrics were the last signal still leaving this process by
being PULLED: a exposition on :9464 that something outside had to visit. That
was never a statement about metrics, it was a statement about where they were
KEPT — VictoriaMetrics, because every reader queried VictoriaMetrics. With one
telemetry store left, traces and logs already reach it in-process over ZAP and
metrics had no way there at all. cloud was the only service in the fleet whose
own measurements existed nowhere once the scrape stopped.

So metrics travel like their two siblings: gather the registry on a timer and
hand the batch straight to datastoremetrics.WriteMetrics, in-process. The
receiver on :4319 is for OTHER processes; this process owns the writer, so it
calls it rather than dialing its own socket to reach a function already in
scope — the same reasoning that makes traces in-process by default.

The registry stops being a published surface and becomes an internal buffer.
That is why this gathers Prometheus families instead of writing a second OTel
exporter: zapmetricreceiver.MetricBatch IS the family model — name, help, type,
labels, value, buckets, quantiles — and the meter provider already renders into
exactly that. One encoder between two spellings of one structure; a second one
would drift from it. MetricGatherer hands out a Gatherer, not the *Registry, so
a reader cannot become a registrant.

Tests drive the whole path: install the provider, record through the ordinary
OTel API, push, and assert what arrives — including that a ZERO gauge survives
the trip with its labels, because hanzo_service_up == 0 is a real negative
answer and a translation that dropped it would turn a down service into a
missing one. Every family shape is pinned, a failing store costs one skipped
push, and shutdown is idempotent.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 13:38:32 -07:00
hanzo-dev 993394fad1 o11y: the wire test pinned arrival, so it failed when the status started meaning delivery
typed_wire_test.go asserted a flat 200 for an unparseable body. That was the old
contract — the receiver answered 200 to everything — so with the status code now
describing DELIVERY it read 503 (no egress configured under test) and failed CI.

The fact the test actually exists to pin is that a malformed payload is RECORDED
rather than REFUSED: a 4xx would make Alertmanager retry it forever. That is now
asserted directly, with an egress installed so delivery is not the variable
under test.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 12:48:41 -07:00
hanzo-dev 2b4f964c17 deps: take hanzoai/sqlite v0.5.0
v0.5.0 deleted the key-derivation and DEK-wrapping API (DeriveKey,
DeriveChildKey, NewDEK, WrapDEK, UnwrapDEK, PrincipalType, PrincipalAAD,
WithPrincipalKey). cloud stopped calling it in 4cb56f6b; commerce and
tasks were the two things left in the build graph that still did, and
both have now moved to cek + namespace:

  commerce v1.49.45  per-org store keyed by cek.DeriveKey, placed by
                     namespace.Path; DEK sidecar, rewrap and the
                     plaintext->encrypted migration deleted
  tasks    v1.52.9   shards opened by cek.Open under the shared
                     orgs/<org>[/projects/<p>]/<ns>.db layout; dek.go
                     and tasksd upgrade deleted

cek goes v0.2.2 -> v0.2.1, which is a REQUIREMENT of v0.5.0, not a
preference: v0.2.2 added sidecar.go to read legacy wrapped-DEK databases,
and it is written against the very symbols v0.5.0 removed, so v0.2.2 does
not compile against v0.5.0 at all. Its exported API is identical to
v0.2.1 — the delta is that legacy read path plus its tests — and reading
databases written under the old scheme is the compatibility this
migration exists to remove.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 12:47:15 -07:00
hanzo-dev 9f374964b3 o11y: take v1.5.50, where the relay collapse reaches all 108 call sites
v1.5.49 carried the request-target fix in ONE of five relay bodies. The other
four — infra, metrics, rules/alerts, integrations — still handed the embedded
runtime a request with RequestURI empty, so 94 of 367 routes answered 404 in this
binary while the standalone served them 200. That is the configuration cloud runs
in production, so the whole of infra, metrics, rules/alerts, integrations,
cloud_integrations, downtime_schedules, route_policies, metric_reduction_rules
and gateway ingestion_keys was unreachable through the unified door.

v1.5.50 collapses the four copies into the one relay and adds the reachability
census that measures it — registered and reachable are now the same set.

It also stops a restart from locking every principal out of o11y (localauthz now
rehydrates its tuples from the durable role rows), and stops a schedule-less
maintenance window from panicking inside encoding/json — a panic that in this
binary would take iam, commerce and ai down with it, and that only became
reachable now that the downtime routes are.

plugin/o11y is unchanged by the bump, verified by regenerating: the fix is in how
a relayed request is shaped, not in which routes exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:56:50 -07:00
hanzo-dev 9576ff8427 risk: the strained state is a measurement, not a field
A control that switches itself off has to be visible from OUTSIDE the process,
and "visible" has to mean a test can drive a tenant into the state and read it
back. The probe published `strained` and nothing held it: a constant zero would
have passed every assertion here.

Now one organisation is pushed past its own aggregate ceiling — where it starts
forgetting its least-recently-active subjects, and every velocity feature for
those subjects reads as inactive, which is exactly what a quiet subject looks
like — and the probe is read back for a non-zero count.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:51:15 -07:00
hanzo-dev 5c4bf7e65f risk: state the scope's share of the budget as the number it is
A figure in a comment is a claim. One scope at both its ceilings is 4.0 MiB of
the 128 MiB budget — 3% — not the vague fraction that was written there.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:46:17 -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 87da6e3a24 risk: three controls that a test could not have caught switching off
Mutation testing found three assertions that survive the defect they name, which
means they were not holding anything:

THE FEATURES GATE was proved only by its METER. Pricing the gate at zero screens
left every assertion green — an op that bills the right number while admitting a
caller with no balance, which is the control off with the books looking perfect.
It is now proved as a bracket, the shape the search gate already used: a cent
under the measured cost refuses, exactly the cost admits.

THE SEARCH METER was proved on a run that RAN TO COMPLETION, where "what it was
admitted for" and "what it did" are the same number and no assertion can tell
them apart. It is now proved on a run a rollout cuts short — the case that
matters, since this binary deploys at one replica with the old pod stopped first,
and the case where metering at accept charges in full for a grid that never ran.
The arena check that needs a completed run is its own test now.

THE FOLD MARK's second step was invisible to every behavioural test, because
replayable applies the window a SECOND time in memory where the nanosecond still
exists, so the re-read row is dropped downstream and nothing upstream can see it.
It is asserted at the seam where it is real instead: a mark must still exclude
the bucket it names AFTER a round trip through tsLiteral, which is second-grained.

Also: the snapshot interval had no test at all — only the count watermark did.
sweep takes its schedule as a parameter, so newPlane states it at the call site
and the loop is measurable without a thirty-second wait. A model that learns a
little and then goes quiet never reaches the watermark, and that is the case the
interval exists for.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:44:18 -07:00
hanzo-dev 9db09d3b1b risk: say what the report costs the plane it reports on
The scan is under the lock and the sort is not, so the two numbers that matter
are the report at a full table and an observation while it runs. Measured
rather than argued: 844us for the whole report over 4,096 callers, 313ns to
observe one request.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:37:18 -07:00
hanzo-dev dfeaec4eb8 risk: a caller is what the server attested, and a bound is a number of bytes
Three findings, one root cause: the sensor and the enforcement were keyed on
the raw Authorization value, validated or not — a string the caller picks. A
held verdict was walked out of five times in five by editing that header; the
lane with no tenant could be filled on demand; and a caller could open one
table entry per request.

A CALLER IS A FACT WE STATED. Signal now carries two fingerprints of the same
credential under different trust: Cred, set only when the identity boundary
VALIDATED it, which is the only thing that can key a caller; and Presented,
whatever the request carried, counted only as spread because a wall of invalid
credentials from one address IS the stuffing signature. callerKey cannot see
the second one. Both are built in ONE place (observation), pinned by a test.
The scorer is asked about the same thing the sensor holds, so a refused caller
cannot be re-judged as somebody else by editing a header.

NOTHING IS REMOVED TO MAKE ROOM FOR SOMETHING ELSE. The reclaim pass that
dropped the oldest half is deleted, not guarded: a table reclaims keys that are
DEAD (unseen for a window, under no live verdict) and REFUSES what does not
fit. So a flood cannot erase a neighbour's counts, cannot release a held
verdict, and cannot overrun the ceiling — the previous rule skipped pinned keys
and then admitted anyway, which is how 25,000 held callers lived in a table
that published 20,000.

THE BOUND IS IN BYTES. A cap on the NUMBER of keys is not a bound when the
values behind them are not bounded, so every string that can enter an entry is
clamped at the door, every entry has a published worst-case size, and every
admission charges it against ONE process budget. Count x size IS the byte
bound; a test fills a table with worst-case values, measures it, and fails if
the published numbers understate it. The per-scope ceilings stay as the
FAIRNESS bound, so the anonymous lane cannot take the room the tenants need.

A BOUND THAT BINDS SAYS SO. Strain is graded — clear, full, refuse, blind —
carried on the observation so an unmeasured caller is not screened as a brand
new one, reported on the scope's own view, and announced once per rise rather
than once per request. The lane that has no tenant is readable at last: it is
named by the empty scope (?org=), which cannot collide with any tenant.

WHAT THE SENSOR CANNOT SEE IT DOES NOT INVENT. A request with no validated
credential and no client address has no identity; keying it under the empty
address would file the whole internet in one row, read as the worst stuffing
run ever recorded, and let one verdict refuse everybody. It is counted as
traffic, named blind, and nothing is held against it. This is the live shape
today: the balancer in front of the ingress is TCP with no PROXY protocol, so
no client address reaches this process at all.

THE LANE IS DERIVED FROM THE COUNTS, so it is computed inside the observation
that produced them. Passing it in meant stating it before those counts existed,
and every request ever counted landed in "unknown".

ABSENCE IS NOT SILENCE. The fail policy turns on two facts, the same rule
hanzoai/iam applies at its own gate: a privileged grant waits for a scorer that
is THERE and did not answer, and proceeds when there is no scorer at all — or
when every slot is held and nothing has come back for a stall, which is a
deadlocked scorer, not a queue. Without that second fact one hung goroutine
403s every armed org's key store until the pod restarts.

AN UNANSWERED SCREEN IS NOT A SALE. Only a scored verdict is billed; the count
is split into answered and unanswered so a scorer that has gone dark is a
number on the org's own report. A fact we do not have is ABSENT from the
signals, never empty — an empty string is a value a scorer can group by.

A SCOPE KEY NAMES THE ROUTE. canonicalService and the rate limiter's
exemptions read the router's path, not the spelling: "/V1/AI/chat" reached the
"/v1/ai/chat" handler while producing a different rate bucket and a different
spend-cap axis.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:32:22 -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
hanzo-dev 593aa309fe o11y: regenerate the catalogue the app has actually served since it mounted
apps/o11y calls o11y.Mount(a) unconditionally, which registers the module's 353
typed operations. plugin/o11y's committed projections were last written the day
BEFORE that call landed, and nothing regenerated them: mcp.json held 12 tools and
openapi.json 20 paths / 32 operations.

So the fleet MCP door at POST /v1/mcp offered agents twelve o11y tools — the
reviews queue and one summary — while the process behind it served three hundred
and sixty-five. The observability surface was absent from /v1/openapi.json and
from every SDK generated off it. Regenerating with the app's own `describe`
target gives 365 tools and 289 paths / 388 operations, and takes the fleet total
from 932 to 1285.

apps/o11y/zipdoc_gen.go was stale in the same window: it still carried
POST /obs/event/claim, a route that no longer exists, and the pre-rewrite prose
for POST /v1/o11y/alerts/:receiver, which now states that delivery is
synchronous. Both come back from source.

This is what `make -f mk/fleet.mk surface-check` exists to catch, and it did not,
because the gate has been failing earlier in its own loop on unrelated apps — a
red gate stops reporting anything new. Recorded rather than papered over: the
regeneration is verified idempotent (a second run reproduces it byte for byte),
and the remaining blockers are in other apps.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:25:01 -07:00
hanzo-dev fbd1f97895 risk: the published baseline has a reader, and the recording warehouse honours a window
The daily recompute wrote hanzo.risk_baseline whether or not anything read it,
and nothing did — a warehouse cost paid every day and collected on never, which
is the same "declared but unwired" defect as a reader with no writer, inverted.
The catalogue gains a NETWORK lens beside its model and surface lenses: the
published bands over the same window, carrying no tenant and the same for every
caller, so an organisation's own surface has something to be read against. One
table, one reader, so the disclosure argument has one place to hold.

Two lenses can fail in one response — an unreachable warehouse fails both — so
the gap accumulates instead of reporting one outage as the other's.

The recording warehouse files baseline rows under the TABLE and not under a
tenant, because that table has no tenant to file them under. Keying them by the
bound org meant keying them by the window's start, which a test had to guess to
the second in order to see its own fixture.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:12:48 -07:00
hanzo-dev b5190e4f9b platform: cap the other two emptyDirs, and state all three in one place
30497e64 capped the buildkitd volume because it is the one that grew. The
artifact job's /w and the smoke job's /data were left as `emptyDir: {}`, and
that is the same defect with a different volume name — which is why this states
the rule as a table rather than adding two more one-off assertions.

An uncapped emptyDir is charged to the NODE's ephemeral storage. It therefore
does not fail the pod that fills it: it fills the runner's rootfs, trips
DiskPressure, and the kubelet answers by evicting the pod's NEIGHBOURS. A job
can stay inside every resource limit it declares and still take down every other
job on the node, so the bound has to be on the VOLUME and not only on the
container — three of eight runners under DiskPressure, 70 finished build Jobs
still holding their pods, and a release Evicted mid-flight for "node was low on
resource: ephemeral-storage".

/w is not scratch: it is the clone, HOME, GOPATH, the npm cache and every
produced binary for every platform, for every recipe entry in turn, in one
volume — the same order of magnitude as the build cache, so the same half-a-node
bound. /data is genuinely small (one 120s boot test against a fresh SQLite) and
is capped anyway, because the failure it prevents is an image that loops writing
on boot, and that one is the neighbours' problem rather than its own.

The three consts sit together: "how much node disk may a job take" is one
question with three answers, not three unrelated numbers, and a reader who finds
one now finds all of them. TestBuildCacheIsCapped is folded into the table — it
was exactly the table's `build` row, and a rule with two gates is a rule that can
be half-updated. Its incident evidence moves into the table's doc. Proven by
uncapping /w: the artifact row fails with the volume, the wanted bound and what
it actually found.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:11:54 -07:00
hanzo-dev 4e02a8f5ea Merge remote-tracking branch 'origin/main' into blue/risk-learning-fix
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:11:25 -07:00
hanzo-dev c9ba05f1e8 Merge remote-tracking branch 'origin/main' into blue/risk-learning-fix
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:08:13 -07:00
hanzo-dev 599bb152f9 o11y: put back the two things the o11y-convergence merge dropped
Both were lost the same way — a conflict resolved by taking one side whole,
where the correct answer was on both sides.

THE PLANE SINK STOPPED COUNTING. 30497e64's predecessor put
cloud.ObserveRows at datastoreSink.Insert, after Send, because that is the one
choke point every plane row this process writes passes through, and because
"event.span went to zero" is the sentence nobody could say for four and a half
months. fix/o11y-convergence deleted event_ingest.go — correctly; it was a
writer that had never once reached a table — and carried datastoreSink forward
into planesink.go from a branch point 32 commits behind, so the counter went
with the dead file. The merge was a modify/delete, git kept the deletion, and
nothing failed: metrics_plane.go still seeds event.span at zero, so the series
exists and reads zero, which is indistinguishable from the outage it was added
to detect. Restored at the same place, after Send, on the surviving sink.

THE NEWEST PLANE TYPE WAS OUTSIDE THE GATE. bf48977a dropped ObsClaimIn and
ObsClaimed from TestNoPlaneTypeCarriesAnUnencodableKind because the op was
retired, which is right, but fix/upstream-status-and-spend-alerts-rpc had added
plane.ScopeRules in the same window and it was never added to the walk. That
test exists because the failure is a property of the KIND — the next map added
to any of these types is another 24h door-shut-while-green — so a plane type the
walk does not reach is the one place the next one will appear. ScopeRules is
walked rather than ScopeRule: the walk descends a slice of structs, and the
reply is the only way the row type ever crosses.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:07:22 -07:00
hanzo-dev 422c13312e Merge remote-tracking branch 'origin/schema/event-fact'
# Conflicts:
#	apps/analytics/warehouse.go

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:06:08 -07:00
hanzo-dev ba267bd506 mcp: one redirect, not two — the terminal handler is the one that can be right
The merge brought two implementations of the same rule together. main landed
mcpAlias (34554a19) five hours after feat/mcp-one-door was opened, both answering
the bare /mcp with a 308 onto /v1/mcp. Keeping both is not belt-and-braces: the
route wins over the terminal handler, so webui/mcp.go's FrameworkMCPPath branch
becomes unreachable code that its own tests still exercise in isolation — a rule
that passes its test and never runs.

mcpAlias goes, and the terminal handler stays, because only one of them is
self-scoping. app.All("/mcp") claims the path unconditionally, so the same call
inside cloud.Serve would hijack a PLUGIN's own zip door — a plugin serves MCP at
exactly FrameworkMCPPath, and an alias there redirects the door onto itself.
webui's handler is terminal: it sees only paths no route claimed, so the plugin
matches its real route and never reaches it, and the host, which moved its door
to MCPPath, does. Same behaviour on the host, no footgun on the child.

It is also the superset. mcpAlias answered /mcp only; mcpDoor also answers a
non-POST /v1/mcp with 405 + Allow: POST (Streamable HTTP's "no SSE stream" —
where a 404 says the door is absent) and gives both replies a JSON body, so a
caller reading bytes instead of following the hop still never gets HTML. And the
address is now one value, manifest.MCPPath, where main still had it as a literal
in cmd/cloud and a second literal in manifest/mcp_test.go.

main's test survives intact and gets stronger: app308 no longer calls a helper,
so it now proves the COMPOSED host redirects. Neutering mcpDoor reproduces the
original defect exactly — POST /mcp = 405, GET /mcp = 200 text/html, 3576 bytes,
the same byte count 34554a19 recorded from production.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit c4c3f40986725d1e99b6050c09de2aa8dfb63d48)
2026-08-02 11:05:08 -07:00
hanzo-dev dc0cc5027a Merge remote-tracking branch 'origin/feat/mcp-one-door'
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:05:00 -07:00
hanzo-dev bf48977ada plane: stop walking a retired op's types
The kind-walk gained ObsClaimIn/ObsClaimed when it was written; the
convergence lane then retired obs_event_claim along with the dead
LLM-obs write path it fed, so the walk referenced types that no longer
exist. Dropped from the list and the header note rewritten to say why:
the property this test guards is a property of the KIND, which is
exactly why it walks kinds instead of a list of types, and it outlives
any individual op.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:59:53 -07:00
hanzo-dev fa5fed4d70 Merge remote-tracking branch 'origin/pci/route-contract' into HEAD
# Conflicts:
#	openapi/floor.json

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:36:57 -07:00
hanzo-dev e3c629c853 Merge remote-tracking branch 'origin/fix/upstream-status-and-spend-alerts-rpc' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:36:22 -07:00
hanzo-dev ddd277750b Merge remote-tracking branch 'origin/fix/o11y-convergence' into HEAD
# Conflicts:
#	apps/o11y/event_ingest.go
#	go.mod
#	go.sum

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:36:22 -07:00
hanzo-dev 7c40d6c976 Merge remote-tracking branch 'origin/fix/event-door-honesty' into HEAD
# Conflicts:
#	apps/analytics/event.go

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:34:40 -07:00
hanzo-dev 04a3616094 risk: ratchet the surface floor to the ten paths the risk plane publishes
The floor is a monotone history of what the surface published, per product; the
merge left it at main's totals while the woven document carries risk's ten. The
ratchet raises it from the regenerated document rather than from either side's
guess.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:29:07 -07:00
hanzo-dev 4650d15c65 Merge remote-tracking branch 'origin/main' into blue/risk-learning-fix
# Conflicts:
#	openapi/floor.json

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:24:38 -07:00
hanzo-dev f600c8efd6 risk: the learning plane is /v1/risk, and every bound is in the dimension that binds
The learning ops move off /v1/ml, which a live product already owns: the
Kubernetes model-SERVING plane (InferenceServices, /v1/ml/models/{name}/predict)
is on it and has customers. /v1/ml/models would otherwise have meant "models you
serve" and "models that learn" at once. Nine typed ops move to /v1/risk/*, the
serving routes are untouched, and there is no alias — nothing shipped under the
old prefix to be compatible with.

A CAP ON A COUNT OF CALLER-SIZED VALUES IS NOT A BOUND. Every per-tenant ceiling
here was a COUNT multiplied by something a caller chose, so the published figures
understated the real cost by whatever the caller picked. maxField bounds, in
bytes, every string an observation carries; the ceilings are derived from it; and
the published figures are now MEASURED against a real worst case rather than
recomputed from the same formula.

A partly-applied bucket is no longer a state the fold can be in. ctx is consulted
at bucket boundaries only, so an interrupted fold resumes exactly where it
stopped instead of re-teaching what it already applied. The watermark steps by a
whole second because that is the finest value tsLiteral can bind — a nanosecond
mark was truncated back onto the bucket it meant to exclude.

The fold marks only what the surface can HOLD. horizon() is one function: the
rollup stops rollLag behind the present, so the fold reads and marks there too.
It used to mark `now`, which claimed buckets the rollup had not written yet — the
next rollup wrote them and the fold never came back, so every cycle dropped that
organisation's most recent history while the model reported itself warm.

A gap re-arms. Only a fold that SUCCEEDED is a fold that happened, so a warehouse
blip on a tenant's first touch no longer costs it the moat for the life of its
residency.

The recording warehouse now honours the bound window. A fake that ignored it made
every window assertion in this package a stub, and fixtures derive their bucket
stamps from horizon() rather than from `now` — a row stamped `now` is a row that
could not exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 10:12:09 -07:00
hanzo-dev 66abde02bc analytics: a campaign read is a NARROWING of the behavior lens, not a second one
`campaignWhere` wrote its own predicate — org, time bounds, `utm_campaign` — and
no `signal` at all. On five tables that was harmless, because naming
`event.event` selected product events by construction. On the ONE fact table the
signal is a PREDICATE, and a predicate can be forgotten: this one was, so
`uniqExact(distinct_id)` and `sum(revenue)` ranged over every log line, span and
error in the window as well as the acts.

It is latent only because nothing carries a `utm_campaign` yet. The day a
campaign ships it stops being latent, and the first number anyone sees is wrong.

So the campaign read composes `eventsWhere` instead of restating it. Which rows
and which sort is ONE decision and now has ONE spelling; the campaign and the
optional variant are what this read adds. Arg order follows the composition —
[org, signal, start, end], then campaign, then variant — and everything
user-derived is still bound, never interpolated.

The regression test pins the composition rather than the string: the built
predicate must have `eventsWhere`'s output as a prefix, with its args leading.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 00:01:41 -07:00
hanzo-dev e5a0712dc0 event: one occurrence table, discriminated by a signal column
The plane was five tables with an identical envelope — event.event, event.error,
event.log, event.span, event.metric. That is CONSISTENT and not UNIFIED: no
cross-signal question could be asked without a five-way UNION ALL, and a new
product meant a new table name, which is how a namespace grows a _v2.

Occurrences now land in ONE table, event.fact, with `signal` saying which sort
of thing happened. `signal` also leads the partition key, so per-signal
retention still drops whole parts, `WHERE signal='log'` still prunes to a fifth
of them (measured: Parts 1/5), and the Replacing collapse stays per-signal so an
id collision across two signals cannot delete a row.

  event -> act      a namespace cannot also be a member of itself
  group -> issue    it identifies an issue, and `group` is one letter from `groups`
  site  -> origin   the place of the fault; `site` already names a published Site
  level + severity_text + severity_number -> severity UInt8, the OTLP number
                    alone, with the word a read-time function of it
  group0..group4 -> groups Map(type, key)

clip is the worked example of why a signal is a value and not a place: session
replay costs one signal value and two columns (object, bytes), reuses
duration/session_id/url, and adds no name to the namespace. The blob it indexes
stays in object storage — it is wrong for a bus message and wrong for a
warehouse row.

Write path: `table` is where a signal lands and how its row is built, and there
are exactly two because there are two grains — an occurrence happened, a sample
was measured. Five writers share one column list, one args builder and one
INSERT string, so they cannot drift into five shapes of one envelope.
landableSignals still DERIVES from writers, so the door and the store are one
edit. sample keeps no writer until event.sample has its name (o11y migration
0003): a door that accepts what the sink cannot land is a 200 that means
discarded.

Read path: `scope(org, signal)` is the mandatory leading predicate of every read
— the tenant first because it leads the sort key, the signal second because it
leads the partition key, both bound. Two lenses were hand-rolling `org = ?` and
would have silently read every signal as a product event.

The fact struct is flat because the table is flat; four optional bodies were the
right shape when each had its own table. Sparsity is not a cost — measured on
the live event.log (798,375 rows), an unpopulated column costs 515 bytes for the
whole table.

DDL is unchanged here and stays unchanged here: hanzoai/o11y owns it
(deploy/datastore/migrations/0002_event_fact.sql), which the pre-existing scan
over this package's sources enforces.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 22:17:12 -07:00
hanzo-dev 6c56a1af09 api: the fleet finds out when a client asks for an address nobody serves
Six instances of one defect shipped: the billing surface dropped compound words
from its route names (payment-methods → methods, payment-config → settings,
auto-recharge → recharge, spend-alerts → alerts, credit-grants → credits, and
four more), cloud and commerce both converged, every published document agreed
with itself, every server test passed — and not one client followed. The
clients' own tests passed too, because a test that pins the URL its client sends
is a test of nothing: it agrees with the client while the client is wrong.

router_test.go asks whether every path an app PUBLISHES reaches that app, and it
is complete on that side. Nobody was asking the other question. manifest/
clients_test.go asks it: for every address a first-party client requests, is it
ROUTED (some row in manifest.Apps hands it to an app) and is it SERVED (that app
registers it, per the subset that app's own binary projects from its own
router). Neither side is derived from the other — `calls` is hand-authored
source, the router is built through the real zip.Load, and the subsets are
forced back to source by `make test`.

A second gate refuses the retired NAMES outright, in `calls` and in Apps. The
general one can only catch a compound name once somebody writes it down, and the
whole failure was that nobody did.

Proven by reverting each half. Point the clients back at the old names and four
failures name the client, the address and its replacement. Drop /v1/billing/
alerts from the commerce row and four more name the address, the app the fleet
delivers it to instead ("ai", the tail /v1 remainder) and the fact that it does
not register it. Both green with the fix.

Writing the ledger turned up three breaks nothing was watching, all the same
shape:

  - GET /v1/billing/portal/methods. cloud's billing app serves the saved-card
    list by proxying here, and NOTHING in the fleet serves it — so the list is
    empty however many cards were vaulted. It was wrong twice: it also asked for
    the retired /v1/billing/portal/payment-methods, which does not exist in the
    commerce module this binary links. The name is fixed here. The missing owner
    is NOT, and is recorded rather than guessed: PortalPaymentMethods keys
    tenancy on a customerId query param, so the console chain cannot serve its
    service-token caller and the S2S chain would let any authenticated browser
    read another tenant's cards by naming their customerId. Choosing that gate
    is an IDOR decision, not a route.
  - DELETE/PATCH /v1/billing/methods/{id}. The billing app owns the prefix and
    registers only the collection, so the live edge answers 405: a customer can
    add a card and never remove one. Reaching the handlers means proxying an
    address this app owns, which is the self-dispatch loop that produced the
    depth-8 502s on top-up. Recorded.
  - POST /v1/billing/payment. Never served by anything, ever. Recorded.

GET /v1/billing/credits WAS fixable and is fixed: the handler sat in the
vendored module with no app registering it, so the Credits tab read a 404 the
client swallowed into an empty list. Registered co-resident beside its siblings,
named on the commerce row, and now in the woven spec — so the SDKs get it too.
Minting is untouched and stays on the mint-gated POST /v1/billing/credit.

Regenerating the commerce subset also surfaced the same defect in the prose
layer, which is why `make -C apps/commerce describe` was already red on main:
POST /v1/store/token had no description because the description that exists was
keyed to /v1/store/storefront-token — an address the router has never
registered. Prose written for a route nobody serves is the identical mistake one
layer up. Re-keyed, so the gate is green again.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 22:10:37 -07:00
hanzo-dev 1167442725 fix: a propagated 402/403 keeps its status; spend-alert rules leave HTTP for the plane
Two defects, each fixed at its one seam.

402/403 -> 500 (#141). zip renders 500 for any error that is not a
*zip.HTTPError, so a clean refusal reached the console as an internal
fault: commerce answers 402 out of funds, the gate answers 403, a plane
callee answers either — and the UI drew a DEAD CARD, with no code to
branch on. The status always existed (ErrInsufficientBalance MEANS 402;
zip's callFault carries a callee's status across the plane intact); what
was missing was a seam to carry it to the wire, so ~20 handlers
remembered cloud.Denied and every other one 500'd.

errmap.go decides it ONCE — refused() is the single classifier, read by
both the app's renderer and the money wire's denial() — and Serve installs
it. A refusal is 4xx ONLY: a 5xx is the absence of a decision, and reading
an unreachable-commerce 502 as a refusal would answer the money wire with
a transport message where "Billing temporarily unavailable" belongs. The
body stays zip's own {status, code, error}; this fills `code` rather than
inventing a second envelope.

spend-alerts 502 recursion (#146). ScopeRateLimit read its config with a
service-token GET /v1/billing/alerts through the commerce transport, which
dispatches in-process by publishing the WHOLE shared app — so the fetch
re-ran the entire edge chain, including ScopeRateLimit, whose cache is
filled only AFTER the fetch returns and was therefore still cold. It asked
again, and again, to the depth guard: 502, ~135 per half hour on the live
pod, with the ceiling failing open throughout. A split deploy recursed the
same way over the network.

commerce owns the rows, so commerce answers for them: a new plane op
(plane.FinanceScopeRules, apps/commerce/scoperules_rpc.go) over the ZAP
socket, which carries that app's ops and no edge chain — the recursion is
structurally absent, not bounded. metering.Client.ScopeRules and the
/v1/billing/alerts path are deleted, so there is one way. The op runs the
same whole-org query as commerce's cap verdict, so a rate ceiling and a
spend cap can no longer bind on different rows.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:58:46 -07:00
hanzo-dev d9e8f148f3 analytics: the obs door test names the world that is left
TestCanonicalDoorFallsThroughWhenObsPeerIsAbsent was written around two worlds —
a peer that claims the body, and a peer that is absent so the product wire takes
it. Only one of them was ever reachable: the claim's sink wrote to tables that do
not exist, so it declined every body it was ever offered, and the claim is now
deleted outright. A test that distinguishes a state from itself pins nothing.

The assertion it carries is the one worth keeping, and the parallel lane already
fixed it — the body lands ZERO facts and must be told so (400 unroutable_events)
rather than receipted 200. That is the mechanism that hid a four-and-a-half-month
span outage: a green check beside a total loss. It now asserts BOTH halves,
because either alone is a lie in one direction — a 400 with facts landed is as
wrong as a 200 with none.

So this keeps the assertion, drops the world, and renames to what it actually
pins: an obs-shaped body is ORDINARY at this door, refused on its own merits, and
the product wire beside it is untouched.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:58:35 -07:00
hanzo-dev 8635fbff3d o11y: three names for one concept, and the one that could not work is gone
A convergence audit read a deployed vintage and reported seven /v1-plane
drifts. Four were already closed on main; three were real, and the largest was
a write path that had never once reached a table.

apps/o11y/event_ingest.go inserted UNQUALIFIED `traces` / `observations` /
`scores`. The DSN it opened (O11Y_DATASTORE_DSN) names no database — hanzo-ds
sets Auth.Database from the URL path and applies no fallback — so those names
resolved to `default`, which is EMPTY on the live datastore. The file said as
much about itself ("ASSUMED SCHEMA", ungrounded against a producer that ships
dist-only) and the datastore's query log holds no such INSERT in its whole
retained window: not failures, never attempted.

It was also the third claimant on one concept. LLM observability is READ off
gen_ai spans in event.span by the o11y runtime, and the eval product owns the
grounded projections — hanzo.eval_traces / hanzo.eval_scores, with DDL
apps/eval/telemetry.go creates itself. So the sink is deleted rather than
qualified: pointing a duplicate at the right database would have made a path
nothing produces for look canonical.

Deleting it retires the plane op it existed to serve (obs_event_claim), which
means POST /v1/event — the fleet's busiest door — no longer makes a
synchronous cross-process round-trip per event to be told "not mine". The
answer the caller gets is unchanged: the claim could only ever decline.

planesink.go keeps the shared datastoreSink, beside its one remaining caller,
and states every table fully qualified. Two pins hold the shape it depends on:
tables name their database (the mistake above, made unrepeatable), and no
plane writer binds ingested_at — event.span measures retention AND partitioning
from that column and defaults it to now64(3), so a writer that names it hands
the wire control of when its own row expires. apps/analytics states the same
rule for its four warehouse writers; this is that pin for these two.

manifest/apps.go drops /v1/insights/e. A prefix there is a claim that the app
ANSWERS the path, and analytics retired that door — a claim it 404s kept the
retirement invisible in the one table that states what the fleet serves. The
four comments still describing it as live are corrected with it.

Verified against the live plane before touching anything: the JetStream stream
is EVENT (4839 messages, 5 consumers, durable on a retain-class volume) and
EVENTS does not exist, so the constant is right as written and a rename would
have orphaned the data or 503'd the plane.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:56:01 -07:00
hanzo-dev ebac5a8ae4 mcp: the door answers MCP, and the console stops pretending to be it
GET https://api.hanzo.ai/mcp returned 200 text/html — the console SPA. A client
that checks a status code reads that as a healthy MCP endpoint. POST there
returned 405 "method not allowed" in the console's own voice, which reads as a
door that exists and was called wrong. Neither was true: nothing was mounted at
that path at all.

Registration order is not what did it. The zap-proto/fiber fork matches by
ServeMux-1.22 specificity, so a static /mcp beats the console's /* catch-all no
matter which registered first — proven by the same app answering POST /mcp with
a real tools/list when the door IS there. The path simply had no route in this
process, and the terminal handler could not tell a machine door from a
client-side console route, so it served the shell.

Why there was no route: the address was written down twice and only once.
cmd/cloud moved zip's door to /v1/mcp with a literal; webui's apiPrefixes — the
list of paths the SPA must never answer — did not know the framework default had
been vacated. Two copies of one fact, and the second was missing.

So the address is now ONE value, manifest.MCPPath, read by the app that serves
the door and by the front door that must refuse to answer it with HTML. The gate
in manifest/mcp_test.go reads it too, instead of restating the literal a third
time — a guard that spells its own address can pass while the door has moved.

/v1/mcp is canonical: /v1/<thing> is the house rule, it is what production
already serves, and being under /v1/ is what puts it in the namespace where an
unmatched sibling is a real 404 instead of HTML. Not /v1/ai/mcp — the door
serves the union of every subsystem's build-time catalogue (929 tools across 116
apps), so filing it under one subsystem would either shrink it or misname it.

The terminal handler now owes a caller two honest answers, and neither serves
MCP:
  /mcp     -> 308 to /v1/mcp, JSON body. 308 keeps method and body, so a POSTed
              initialize or tools/list reaches the real door instead of the
              shell. A signpost, not a second surface — nothing here has a tool
              list.
  /v1/mcp  -> 405 + Allow: POST for a non-POST. zip registers only the JSON-RPC
              POST; MCP Streamable HTTP says a door with no SSE stream answers
              405, and the 404 it used to give says the door is absent — the
              same lie pointing the other way.

Both rules are self-scoping because the handler is TERMINAL: it sees only paths
no route claimed. A plugin serving its own door at zip's default — which is
where a host forwards a composed tools/call, and why cloud.Serve must keep it —
matches a real route and never reaches them.

And the API-namespace 404 now runs BEFORE the static-console gate. WHAT the path
is decides the answer, not what verb a browser would have used: a POST to an
unmatched /v1 path was answered 405, telling a client the endpoint exists and it
used the wrong method. 405 is a claim about a door; only a door may make it.

Proven against the built host binary, by body:
  POST /v1/mcp  -> 200 application/json, 42 tools, and an initialize handshake
                   returning protocolVersion 2025-06-18
  POST -L /mcp  -> follows the hop, id echoed, lands on the tool list
  GET  /mcp     -> 308, Location: /v1/mcp, no HTML
  GET  /v1/mcp  -> 405, Allow: POST
  /, /settings, /mcp-servers, /auth/callback -> 200 text/html, the console shell

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:55:15 -07:00
zeekayandhanzo-dev 28e0f2848f deps: commerce v1.49.39 -> v1.49.40 — the deploy carries the pricing change
v1.49.39 alone would have shipped a HALF-MIGRATED catalog. Every stored plan row
carries Managed=true, which commerce's seed sets on its own writes, and the seed
skipped any Managed row — so a boot would have created `go` and `dev` while
leaving `pro` at $20 and `developer`/`plus` on sale beside them.

v1.49.40 gives the seed a real discriminator (AdminEdited, set only by the admin
CRUD) so it can correct rows it wrote itself, and archives what the catalog
stopped publishing. Rolling this binary is now the whole cutover: no SuperAdmin
bearer, no script run against production, no manual step to forget.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:54:36 -07:00
hanzo-dev dc25e32d35 analytics: the event door tells the truth about what it stored
POST /v1/event answered 200 {"accepted":0,"dropped":1} for EVERY wire shape it
publishes when nothing could be attributed. The anonymous projection refuses a
kind it cannot name (publicKinds stores pageviews and errors; a log, a span and
an exception envelope are none of those) and said so in a receipt field no
client parses and no probe reads. A caller whose key was absent, revoked or
mistyped lost 100% of what it sent with a green check beside it — the mechanism
that hid an 88% log loss, a four-and-a-half-month span outage and a day of
missing Sentry traffic.

The rule is now exactly "did anything land", decided ONCE in `answer` (event.go)
— the tail every lane already reached:

  accepted > 0   -> 200, unchanged, INCLUDING the partial batch. A batch is
                   never failed whole for its worst element.
  nothing sent   -> 200. Dropping nothing is not losing anything.
  nothing landed -> 4xx naming the one thing the caller can fix, in
                   HTTPError.Code: 401 ingest_key_required (no credential),
                   403 insufficient_capability (a guest token that RESOLVED —
                   it has a key, so telling it to get one is a false
                   instruction), 400 unroutable_events (full capability, and
                   the body still named nothing storable).

DNT stays 200: an honored opt-out is the one total drop that is not a failure.

The o11y claim goes through the same receipt — it is where the missing logs and
spans were headed, so exempting it would leave the defect in the lane it cost
the most. obs_door_test.go asserted `want 200 via the product wire` for a body
that landed ZERO facts; it now asserts the truth.

A nonzero drop is visible: hanzo_ingest_dropped_total{org,source,reason} (what
an ingest-drop alert reads) plus a warn line naming tenant and door.

Shape dispatch is untouched — isInsightsWire still runs BEFORE the canonical
object branch, pinned directly at the decoder.

25 existing tests encoded the 200-while-storing-nothing as the contract. Each
keeps its capability assertion and swaps only the status observable.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 21:51:46 -07:00
hanzo-dev 59ed9a2992 dataset: every source scan is admitted, every outcome is recorded, one mint
Red held this plane on three ship-blockers, all of which were the same shape:
a property the package claimed and did not have.

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

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

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

Also closed, from the same review:

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 18:15:38 -07:00
hanzo-dev ae3a309941 dataset: a model can name the exact bytes it trained on, forever
A dataset is a VALUE, not a query. Storing a spec and re-running it is the
design that guarantees irreproducibility: the source is a SummingMergeTree
whose parts merge, its retention drops the tail, and the rollup behind it can
be re-run — so the same question asked twice is two different answers, and a
model that cites "the query" has cited nothing. Here a dataset is declared as
a version, materialised once, fingerprinted over the spec AND the rows, and
never rewritten. That is the only form in which an audit can be answered.

Seven typed zip ops under /v1/ml/datasets — declare, list, describe,
materialise, lineage, export, dispose. One declaration each, so the route, the
OpenAPI operation, the MCP tool, the CLI verb and every generated SDK method
come from the same place and cannot drift.

The four properties, and where each is enforced:

  tenancy       every statement opens `org = ?` with a tenant.Key, which has no
                exported field, cannot be written as a literal outside its own
                package and cannot be decoded from a request body. The key also
                leads both tables' sort keys and both partition expressions, so
                a per-tenant read is a prefix scan and a disposal cannot be
                spelled across a tenant. A row that comes back belonging to
                someone else is a REFUSAL, not a filter.
  immutability  `ready` is the greatest rank of the ReplacingMergeTree version
                column, so no later write of any other stage displaces a
                published version; the door refuses any transition out of a
                terminal state. Two layers, engine and door.
  determinism   splits are temporal cuts and then grouped by SUBJECT — a random
                split puts one device on both sides of the line and the model
                memorises the entity. Membership under the row cap is a seeded
                sample of the subject, recorded on the manifest, so a capped
                dataset reproduces instead of being whichever rows came back
                first. Coordinates are hashed as IEEE-754 bits, not as text.
  expiry        NO table TTL, deliberately: a table TTL is a fleet-wide clock no
                tenant can hold longer. Disposal is the tenant's own DROP
                PARTITION on (org, name).

The maturity horizon is what keeps a training set from knowing the future: a
chargeback lands 30 to 120 days after the transaction it condemns, so a row is
admitted only once it has aged past the horizon its version declares. Lineage
is MEASURED rather than recalled — the plane re-asks the source and says
plainly when the window can no longer be re-derived, because an admitted gap is
actionable and an unfalsifiable claim is not.

Bounded so a build cannot become a DoS: the window by the source's retention,
the horizon at a year, rows at 200k, names and versions per org, ONE
materialisation per tenant and eight in the process, a fifteen-minute job wall,
a bounded export page, and the priced act metered at the same gate the fleet
uses. Materialising answers 202 as soon as the attempt is on record; a client's
timeout is never a data plane's timeout.

Its own app rather than a leaf of another: it shares no state with a scorer, so
it restarts empty and answers identically — which a process pinned to one
replica for its in-memory forests cannot promise, and which is exactly what a
plane holding the record of what a model trained on must do.

apps/tenant is its own package because the key is not any one subsystem's: the
dataset plane, the scoring plane and the compliance plane must agree on it byte
for byte, or a dataset is keyed differently from the model fitted on it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 18:15:36 -07:00
blueandhanzo-dev 864d757a88 risk: one client address, one published document
FOUR MORE COPIES OF THE ADDRESS RULE. apps/security, apps/legal and
apps/compliance each carried their own clientIP — left-most X-Forwarded-For,
falling back to X-Real-Ip — and apps/plugin put the raw header straight into an
audit record's SourceIP. Every one of them writes the value into a durable audit
row, and both headers are written by the party being audited: an address the
subject chose is not evidence. They read cloud.ClientIP now, which is the one
rule, so there is one place to be right and one place to review.

THE DOCUMENT. plugin/gateway/{openapi,mcp}.json re-describe the config plane from
the typed ops — mode's prose says what it now does, and the traffic view carries
the tenant's own saturation count — and openapi.yaml is the weave of the subsets,
which this branch had left behind: it added GET /v1/gateway/traffic without
regenerating the golden, so the published document was one operation short of the
routes and openapi/floor.json still said gateway had two. The floor RAISES
(2 → 3, paths 1396 → 1397), which is the direction a new route is meant to move
it. `make -f mk/fleet.mk openapi-weave` is green and idempotent.

The o11y plugin's own chain test pins what the new boundary buys: a forged
X-User-Id and X-User-IsAdmin do not survive it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 17:18:07 -07:00
aandhanzo-dev 9f27cf3aa0 risk: a tenant's aggregates are its own, its record is durable, and its compute is paid for
Six defects, and the first two are the same defect twice: state a caller can
grow, held in one place, bounded once for everybody.

THE AGGREGATES ARE PER TENANT (ring.go). One process-wide velocity.Store with a
~9,225-key cap over the whole fleet backed eight of the nine model dimensions.
A key is <org, axis, subject> and the subject comes off the wire, so the busiest
organisation evicted the quietest one's keys — and a tenant reading zero for
eight dimensions scores as unremarkable and raises nothing. No error, no log, no
alert: a cross-tenant denial of service costing the attacker ordinary use of
their own account. The fix is the shape, not a bigger number: a ring set belongs
to one resident, newRings is the only constructor in the package, and the bound
is a per-tenant byte budget, so a tenant that outgrows it evicts its OWN
least-recently-active subject. The process ceiling is the product of two stated
numbers (64 x 8 MiB) rather than a hope.

THE RINGS ARE DURABLE, as a projection rather than as state. This binary rolls
one replica at a time with the old pod stopped first, so in-memory aggregates
were lost on every rollout — the same silent quiet, arriving on a schedule. They
are now rebuilt deterministically from the tenant's own record of what it
taught, bounded by age AND by count, because every tenant's shelf shares one
volume and a record bounded only by age is one tenant filling another's disk.

TIME IS BOUNDED IN BOTH DIRECTIONS. `at` was parsed and believed. The rings
track a leading edge, so one event stamped in the future moved it there and
every real event for that subject was then older than every window: one request,
and that subject's velocity features read as nothing for as long as the state
lived. Per-subject detector evasion, free, for any authenticated caller. Refused
at the wire door with a 400 and refused again in the rings, so no path can
poison the edge.

SCORE AND LEARN ARE GATED AND METERED. The only Gate/Meter call site was the
search. The two ops an abuser would actually call in a loop were free, unbounded
compute against per-tenant model state and a per-tenant disk write — a denial of
service and lost revenue at the same time, and which one it is depends only on
who finds it first. The billable unit is a SCREEN: one event judged against an
organisation's own model. Scoring is one, a batch is one per event, a search is
one per candidate per event, priced from the measured size of the run rather
than as a flat fee. Each tenant's in-flight calls are bounded too, per tenant,
so at the bound that organisation is told to slow down and nobody else notices.

THE MOAT IS WIRED. rollup() was the only writer of hanzo.risk_feature and had no
production caller, so the per-org feature surface was a table nothing wrote, the
warm read nothing, and the whole thing was prose. plane.roll is that caller: one
window per source plane per tenant under a durable per-tenant watermark, because
the surface is a SummingMergeTree and rolling a window twice does not replace
it, it doubles that organisation's history. Windows are aligned to the surface's
own five-minute grain — a window that ends mid-bucket counts every uniqExact
column once per partial insert — and every surface read rolls first, so a search
cannot answer "your history is empty" while the fold that would have filled it
is still running.

ONE ORGANISATION, ONE VOTE. The k-anonymity floor counted CONTRIBUTORS: twenty
five organisations satisfied it while one supplied a million of the million-and
-twenty-four values, and the published network median was then that
organisation's own median, republished under a name that says it is everyone's.
Each organisation is now reduced to one number — its own median over its own
subjects — before any quantile is taken, so every contributor's weight is
exactly 1/orgs, which the floor bounds at 4%.

Three more found while proving the above:

  A FOLD IS ADMITTED, NOT ASSUMED. A fold rolls four source planes and reads a
  window, and one goroutine per residency is unbounded fan-out at the warehouse.
  Tickets bound the concurrency; a tenant that finds none is not marked folded,
  so its next touch retries — deferred and reported, never dropped and silent.

  A HISTORY IS FOLDED ONCE. The fold watermark travels in the snapshot's own
  row, so a model restored from its own state does not read the same thirty days
  again. Without it, a resident bound a busy fleet hits routinely meant the
  masses counted how often we evicted a tenant rather than what its traffic did.
  Both watermark-driven folds serialise per tenant, because idempotent-under-a
  -watermark is only true one at a time.

  A METER OWNS THE STRINGS IT RETAINS (metering.Usage.Clone). A Usage built in a
  handler carries zero-copy views into the reused request arena, and MeterUsage
  records on a background goroutine — so the debit could marshal the NEXT
  request's bytes onto this caller's row, on a connection two tenants took turns
  on. Not a crash: a wrong record. Fixed once where the retention happens rather
  than in each of eleven callers.

Nine new tests, each named for the defect it refutes and each mutation-proven:
remove the fix and the test that names it fails. zipdoc -check green, the woven
document regenerates from source, and the suite is green under -race.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 17:13:58 -07:00
blueandhanzo-dev 27dbfd2682 risk: an address, a lane and a path are facts the caller does not get to state
Three ways a caller was deciding its own case, and one shared table every
tenant could evict from.

THE ADDRESS. ClientIP took the LEFT-MOST X-Forwarded-For entry — the one the
client writes — so one host could present a million clients: it defeated the
per-IP edge limit keyed on it, put a chosen address into an audit row, and fed
the sensor's address table without bound from an unauthenticated request. The
rule is now the peer first (a direct caller cannot lie about its socket) and,
for a trusted peer, the right-most chain entry that is not one of our own hops.
Our hops are a CIDR set (CLOUD_TRUSTED_PROXIES, private space by default), not
a hop count: a count is a promise about topology that nothing enforces. All
X-Forwarded-For header lines are read, since a second line hides the first.

THE LANE. principalValidated read c.Org() || c.User(), and both are headers:
X-Org-Id survives the boundary on the anonymous path by design, and a
hand-written plugin process may have no boundary at all. Two headers plus an
sk--shaped string that never validated moved a bad bot into the AGENT lane. The
boundary now states what it minted (principal.Mint, a request-local slot no
client can write) and the classifier reads that. No attestation ⇒ anonymous.
The gate's tenant comes from the same place, so a forged org cannot write into
another tenant's sensor state or read its posture.

THE PATH. Privileged compared strings.HasPrefix against the raw c.Path() while
fiber routes case-insensitively and ignores a trailing slash, so /V1/KMS/...
reached the key store and matched no grant prefix — the scorer's silence
ALLOWED what fail-closed exists to refuse. cloud.RoutePath is fiber's own
detection path, and every security comparison runs against it on segment
boundaries (which also stops /v1/iam/signupfoo matching /v1/iam/signup).

THE TABLE. edge.Traffic held one map for the whole fleet under a global cap,
so the org that filled it evicted whoever was quietest and that victim's abuse
controls went quiet with no error. State is now per tenant with a per-tenant
bound, reached only by indexing tenants[org]: a cross-tenant read or eviction
is unwritable, not merely refused, and a tenant at its ceiling degrades itself
and reports it (TrafficView.Saturated). One reclaim policy in table[V] —
unexported map, cap as a constructor argument, a live verdict never dropped.

ARMING. Mode was seeded from the platform row for every org, so the one PUT
that arms the anonymous lane armed every tenant silently. It reads the org's
own row now; the platform row governs the lane with no tenant and nothing else.
And it is no longer self-service: writing it takes SuperAdmin whichever row it
lands on, because the subject of an abuse control must not be able to switch
the control off — an org-admin credential is what a stolen key buys.

Also: asking is bounded (MaxScorerCalls in flight, RefusalBusy past it, so a
stuck scorer cannot become an out-of-memory), and the o11y plugin installs the
identity boundary it was reading headers without.

Every fix carries a regression test, and each was mutation-proven: revert the
fix, exactly that test goes red.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:59:55 -07:00
blueandhanzo-dev fb3aa34bec risk: a hold must not buy a free minute at a time
When a held verdict lapsed, the caller went back to being judged only by the
local pattern — so an attacker refused on evidence the SENSOR cannot see got a
minute of enforcement and then walked. The scorer reads the org's own history:
prior accounts on a device, a spend curve, a chargeback. None of that leaves a
trace in a rolling minute of request counts, so waiting for the pattern to
re-trip waits forever.

A lapsed hold now forces the question again on the next request. Once the scorer
allows the caller, the record is released so it stops forcing a screen — the
hold record is exactly the interval between 'a verdict ended' and 'the scorer
said it is fine now'.

The test backdates the deadline rather than sleeping a millisecond, because a
millisecond hold is a race and a test that can flake is a test that will.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:59:53 -07:00
blueandhanzo-dev d37dc8d292 risk: one credential is one caller however it is spelled
credentialClass read the Authorization header directly, which is a SECOND answer
to a question the identity boundary already answers. callerToken is the one
resolution SanitizeIdentity and CallerBearer share — Authorization bearer, then
X-Authorization, then Basic, then a session cookie — so a client authenticating
with any of the others validated upstream and was then counted here as anonymous,
pooling its traffic under its ADDRESS instead of under itself. That is precisely
the caller the sensor exists to tell apart from its neighbours, and it was the
one it could not see.

Now it reads callerToken, with X-Api-Key after it: that header is not in the
boundary's precedence but several SDKs send it, and a caller the boundary could
not identify is still a caller the sensor must distinguish from the next one.
Three spellings of one key now fingerprint to one caller, which is asserted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:59:48 -07:00
blueandhanzo-dev 229e364b1b risk: the lane with no tenant is the one a bad bot calls from, so it must be armable
edge.Store.Mode resolved an empty org to shadow, which meant the anonymous lane
could never be armed — there was no org to arm, so the one lane the gate exists
for was permanently a sensor. It now resolves to the PLATFORM row, the same shape
PerIPRPM already has and for the same reason: a request with no tenant at
evaluation time is governed by the platform scope. A SuperAdmin arms it by
targeting the reserved admin org, whose row IS that scope, so this is one
mechanism rather than a second one for the case that has no tenant.

Also: a liveness probe is never a grant and is never screened, through ONE
predicate both the exemption and Privileged read, so the two cannot disagree
about what a probe is. Without it a fail-closed deployment answered 403 to its
own kubelet on GET /v1/kms/health. Read-only methods only, so a route cannot be
named into the exemption to dodge the gate.

And the exempt list is now only what it has to be: the appeal surface (/v1/risk,
because a refusal nobody can read the decision for is unappealable) and the
internal money plane. /v1/ml and /v1/aml come off it — they are ordinary
surfaces served by ordinary apps, and the gate calls the scorer as a Go function
rather than as a route, so watching them cannot re-enter anything.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:59:47 -07:00
blueandhanzo-dev 8cbb7f39b7 risk: the edge asks one scorer, and the answer to silence depends on what is being granted
Two enforcement points now defend the lifecycle, and neither of them scores.

cloud.Decide (risk.go) is the ONE door to /v1/risk: the app that owns that
prefix hands its scoring function to the core with SetRiskScorer, the same
inversion SetObsEventIngest already uses because package cloud cannot import an
app. The fail policy lives in one function and nowhere else — an absent,
erroring, silent, out-of-vocabulary, panicking or over-budget scorer ALLOWS an
ordinary request and BLOCKS a privileged grant, and every answer names why it
was not scored, so an allow that happened because nobody was listening is never
recorded as clean.

AbuseGate sits between the rate limiter and the funding gates and keys on the
CREDENTIAL, which is what neither existing limiter can see: EdgeRateLimit keys
on client IP before identity, ScopeRateLimit on (org, project, service), so a
key lifted out of a CI log and used inside its org's normal ceiling is invisible
to both. It counts, classifies, asks and enforces; a non-allow verdict is held
for a minute so an attack costs one screen rather than one per request, and the
refusal is a 401/403 that AuditTrail already records — one event, one record.

Shadow per org by default, and arming is refused while no scorer is installed.
That is the line between a defense and an outage: failing closed on a component
that was never wired would answer 403 to a deployment's own key store.

Agency reads our own issuance and never the client's self-description. An
attributable machine credential is the agent lane whatever its user-agent
claims; a browser session is human; an unattributable caller is unknown until it
shows an abuse shape, and only then bot. Anonymous is not malicious.

The sensor lives in the leaf edge package so the middleware and /v1/gateway
share one object. Bounded by construction, org leading every key, credentials
present only as a keyed per-process fingerprint that cannot be tested against a
candidate key off-box.

GET /v1/gateway/traffic reports the caller's own lane split, denials, screens
and busiest credentials — the differentiator has to be visible to be worth
anything.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:59:43 -07:00
aandhanzo-dev 4a92779e33 risk: every organisation's model learns from its own data, and cannot reach another's
A new app, apps/risk, owning the native leaves of /v1/ml: the per-organisation
feature surface over the event surface every org already writes to, and the
per-organisation models trained on it.

The leaves are on this row and not on ml's because the model is IN-PROCESS
MUTABLE STATE. A manifest row is a binary; if one process learned and another
scored, the two would hold different mass counters and answer one question two
ways, with no error and no log. One owner of the state, one row. ml keeps the
kserve/Kubeflow bridge at /v1/ml/models and /v1/train/*, and longest-prefix
match separates them.

Training is in-process on half-space trees (luxfi/aml pkg/anomaly): no training
pass, no retained sample, no job — the geometry is built before data arrives and
the model IS a set of mass counters, per tenant, ~336 KB, snapshot-restorable on
the per-org encrypted store the rest of the fleet already uses. Attribution is a
counterfactual on the model that raised the alert, so there is no second
explainer to disagree with the scorer. Only four base-free packages are linked;
the image's sqlite gate is satisfied.

The boundary is three arguments, not one rule:

  TYPE — the warehouse is reachable from one file, and every function in it that
  builds a feature statement takes a tenant, which has no exported constructor
  and is minted in one place from the validated principal.
  STATEMENT — the tenant is the leading BOUND predicate of every statement; the
  window is bound; every column and aggregation resolves through a fixed
  allowlist. Nothing user-derived is ever an identifier.
  BEHAVIOUR — an organisation naming another's subject reads zero rows, not a
  refusal, because a refusal is a probe oracle.

The key is <brand>/<org>, so two brands' identically named organisations are two
tenants, two models and two sets of rows. The source planes are read with the
bare slug they carry and the surface is written with the qualified key, and the
direction is asserted rather than commented.

Cross-organisation learning is aggregate-only and it is ONE table with no tenant
column at all: four quantiles of one dimension over one day, published only when
at least twenty-five organisations and a thousand buckets contributed, enforced
in the statement and again on read. No model reads it. The anonymous lane is
refused at the mint, so there is no filter to forget.

Shadow is the default, per organisation, with stated appetite reported beside
realised. Shutdown snapshots every resident model: this binary rolls one replica
at a time, and a model not written down is a tenant returned to warming — and a
warming model refuses to score, which reads as clean to anything not checking
the refusal.

Nine typed zip ops and one route untyped by design (the real probe, whose 503
carries the report a typed error envelope would drop), held to a closed list by
a test. zipdoc -check green; the app's own subset and the woven openapi.yaml
regenerate from source; 39 tests green, and green under -race.

Eleven mutations proven: drop the org predicate, add an org column to the
baseline, key the model on the bare org, carry the bare org on the transaction,
fold two subject kinds onto one key, write the bare org into the surface, drop
the snapshot tenant check, lower the k-anonymity floor, admit the anonymous
lane, let a second file reach the warehouse. Each fails the test that names it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:36:29 -07:00
2109 changed files with 157668 additions and 56624 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$//'
+287 -75
View File
@@ -196,6 +196,7 @@ jobs:
- name: go env for private modules
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
# GOPRIVATE names exactly the namespace that is private. github.com/hanzoai/*
# is: ai, account, commerce, orm, xorm, beego, csqlite and ~30 more are
@@ -203,7 +204,30 @@ jobs:
# that cannot see them. Everything else stays on the public proxy + checksum
# db, which is what makes a module hash immutable: zap-proto (all 55 repos)
# and luxfi (all 37 deps here) are public and proxy-served.
#
# OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. The module PATH stays
# github.com/hanzoai/* — that is the package's name, not its address — but
# the address git dials is git.hanzo.ai, which is canonical anyway.
#
# This is not a preference. Every release for nine consecutive commits was
# blocked because hanzoai/zen's GitHub collaborator list drifted from its
# sibling modules': the token could read ai, commerce, orm and account, and
# answered `Repository not found` for zen alone. A private repo denies and
# a missing repo denies with the same 404, so the build could not even say
# which had happened. Nothing about zen changed; an ACL beside it did, and
# it stopped the fleet.
#
# A checksum makes the substitution safe rather than merely convenient: the
# forge mirrors the same objects, so the fetched zip hashes to the h1: line
# already committed in go.sum. A forge that served different bytes would
# fail the build, loudly, instead of shipping them.
#
# GH_PAT remains the fallback for a module the forge has not mirrored.
run: |
set -euo pipefail
if [ -n "${FORGE_TOKEN:-}" ]; then
git config --global url."https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"
fi
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/hanzoai/*"
@@ -282,14 +306,61 @@ jobs:
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Version, derived ONCE — and reused on a resume
- name: The commit must exist on github before a tag can name it
env:
GH_PAT: ${{ secrets.GH_PAT }}
SHA: ${{ github.sha }}
# The claim below reserves a version by creating refs/tags/v<N> AT THIS
# COMMIT on github.com. A ref can only point at an object that is there,
# so the claim answers 404 — "Object does not exist" — for a commit github
# has never seen, and refuses to build.
#
# It routinely has not seen it. CI runs on git.hanzo.ai, which is canonical
# and where the push lands; github is fed by a PUSH MIRROR on an 8-HOUR
# interval, and the claim runs seconds later. So the object the tag must
# name is normally hours away, and every release in that window fails on a
# 404 that reads like a permissions problem and is really a race. It cost
# the fleet four days of releases stacked behind one.
#
# Publishing the commit here closes the race at its cause: after this step
# github HAS the object, whatever the mirror's schedule. It goes to a ref
# of its own rather than to main, because main is the mirror's to move and
# the two lineages do diverge — this step's job is to make the object
# exist, not to decide what main is.
run: |
set -euo pipefail
api="https://api.github.com/repos/hanzoai/cloud"
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
echo "commit ${SHA} is already on github — nothing to publish"
exit 0
fi
echo "commit ${SHA} is not on github yet (push mirror runs every 8h); publishing it now"
git push --force "https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" \
"${SHA}:refs/heads/forge-head"
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
echo "github now resolves ${SHA}"
exit 0
fi
sleep 3
done
echo "::error::pushed ${SHA} to github but it still does not resolve — the claim below would 404 on an object that is not there"
exit 1
- 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 +368,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 +418,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,27 +540,59 @@ 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 }}
org.opencontainers.image.source=https://github.com/hanzoai/cloud
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
FORGE_TOKEN=${{ secrets.FORGE_TOKEN }}
# 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 +653,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.
@@ -499,7 +687,6 @@ jobs:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_ORG: ${{ vars.KMS_ORG || 'hanzo' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
# pin.sh probes the registry before it moves anything; these let it read
# a private manifest instead of falling back to anonymous.
@@ -510,16 +697,21 @@ jobs:
VERSION="${{ needs.image.outputs.version }}"
# Secrets come from KMS, never from a file or a repo variable.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
KMS_TOKEN=$(curl -sS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
| jq -r '.accessToken // empty' || true)
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT}"; exit 1; }
PIN_TOKEN=$(curl -fsS \
"${KMS_ENDPOINT}/v1/kms/orgs/${KMS_ORG}/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty')
[ -n "$PIN_TOKEN" ] || { echo "::error::UNIVERSE_PIN_TOKEN missing in KMS at ${KMS_ORG}/deploy (env ${KMS_SECRET_ENV})"; exit 1; }
# THERE IS NO ORG IN A KMS PATH. The store root comes from the validated
# claim, so the org is the credential's, not the URL's — that is what
# makes another tenant's secret unnameable rather than merely refused.
# This asked for /v1/kms/orgs/<org>/secrets/... which is not a route the
# broker has, so it 404'd on every release since the car was written.
PIN_TOKEN=$(curl -sS \
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty' || true)
[ -n "$PIN_TOKEN" ] || { echo "::error::UNIVERSE_PIN_TOKEN missing in KMS at deploy/ (env ${KMS_SECRET_ENV}, org from the KMS credential)"; exit 1; }
echo "::add-mask::${PIN_TOKEN}"
git clone --quiet --depth 1 \
@@ -584,13 +776,19 @@ jobs:
with:
go-version-file: go.mod
# Same private-module contract as the containment job: github.com/hanzoai/*
# is private, so it must resolve direct+authenticated and skip a sumdb that
# cannot see it, while everything else stays on the public proxy.
# Same private-module contract as the containment job, including the forge
# substitution: our own modules resolve from git.hanzo.ai (the canonical
# address) with GitHub as the fallback, so one drifted GitHub ACL cannot
# stop a release. go.sum still decides whether the bytes were right.
- name: go env for private modules
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
if [ -n "${FORGE_TOKEN:-}" ]; then
git config --global url."https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"
fi
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/hanzoai/*"
@@ -600,20 +798,34 @@ jobs:
- name: Every published address is routed
run: go run ./cmd/reach openapi.yaml https://api.hanzo.ai openapi/unreachable.txt
# THE MCP PROJECTION. The door serves exactly the tools in the committed
# plugin/*/mcp.json, so "the MCP tool list was refreshed" is not a claim to
# make — it is a number to check. It needs no car of its own: the subsets
# are regenerated by car 0, baked into the image by car 1 and deployed by
# car 2. This is the assertion that all three happened.
- name: The MCP tool list is the one this release committed
# THE MCP DOOR, AND WHETHER EVERY SUBSYSTEM ANSWERED IT.
#
# This step used to compare the live tool count against `jq -s length` over
# the committed plugin/*/mcp.json. Both sides of that comparison came from
# the same files — the door was SERVING those bytes — so it could only ever
# prove the image carried the tree it was built from. It passed for months
# while plugin/o11y/mcp.json held 12 tools and the o11y binary served 365.
#
# The door composes itself by asking every subsystem now, so the release
# question is a different and much better one: DID THEY ALL ANSWER. A
# subsystem that is down, mis-rolled or wedged is named in the reply's
# _meta, which is the one thing the old check could never see — a broken
# deployment matched the files exactly.
- name: Every subsystem answered the MCP door
run: |
set -euo pipefail
WANT=$(jq -s '[.[]|length]|add' plugin/*/mcp.json)
GOT=$(curl -fsS -X POST https://api.hanzo.ai/v1/mcp \
BODY=$(curl -fsS -X POST https://api.hanzo.ai/v1/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools|length')
echo "MCP: committed ${WANT}, live ${GOT}"
[ "$WANT" = "$GOT" ] || { echo "::error::the MCP door serves ${GOT} tools and this release committed ${WANT}"; exit 1; }
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
TOOLS=$(echo "$BODY" | jq '.result.tools|length')
DOWN=$(echo "$BODY" | jq -r '.result._meta["hanzo.ai/unavailable"] // [] | length')
echo "MCP: ${TOOLS} tools live, ${DOWN} subsystems unavailable"
if [ "$DOWN" != "0" ]; then
echo "$BODY" | jq -r '.result._meta["hanzo.ai/unavailable"][] | " \(.app): \(.error)"'
echo "::error::${DOWN} subsystems did not answer the fleet's agent door"
exit 1
fi
[ "$TOOLS" -gt 0 ] || { echo "::error::the MCP door serves ZERO tools"; exit 1; }
# ══ CAR 4 ══ FAN OUT. One release, one document, every projection.
#
@@ -639,7 +851,6 @@ jobs:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_ORG: ${{ vars.KMS_ORG || 'hanzo' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
run: |
set -euo pipefail
@@ -662,17 +873,18 @@ jobs:
# naming the exact KMS path to create, instead of quietly shipping a
# cloud nobody's client knows about. That silent version is what the
# fleet has been living in.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
KMS_TOKEN=$(curl -sS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
| jq -r '.accessToken // empty' || true)
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT}"; exit 1; }
TOKEN=$(curl -fsS \
"${KMS_ENDPOINT}/v1/kms/orgs/${KMS_ORG}/secrets/deploy/FLEET_DISPATCH_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty')
# Same route correction as the pin above: no org in a KMS path.
TOKEN=$(curl -sS \
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/FLEET_DISPATCH_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty' || true)
if [ -z "$TOKEN" ]; then
echo "::error::FLEET_DISPATCH_TOKEN missing in KMS at ${KMS_ORG}/deploy (env ${KMS_SECRET_ENV}). Create it with contents:write + metadata:read on hanzoai/{python-sdk,js-sdk,java-sdk,cli} hanzo-go/sdk hanzo-rs/sdk hanzo-kotlin/sdk hanzo-cpp/sdk hanzo-docs/docs — six owners, so a fine-grained PAT cannot carry it; use a classic PAT with repo scope or a GitHub App installed on all six. Until it exists, every cloud release ships a document no client is regenerated from — which is the failure this car was built to end, so it fails rather than skipping."
echo "::error::FLEET_DISPATCH_TOKEN missing in KMS at deploy/ (env ${KMS_SECRET_ENV}, org from the KMS credential). Create it with contents:write + metadata:read on hanzoai/{python-sdk,js-sdk,java-sdk,cli} hanzo-go/sdk hanzo-rs/sdk hanzo-kotlin/sdk hanzo-cpp/sdk hanzo-docs/docs — six owners, so a fine-grained PAT cannot carry it; use a classic PAT with repo scope or a GitHub App installed on all six. Until it exists, every cloud release ships a document no client is regenerated from — which is the failure this car was built to end, so it fails rather than skipping."
exit 1
fi
echo "::add-mask::${TOKEN}"
+1 -1
View File
@@ -76,7 +76,7 @@ client of this; none of them holds a shared key or bills anything itself.
equals the session principal) and unexpired. Fail secure: if no such token is
available, DENY (401 / "sign in") — never fall back to an ambient or service
credential, which would run as the wrong principal or drain a shared org.
- NO shared keys. NO per-app keys. NO per-user minted `hk-` keys for chat. The
- NO shared keys. NO per-app keys. NO per-user minted API keys for chat. The
IAM token IS the credential and the billing identity.
- Reference implementations:
- `chat/api/server/routes/agents/cloud.js` +
+97 -11
View File
@@ -23,7 +23,7 @@
# force-cache-busted every build) used to dominate the ~20-min build; it is now
# a registry pull.
# console-embed (hanzoai/console Dockerfile.embed) → /dist → webui/dist (go:embed)
# agent-skills (hanzoai/openapi Dockerfile.skills) → /catalog → apps/agentskills/catalog (go:embed)
# agent-skills (hanzoai/openapi Dockerfile.skills) → /catalog → apps/skills/catalog (go:embed)
# Pinned to ghcr.io so BOTH buildx lanes (release.yml + platform arcbuild) pull
# it directly; the SAME tags are mirrored to registry.hanzo.ai (S3-backed) for
# GET-flow consumers (docker/kaniko/crane). Override any pin with
@@ -44,7 +44,25 @@
# 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
#
# CONSOLE IS PINNED BY SEMVER, not by sha. `sha-<sha7>-amd64` is what the builder
# publishes on every main push; `v<X.Y.Z>` is what it publishes on a cut v* tag,
# and that is the one to name here — the pin then says which RELEASE of the
# console a cloud image carries, which a sha cannot.
#
# The tradeoff is real and the discipline changes to match: a sha tag cannot be
# re-pushed to different bytes, whereas a semver tag CAN be moved (`:v8.4.118`
# was, in this fleet). So the rule that keeps this reproducible is now a rule
# about tags, not about tag SHAPE: a cut tag is never re-pointed. Cut the next
# patch instead — that is cheap, and it keeps "which console is in v1.801.N"
# answerable from git alone.
# 8.5.50 is the release whose assistant actually sends its credential: the
# streamed completion rides the client's one authorized door, preferences moved
# to cloud's /v1/prefs, the 401 card stopped claiming an expired session, and
# exactly one composer mounts per viewport. Cut as a release tag because a tag
# build publishes its name verbatim — which console is in this image is
# answerable from this line alone.
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:8.5.50
ARG SKILLS_IMAGE=ghcr.io/hanzoai/agent-skills:sha-b931a11-amd64
# ── toolchain base images: the golang + alpine FROMs below pull from our own
@@ -131,8 +149,20 @@ COPY go.mod go.sum ./
# and resolves fine from a clean cache. That is exactly what wedged the release
# on otel-collector v0.144.10. BUMP THE SUFFIX (-v4 -> -v5) to force a cold
# module cache the next time a phantom pin poisons it.
# FORGE_TOKEN, when supplied, points our OWN modules at git.hanzo.ai. The module
# path stays github.com/hanzoai/* — a name, not an address — and git dials the
# canonical forge instead. The longer prefix wins in git, so only hanzoai/* is
# redirected and every other github.com module still goes to GitHub. go.sum is
# unchanged and still authoritative: the forge mirrors the same objects, so the
# zip hashes to the committed h1: line, and a forge serving different bytes fails
# the build rather than shipping them. Both secrets are optional; absent either,
# this falls back to exactly the previous behaviour.
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
--mount=type=secret,id=FORGE_TOKEN \
--mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
if [ -s /run/secrets/FORGE_TOKEN ]; then \
git config --global url."https://x:$(cat /run/secrets/FORGE_TOKEN)@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"; \
fi && \
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
fi && \
@@ -144,14 +174,14 @@ COPY --from=console /dist/ /src/webui/dist/
# Overlay the FULL agent-skills catalog before `go build` so //go:embed all:catalog
# bakes the complete set (all services × brands), not the committed `ai` fallback.
#
# The path is apps/agentskills/catalog because that is where the embed is
# (apps/agentskills/agentskills.go). It read clients/agentskills/catalog until
# The path is apps/skills/catalog because that is where the embed is
# (apps/skills/skills.go). It read apps/skills/catalog until
# now — the pre-f873d1a1 home of every subsystem — and COPY CREATES a missing
# destination, so the overlay landed in a directory no package embeds and nothing
# anywhere disagreed. Every image since that move has shipped the tracked fallback
# instead: one skill (ai_models) per brand, served as the whole of
# /.well-known/agent-skills/index.json. The RUN below is the gate that was missing.
COPY --from=skills /catalog/ /src/apps/agentskills/catalog/
COPY --from=skills /catalog/ /src/apps/skills/catalog/
# RED gate — the overlays landed WHERE THE EMBED READS. Both COPYs above write
# into a tracked fallback that exists precisely so a bare `go build` works, and
# `COPY` creates a missing destination rather than failing — so a stale path is
@@ -162,8 +192,8 @@ COPY --from=skills /catalog/ /src/apps/agentskills/catalog/
# skill per brand, and the console fallback is a hand-written index.html with no
# script at all — a static SPA export carrying zero JavaScript is not a build.
RUN set -eu; \
n="$(sed -n 's/.*"skill_count":[[:space:]]*\([0-9]*\).*/\1/p' /src/apps/agentskills/catalog/hanzo/index.json)"; \
[ "${n:-0}" -gt 1 ] || { echo "SKILLS-GATE FAIL: apps/agentskills/catalog holds the ${n:-0}-skill fallback — the overlay missed the //go:embed path"; exit 1; }; \
n="$(sed -n 's/.*"skill_count":[[:space:]]*\([0-9]*\).*/\1/p' /src/apps/skills/catalog/hanzo/index.json)"; \
[ "${n:-0}" -gt 1 ] || { echo "SKILLS-GATE FAIL: apps/skills/catalog holds the ${n:-0}-skill fallback — the overlay missed the //go:embed path"; exit 1; }; \
j="$(find /src/webui/dist -type f -name '*.js' | wc -l)"; \
[ "$j" -gt 0 ] || { echo "CONSOLE-GATE FAIL: webui/dist carries no JavaScript — the overlay missed the //go:embed path and the image would ship the fallback shell"; exit 1; }; \
echo ">> overlays landed: $n skills/brand, $j console scripts"
@@ -201,6 +231,37 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
go generate -run zipdoc ./...
# The commit this image is built FROM, handed in by the SAME builder that already
# feeds it to the OCI label in the final stage (apps/platform buildFrontendCmdRev,
# `--opt build-arg:REVISION=<sha>`; the other lane passes github.sha).
#
# `ARG REVISION` already existed — but ONLY in that final stage, and an ARG is
# per-stage, so it was never in scope where `go build` runs and no binary in this
# image could name its commit. The wire was connected at one end.
#
# Do not "fix" it by trusting the label. A label is read by whoever thinks to open
# the registry; the PROCESS is read by whoever is holding the outage — and this
# fleet's revision label has itself read `unknown` on natively-built images
# without anyone noticing, which is what a label is worth.
#
# DECLARED HERE, AS LATE AS POSSIBLE, and deliberately not beside ARG VERSION at
# the top of the stage: everything below `COPY . .` is already re-keyed by any
# source change, so a per-commit value costs nothing from this line down. The same
# value in scope ABOVE would re-key `go mod download` and turn every build into a
# full one.
ARG REVISION=unknown
# ONE flag string for EVERY binary in this image. This is a build-stage variable —
# the final stage does not inherit it and nothing reads it at run time; it exists
# so the stamp cannot reach some binaries and miss others.
#
# It has to reach the PLUGINS. cmd/cloud is a router that links zip and the
# manifest, not the package these symbols live in, so `-X github.com/hanzoai/
# cloud.Version=` on /cloud has always been silently dropped — measured: the flag
# shows up in the binary's `go version -m` build record and the value is nowhere
# in the linked bytes. The plugins are what serve /v1/health, and they carried no
# -X whatsoever, so stamping only the entrypoint would have left the process that
# answers the question mute.
ENV GO_LDFLAGS="-s -w -X github.com/hanzoai/cloud.Version=${VERSION} -X github.com/hanzoai/cloud.revision=${REVISION}"
# THE LIGHT HOST (cmd/cloud) — ~400 packages, pure Go, no codec and no subsystem
# (it links zip + the manifest + the light webui console embed, and nothing else).
# It is the ENTRYPOINT. It knows only where each app lives and what path it
@@ -209,14 +270,13 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
# together, so no build in this image is the mega link that once dominated it.
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build \
-ldflags="-s -w -X github.com/hanzoai/cloud.Version=${VERSION}" -o /cloud ./cmd/cloud
CGO_ENABLED=0 go build -ldflags="$GO_LDFLAGS" -o /cloud ./cmd/cloud
# The functional smoke prober (plugin/smoke) — a stdlib-only static binary shipped
# alongside the host so the release gate can `docker exec` it against the freshly-
# built image (and any deployment can be smoked via `docker run --entrypoint /smoke`).
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /smoke ./plugin/smoke
CGO_ENABLED=0 go build -ldflags="$GO_LDFLAGS" -o /smoke ./plugin/smoke
# EVERY subsystem, each as its OWN binary in /plugins beside the host. The host
# fork/execs a sibling <dir>/<name> (manifest.App.Plugin) on the first request that
# reaches its prefix, so the binary must be in the image or the mount aborts:
@@ -260,8 +320,34 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
for p in $names; do \
[ -d "./plugin/$p" ] || { echo "FATAL: manifest app '$p' has no plugin/$p — run 'make generate' and commit"; exit 1; }; \
echo "building plugin $p"; \
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="-s -w" -o "/plugins/$p" "./plugin/$p"; \
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$p" "./plugin/$p"; \
done
# THE STAMP LANDED — asked of the ARTIFACT, not of the flag string.
#
# `-X` naming a path or symbol the linker cannot resolve is not an error: it is
# dropped, the build succeeds, and every binary then reports the entirely
# legitimate-looking "unknown" forever. A renamed package or variable would fail
# in exactly the one way nobody looks at, which is how this started.
#
# `go version -m` is NOT a witness — it echoes the -ldflags string that was
# REQUESTED, and that string is present even when the symbol was never set
# (measured on /cloud, whose Version stamp has been dropped all along). Only the
# linked bytes answer.
#
# strings|grep rather than a bare grep: grep treats binary input as non-text and
# its exit status there is not portable across implementations, so a plain
# `grep -qF` can report no match on a binary that demonstrably contains the sha.
# strings normalises to text lines first; binutils is already installed above.
#
# An image built with no REVISION is not a failure — it is a build that cannot
# name its commit, and it says so here and on every health response it serves.
RUN set -eu; \
if [ "$REVISION" = "unknown" ]; then \
echo ">> no REVISION build-arg: this image cannot name its commit, and every health response it serves will report revision=unknown"; \
else \
strings -a /plugins/base | grep -qF "$REVISION" || { echo "FATAL: -X did not reach /plugins/base — github.com/hanzoai/cloud.revision was not resolved, so it was dropped and every health response would report 'unknown'"; exit 1; }; \
echo ">> revision $REVISION linked into the plugins"; \
fi
# Prove a SHIPPED sqlite-backed plugin binds sqlite3_* to libsqlcipher, not a
# plaintext libsqlite3. /plugins/base opens per-org stores under the SAME CGO=1 +
# libsqlite3 build every plugin above got, so it is a real witness for the set.
+299 -37
View File
File diff suppressed because one or more lines are too long
+93 -19
View File
@@ -28,6 +28,22 @@ LDFLAGS ?= -s -w
# .git has nothing to describe. Empty stamps nothing, and cmd/hanzo's
# resolveVersion then answers from the metadata the toolchain embeds by itself.
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)
# The commit those same bytes were built FROM — the other half of the question,
# from the same command, in the same -X idiom. `--abbrev=40 --match=''` makes
# describe report the full object name and nothing else.
#
# `--dirty` is load-bearing rather than decorative: on an uncommitted tree it
# appends `-dirty`, which is not a 40-hex name, so cloud.Revision reports
# "unknown" instead of naming a commit whose source is NOT what was built. That
# lie is the one this whole change exists to remove, so the local build must not
# tell it either. Honest by construction, with no second rule to keep in step.
REVISION ?= $(shell git describe --always --abbrev=40 --match='' --dirty 2>/dev/null)
# What a build says about itself, written ONCE: the tag it was published under
# and the commit it came from. Appended per-target for the reason above — `make
# LDFLAGS=...` keeps overriding exactly what it always did — and shared by the
# host and the plugins, because three copies of a stamp is three chances to
# stamp one binary and forget the one that answers /v1/health.
STAMP = -X github.com/hanzoai/cloud.Version=$(VERSION) -X github.com/hanzoai/cloud.revision=$(REVISION)
# Path to a hanzoai/console checkout used to build the embedded console bundle.
CONSOLE_DIR ?= ../console
# Path to a hanzoai/openapi checkout — the SOT the agent-skills catalog is generated from.
@@ -64,10 +80,10 @@ APPS := $(shell sed -n 's/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)
# them in parallel and build exactly the one you ask for.
APP_BINS := $(addprefix bin/,$(APPS))
.PHONY: help webui deploy-ui agentskills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run smoke test test-fast test-cgo test-codec vet tidy docker docker-push clean e2e
.PHONY: help webui deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
webui: ## Build the real console static bundle into webui/dist (go:embed source). CONSOLE_DIR=<path to console>.
@command -v npm >/dev/null 2>&1 || { echo "npm is required to build the console bundle"; exit 1; }
@@ -91,12 +107,12 @@ deploy-ui: ## Build the monochrome ArgoCD dashboard bundle into apps/deploy/webu
cp -r "$(DEPLOY_DIR)/ui/dist/app/." apps/deploy/webui/dist/
@echo ">> embedded monochrome ArgoCD bundle into apps/deploy/webui/dist (index.html $$(wc -c < apps/deploy/webui/dist/index.html) bytes)"
agentskills: ## Regenerate the FULL agent-skills catalog into apps/agentskills/catalog (go:embed source) from the openapi SOT. OPENAPI_DIR=<path to openapi>.
skills: ## Regenerate the FULL agent-skills catalog into apps/skills/catalog (go:embed source) from the openapi SOT. OPENAPI_DIR=<path to openapi>.
@test -f "$(OPENAPI_DIR)/skills.py" || { echo "openapi checkout not found at $(OPENAPI_DIR) — set OPENAPI_DIR=<path> or clone hanzoai/openapi"; exit 1; }
# skills.py rewrites the whole catalog dir; the .gitignore keeps only the tiny
# `ai` fallback tracked, so the full set is embedded at build but never committed.
python3 "$(OPENAPI_DIR)/skills.py" --no-services --out apps/agentskills/catalog
@echo ">> embedded FULL agent-skills catalog ($$(jq -r .skill_count apps/agentskills/catalog/hanzo/index.json) skills/brand)"
python3 "$(OPENAPI_DIR)/skills.py" --no-services --out apps/skills/catalog
@echo ">> embedded FULL agent-skills catalog ($$(jq -r .skill_count apps/skills/catalog/hanzo/index.json) skills/brand)"
# THE DEFAULT BUILD IS THE HOST, and that is the whole point of the plugin model:
# nothing compiles together. The fused binary linked all 112 subsystems into one
@@ -121,7 +137,7 @@ build: cloud ## FAST PATH (default): build the light host into ./bin/cloud. Then
# named cloud — it IS the one real binary, and its ENTRYPOINT the image ships.
cloud: ## Build the light host into ./bin/cloud (links zip + the manifest, none of the apps).
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) -X github.com/hanzoai/cloud.Version=$(VERSION)" -o bin/$@ ./cmd/$@
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) $(STAMP)" -o bin/$@ ./cmd/$@
@echo ">> bin/cloud — $$(CGO_ENABLED=$(CGO_ENABLED) $(GO) list -deps ./cmd/cloud | wc -l) packages, $$(du -h bin/cloud | cut -f1)"
# THE RELEASE LAYOUT: the light host plus one dedicated binary per app, all in
@@ -147,7 +163,7 @@ apps: $(APP_BINS) ## Build every app binary into ./bin. Parallelise: make -j app
$(APP_BINS): bin/%:
@test -d plugin/$* || { echo "no plugin/$* — run 'make generate', or check the name against 'make plugin' with no APP"; exit 1; }
@mkdir -p bin
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o $@ ./plugin/$*
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) $(STAMP)" -o $@ ./plugin/$*
plugin: ## Build ONE app into ./bin: make plugin APP=wallets.
@test -n "$(APP)" || { echo "usage: make plugin APP=<name>"; echo "apps: $(APPS)"; exit 1; }
@@ -179,15 +195,22 @@ 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
# dev and lint are the names every repo in the fleet answers to. They are ALIASES
# of the two targets that already do the work, never copies of them, so each of
# those two things still has exactly one recipe.
dev: run ## Alias for run.
smoke: ## Build and run the smoke prober (mount-time integration check).
$(GO) run ./plugin/smoke
@@ -265,9 +288,10 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
# prose and examples reach the document. `-run zipdoc` picks the directives
# out of ./... by name, so a typed op added anywhere is covered and no
# unrelated generator fires.
# 2. each app describes ITSELF: `<app> openapi` mounts that one subsystem and
# 2. each app describes ITSELF: `<app> describe` mounts that one subsystem and
# projects its own router into plugin/<app>/openapi.json (mk/fleet.mk — one lean
# binary per app, no fused build and no mega link).
# binary per app, no fused build and no mega link). It no longer writes an MCP
# catalogue beside it: the door asks the subsystems (package fleet).
# 3. the weave composes those subsets into openapi.yaml (openapi/weave.go),
# refusing when two apps claim one path or one schema name. There is no
# monolith left to read: the woven document IS the published spec.
@@ -293,7 +317,7 @@ describe: ## Regenerate every app's projections, then weave them into openapi.ya
$(GO) generate -run zipdoc ./...
$(MAKE) -f mk/fleet.mk describe-apps
$(MAKE) -f mk/fleet.mk openapi-weave OUT=openapi.yaml
@echo ">> openapi.yaml — $$(grep -c '^ /' openapi.yaml) paths, $$(cat plugin/*/mcp.json | grep -c '\"name\":') MCP tools"
@echo ">> openapi.yaml — $$(grep -c '^ /' openapi.yaml) paths. The MCP tool list is NOT an artifact: POST /v1/mcp asks every subsystem."
test-cgo: ## Prove the cgo build works too — forces the fork's pure-Go backend via -tags sqlite_purego so the embedded modernc importers don't double-register "sqlite".
$(TEST_ENV) CGO_ENABLED=1 $(GO) test -tags "sqlite_purego $(TEST_TAGS)" ./...
@@ -317,6 +341,8 @@ test-codec: ## Run the suite against the engine the image ships (cgo + a real li
vet: ## go vet across the module.
CGO_ENABLED=$(CGO_ENABLED) $(GO) vet ./...
lint: vet ## Alias for vet.
# Not part of `test`: it rewrites source, so it runs deliberately, alone. It is how a
# new assertion earns its place — break the property, watch the test go RED. An anchor
# that no longer matches is a hard FAILURE here, never a skip, so a refactor that
@@ -334,5 +360,53 @@ docker: ## Build the Docker image (uses repo Dockerfile, scratch final stage).
docker-push: docker ## Push the Docker image to ghcr.io. Requires docker login.
docker push $(DOCKER_IMAGE):$(DOCKER_TAG)
# COMPOSE is the check the v1.801.425/.426 outage needed and nobody had. zip
# refuses to compose a program whose middleware could never run, and it refuses at
# BOOT — so fifteen plugins built, linked, passed vet and unit tests, and then
# crash-looped in production. `go build` cannot see it; only running the binary can.
#
# SURVIVAL is the signal, and it is the only honest one. A compose panic is fatal,
# so a process still alive when the timeout kills it (rc 124) composed. Grepping
# the log for a success line does NOT work: `"message":"zip new"` is printed
# BEFORE composition, and reading it as a pass is exactly how a broken build was
# twice reported shipped.
#
# Each app gets a writable data dir and PORT ZERO on all four listeners. Without a
# data dir it dies on `mkdir /var/lib/cloud/orgs`, and without free ports it dies on
# binding :8080/:9653/:9090/:8081 — either way long before it reaches the router, and
# an early death looks like silence, which reads as a pass.
#
# :0 RATHER THAN A COMPUTED PORT BLOCK. This handed out 41000+index*10 and it was
# accidental complexity: the question is "does this binary compose", and answering it
# does not require owning a port namespace. Worse, it answered WRONG — a second run
# inside sixty seconds collided with the first run's sockets in TIME_WAIT, which
# `ss -lnt` does not show, and reported up to 16 healthy apps as DIED. A check that
# invents failures gets ignored exactly as fast as one that misses them. The kernel
# already allocates ports correctly; asking it removes the bookkeeping, the stride,
# the TIME_WAIT window and the cap on concurrency in one move.
#
# CONCURRENT, because the timeout is the cost and it is paid per app: one at a time,
# $(words $(APPS)) apps take most of an hour, and a check nobody runs is how all of
# this reached production. Failures go to files rather than racing onto stdout.
COMPOSE_DIR ?= .compose
COMPOSE_JOBS ?= 8
compose: apps ## Prove every app binary BOOTS — the compose check `go build` cannot do.
@rm -rf $(COMPOSE_DIR) && mkdir -p $(COMPOSE_DIR)
@printf '%s\n' $(APPS) | xargs -P$(COMPOSE_JOBS) -n1 sh -c '\
a=$$0; d=$(COMPOSE_DIR)/$$0; mkdir -p $$d/rt; \
out=$$(CLOUD_DATA_DIR=$$d ZIP_RUNTIME_DIR=$$d/rt \
CLOUD_LISTEN=:0 CLOUD_ZAP_LISTEN=:0 \
CLOUD_HEALTH_LISTEN=:0 CLOUD_ADMIN_LISTEN=:0 \
timeout 25 ./bin/$$a 2>&1); rc=$$?; \
if printf "%s" "$$out" | grep -q "does not compose"; then \
{ echo "PANIC $$a"; printf "%s\n" "$$out" | grep -E "zip: (the group|GET|POST|PUT|PATCH|DELETE)" | sed "s/^/ /" | head -4; } > $$d.fail; \
elif [ $$rc -ne 124 ]; then \
echo "DIED $$a (rc=$$rc): $$(printf "%s" "$$out" | tail -1 | cut -c1-140)" > $$d.fail; \
fi'
@set -- $(COMPOSE_DIR)/*.fail; \
if [ -e "$$1" ]; then cat $(COMPOSE_DIR)/*.fail; n=$$(ls $(COMPOSE_DIR)/*.fail | wc -l); \
rm -rf $(COMPOSE_DIR); echo ">> compose FAILED: $$n of $(words $(APPS)) apps"; exit 1; \
else rm -rf $(COMPOSE_DIR); echo ">> compose: $(words $(APPS)) apps boot"; fi
clean: ## Remove built artifacts.
rm -rf bin
rm -rf bin $(COMPOSE_DIR)
+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
+212
View File
@@ -0,0 +1,212 @@
package cloud
// Agency — telling a customer's automation apart from a bad bot.
//
// This is the question a generic bot filter cannot answer and we can, because
// the answer is a fact about OUR OWN issuance rather than a guess about the
// client. A user-agent string is whatever the caller typed; a credential is
// something we minted, to a named principal, in a named tenant, that we meter
// and can revoke. So the classification here reads the credential, never the
// client's self-description. There is no user-agent heuristic in this file and
// there must not be one: an agent that lies about its user-agent is still
// holding our key, and a scraper that copies Chrome's is still holding nothing.
//
// FOUR LANES, and the boundary between them is attributability:
//
// agent — an attributable machine credential (sk-, or a machine JWT).
// Programmatic traffic that a named org pays for and we can switch
// off. This is the lane our own agents run in, and it is the lane a
// customer's automation runs in. It gets judged on VOLUME PATTERN,
// not on being automated: being automated is the product.
// human — a browser session bearer. A person at a keyboard.
// bot — unattributable traffic already showing an abuse shape: no
// credential (or a publishable one, the kind that ships in a browser
// bundle and is therefore the kind that gets copied) TOGETHER WITH a
// pattern no legitimate client produces — many keys from one address,
// a wall of auth failures, a path sweep.
// unknown — unattributable but unremarkable. Scored normally. Most anonymous
// traffic is here and stays here, which is the point: "anonymous" is
// not "malicious".
//
// THIS FILE ANSWERS ONE HALF OF THAT AND THE SENSOR ANSWERS THE OTHER. Reading a
// request for its credential CLASS needs the request, so it is here. Turning a
// class plus a traffic pattern into a LANE needs the pattern, so it is
// edge.Lane — inside the observation that produced the counts, which is the only
// place that can compute it before it is counted. Splitting it the other way is
// what made every request in the lane report land in "unknown": the gate had to
// state the lane before it had asked what the caller had been doing.
//
// The gate's classification is a PRIOR. It is sent to the scorer as a signal and
// the scorer — which holds the agent registry, the session plane and the metered
// shape — may overrule it. Its answer is the authoritative one. That split is
// deliberate: the edge must classify in nanoseconds off facts already in hand,
// and must not learn to score.
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
"github.com/hanzoai/cloud/apps/gateway/edge"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
)
// credentialClass reads the class off a request. It looks at the Authorization
// header for the credential's SHAPE and at the validated principal for whether
// the identity boundary accepted it — never at the body, never at a client
// header the boundary does not mint. The vocabulary is edge's (edge.CredSecret
// and friends), because the lane rule that consumes it lives there.
//
// A credential that was presented and did NOT validate is anonymous, not
// secret: possession of a string that fails is possession of nothing.
func credentialClass(c *zip.Ctx) string {
tok := callerCredential(c)
switch {
case tok == "":
return edge.CredAnonymous
case IsPublishableKey(tok):
// A pk- names an org and no principal. It is a tenant label, not an
// authentication, so it stays publishable whether or not an org resolved.
return edge.CredPublishable
case !principalValidated(c):
return edge.CredAnonymous
case isAPIKey(tok):
return edge.CredSecret
default:
return edge.CredSession
}
}
// principalValidated reports whether the identity boundary VERIFIED a principal
// for this request — read from the boundary's own attestation (principal.Minted),
// never from a header.
//
// It used to read `c.Org() != "" || c.User() != ""`, and both disjuncts were
// forgeable:
//
// - X-Org-Id survives the boundary on the anonymous path by design (the Phase-1
// data passthrough, documented in middleware_identity.go), so ANY caller can
// make c.Org() non-empty by sending the header;
// - in a process where the boundary is not installed at all — a hand-written
// plugin main — nothing strips either header, so X-User-Id is the client's
// too.
//
// Either one, plus an sk--shaped string in Authorization that never validated,
// moved a caller from the anonymous lane into the AGENT lane: the lane whose
// whole meaning is "we minted this credential to a named tenant and can revoke
// it". A differentiator a client can set is not a differentiator.
//
// The attestation is absent when no boundary ran, which resolves to anonymous —
// the fail-closed direction for a classifier: unattributable traffic is judged on
// its shape, and only a credential WE resolved buys the agent lane.
func principalValidated(c *zip.Ctx) bool {
p, ok := principal.Minted(c)
return ok && p.User != ""
}
// verifiedOrg is the tenant the identity boundary resolved for this request, and
// "" for an anonymous caller or a process with no boundary. It is the sensor's
// keyspace index, so it must be the SERVER's answer: an org taken from a header
// would let one caller write into — and evict from — another tenant's state.
func verifiedOrg(c *zip.Ctx) string {
p, ok := principal.Minted(c)
if !ok || p.User == "" {
return ""
}
return p.Org
}
// observation is the ONE place an observation is built from a request, and the reason
// it is one place is that two of its fields are the same fingerprint under
// different trust:
//
// Cred — set ONLY when the identity boundary validated the credential. It
// is what the sensor keys on, so it must be a fact we stated. A
// caller keyed on a string it chooses can leave its own hold by
// typing a different one, and can open a table entry per request.
// Presented — set for whatever the request carried, valid or not. It is counted
// only as spread, because a wall of invalid credentials from one
// address IS the stuffing signature and refusing to count it would
// blind the sensor to the attack it exists to see.
//
// Building this anywhere else would mean deciding that trust question a second
// time, and the second answer is the one that would be wrong.
func observation(c *zip.Ctx, path string) edge.Signal {
presented := credentialOf(c)
s := edge.Signal{
Org: verifiedOrg(c),
Presented: presented,
IP: ClientIP(c),
Path: path,
Class: credentialClass(c),
}
if principalValidated(c) {
s.Cred = presented
}
return s
}
// fingerprintSalt is a per-PROCESS secret. A credential fingerprint is only ever
// compared with another fingerprint from the same process and the same window,
// so the salt never needs to be shared, persisted or rotated — and because it is
// never shared, a fingerprint that escapes in a log or a report cannot be tested
// against a candidate key anywhere else. Generated from crypto/rand at init; a
// generator failure is fatal at start rather than silently downgrading to a
// guessable salt.
var fingerprintSalt = mustSalt()
func mustSalt() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("cloud: no entropy for the credential fingerprint salt: " + err.Error())
}
return b
}
// fingerprintLen is how much of the digest is kept: 12 base64url characters, 72
// bits. Enough that two live credentials colliding is not a thing that happens,
// short enough to read in a report.
const fingerprintLen = 12
// Fingerprint turns a credential into a stable per-process handle. It is what
// the sensor counts under and what a traffic report shows — the credential
// itself never enters a counter, a log, a record or a response.
//
// HMAC-SHA256 rather than a bare hash: with a bare hash, anyone holding a
// candidate key could confirm it against a published fingerprint. With a keyed
// digest under a salt that never leaves the process, they cannot.
func Fingerprint(cred string) string {
cred = strings.TrimSpace(cred)
if cred == "" {
return ""
}
m := hmac.New(sha256.New, fingerprintSalt)
_, _ = m.Write([]byte(cred))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))[:fingerprintLen]
}
// callerCredential is the credential this request presented, in the SAME
// precedence the identity boundary trusts — callerToken, the one token resolution
// SanitizeIdentity and CallerBearer already share. Reading the Authorization
// header directly would be a second, drifting answer to "which credential
// identifies this caller": a client authenticating with X-Authorization or a
// session cookie would validate upstream and then be counted here as anonymous,
// so its traffic would be pooled under its address instead of under itself.
//
// X-Api-Key is checked after it, because that header is not part of the identity
// boundary's precedence but IS a spelling several SDKs send; a caller the boundary
// could not identify is still a caller this sensor must be able to tell apart from
// the next one.
func callerCredential(c *zip.Ctx) string {
if tok := callerToken(c); tok != "" {
return tok
}
return strings.TrimSpace(c.Header("X-Api-Key"))
}
// credentialOf returns the fingerprint of whatever credential a request
// presented, and "" when it presented none.
func credentialOf(c *zip.Ctx) string { return Fingerprint(callerCredential(c)) }
+55
View File
@@ -0,0 +1,55 @@
package cloud
// The pin for the trust decision. An edge.Signal carries two fingerprints of the
// same credential under different trust — one the sensor may key on, one it may
// only count — so a second place that builds one is a second answer to "did this
// credential validate", and the second answer is the one that would be wrong.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestPin_AnObservationIsBuiltInOnePlace(t *testing.T) {
names, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
for _, name := range names {
if strings.HasSuffix(name, "_test.go") || name == "agency.go" {
continue
}
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "edge.Signal{") {
t.Errorf("%s builds an edge.Signal; the one constructor is observation() in agency.go", name)
}
}
}
// And that constructor may only put a fingerprint in the KEY field behind the
// identity boundary's own attestation.
func TestPin_OnlyAnAttestedCredentialBecomesAKey(t *testing.T) {
b, err := os.ReadFile("agency.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
i := strings.Index(src, "func observation(")
if i < 0 {
t.Fatal("observation() is gone; the observation constructor moved and this pin did not")
}
body := src[i:]
if j := strings.Index(body, "\n}\n"); j >= 0 {
body = body[:j]
}
assign := strings.Index(body, "s.Cred = ")
guard := strings.Index(body, "if principalValidated(c)")
if assign < 0 || guard < 0 || guard > assign {
t.Error("Signal.Cred is set without the identity boundary's attestation guarding it")
}
}
+162
View File
@@ -0,0 +1,162 @@
package cloud
// Reading a REQUEST for the two facts the differentiator turns on: which class
// of credential it presented, and whether the identity boundary validated it.
// The lane rule those two feed is a pure function and is specified where it
// lives, in edge (lane_test.go).
import (
"crypto/sha256"
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/cloud/apps/gateway/edge"
"github.com/zap-proto/zip"
)
// The whole claim of the differentiator is that the classification reads OUR
// issuance and not the client's self-description. If a user-agent string could
// move a caller between lanes, the classification would be worth nothing.
func TestCredentialClass_ReadsTheCredentialNotTheClient(t *testing.T) {
cases := []struct {
name string
org string
auth string
apiKey string
ua string
want string
}{
{"validated secret key", "acme", "Bearer sk-live-1", "", "curl/8", edge.CredSecret},
// hk- is not one of the two key shapes (see APIKeyPrefixes: pk- and sk-).
// It reaches no key door and resolves to no principal, so it cannot raise
// a caller into the agent lane on its own — whatever else validated this
// request, the hk- string contributed nothing to the classification.
{"hk- is not a key shape, so it does not classify as one", "acme", "Bearer hk-live-1", "", "", edge.CredSession},
{"validated session bearer", "acme", "Bearer eyJhbGciOi.payload.sig", "", "Mozilla/5.0", edge.CredSession},
{"publishable key, org resolved", "acme", "Bearer pk-live-1", "", "", edge.CredPublishable},
{"publishable key, no org", "", "Bearer pk-live-1", "", "", edge.CredPublishable},
{"secret-shaped but unvalidated", "", "Bearer sk-live-1", "", "", edge.CredAnonymous},
{"session-shaped but unvalidated", "", "Bearer eyJhbGciOi.payload.sig", "", "", edge.CredAnonymous},
{"no credential at all", "", "", "", "Mozilla/5.0", edge.CredAnonymous},
{"key in the X-Api-Key header", "acme", "", "sk-live-1", "", edge.CredSecret},
{"a browser user-agent cannot make a key a session", "acme", "Bearer sk-live-1", "", "Mozilla/5.0 Chrome/126", edge.CredSecret},
{"a curl user-agent cannot make a session a key", "acme", "Bearer eyJhbGciOi.p.s", "", "curl/8.7", edge.CredSession},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got string
app := zip.New(zip.Config{})
app.Use(attest()) // the boundary; without it every caller is anonymous.
app.Get("/probe", func(c *zip.Ctx) error {
got = credentialClass(c)
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
if tc.org != "" {
req.Header.Set("X-Org-Id", tc.org)
req.Header.Set("X-User-Id", "u-"+tc.org)
}
if tc.auth != "" {
req.Header.Set("Authorization", tc.auth)
}
if tc.apiKey != "" {
req.Header.Set("X-Api-Key", tc.apiKey)
}
if tc.ua != "" {
req.Header.Set("User-Agent", tc.ua)
}
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("credentialClass = %q, want %q", got, tc.want)
}
})
}
}
// A fingerprint must identify a caller within a process and be useless outside
// one. The salt is per-process and keyed, so a published fingerprint cannot be
// tested against a candidate key anywhere else.
func TestFingerprint(t *testing.T) {
if Fingerprint("") != "" {
t.Fatal("no credential must fingerprint to nothing, not to a constant every anonymous caller shares")
}
a, b := Fingerprint("sk-live-1"), Fingerprint("sk-live-1")
if a != b {
t.Fatal("a fingerprint must be stable within a process")
}
if a == Fingerprint("sk-live-2") {
t.Fatal("two credentials must not share a fingerprint")
}
if len(a) != fingerprintLen {
t.Fatalf("fingerprint length = %d, want %d", len(a), fingerprintLen)
}
// It must not be a bare digest of the credential: with a bare digest anyone
// holding a candidate key could confirm it against a published fingerprint.
if a == unsaltedDigest("sk-live-1") {
t.Fatal("the fingerprint is an unsalted digest — a published one would be brute-forceable")
}
// Whitespace is not a second identity for the same key.
if Fingerprint(" sk-live-1 ") != a {
t.Fatal("a padded credential must fingerprint to the same caller")
}
}
// unsaltedDigest is what a NAIVE implementation would produce. It exists only so
// the test above can assert we did not write that one.
func unsaltedDigest(s string) string {
sum := sha256.Sum256([]byte(s))
return base64.RawURLEncoding.EncodeToString(sum[:])[:fingerprintLen]
}
// The credential must be read through the SAME resolution the identity boundary
// trusts. A second answer to "which credential is this caller" would pool a
// client that authenticates by X-Authorization or by session cookie under its
// ADDRESS instead of under itself — which is exactly the caller the sensor exists
// to tell apart from its neighbours.
func TestCredentialClass_UsesTheBoundarysOwnResolution(t *testing.T) {
cases := []struct {
name string
set func(*http.Request)
wantClass string
wantSameAs string // a header spelling that must fingerprint identically
}{
{"Authorization bearer", func(r *http.Request) {
r.Header.Set("Authorization", "Bearer sk-live-1")
}, edge.CredSecret, ""},
{"X-Authorization bearer", func(r *http.Request) {
r.Header.Set("X-Authorization", "Bearer sk-live-1")
}, edge.CredSecret, "Bearer sk-live-1"},
{"X-Api-Key", func(r *http.Request) {
r.Header.Set("X-Api-Key", "sk-live-1")
}, edge.CredSecret, "Bearer sk-live-1"},
}
base := Fingerprint("sk-live-1")
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var class, fp string
app := zip.New(zip.Config{})
app.Use(attest())
app.Get("/probe", func(c *zip.Ctx) error {
class, fp = credentialClass(c), credentialOf(c)
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set("X-Org-Id", "acme")
req.Header.Set("X-User-Id", "u-acme")
tc.set(req)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if class != tc.wantClass {
t.Fatalf("class = %q, want %q", class, tc.wantClass)
}
if fp != base {
t.Fatalf("one credential must fingerprint to ONE caller however it is spelled: %q vs %q", fp, base)
}
})
}
}
+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")
}
}
+190
View File
@@ -0,0 +1,190 @@
package cloud
import (
"github.com/hanzoai/cloud/apps/sites"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// App returns an app carrying everything a Hanzo program must carry, in the one
// order those parts are correct in. It is the only way to obtain one: a program
// mounts its subsystem on what it gets back and never builds a zip.App itself.
//
// The point is what a caller no longer has the opportunity to forget. Identity is
// not an option a program passes, it is a property of the value it receives, so a
// program is either holding an app that identifies its callers or it is holding
// nothing. Every other member here was equally forgettable and was equally
// forgotten: the o11y binary assembled its own app and reached production with no
// panic recovery, no request id, no response-header posture, no tracing, no
// request log and no typed-op enrichment — a shape nobody chose and nobody could
// see, because there was nothing to compare it against.
//
// WHERE THE EDGE IS. Production runs ingress → gateway → the front door
// (cmd/cloud) → this program. The gateway is the public edge and owns rate
// limiting for the internet. The front door installs no middleware of its own —
// it routes, serves the console, threads operator flags and scopes credentials —
// so a program built here is its OWN edge and defends itself. That is why the
// browser and flood defenses are here rather than borrowed from a parent. A
// program reached over the plane socket instead trusts what its host asserted:
// the kernel answers which process is calling, and the boundary's findings travel
// with the request.
//
// name is what the program calls itself in a diagnostic. tools is the MCP surface,
// which only a program holding a subsystem list can project — everyone else passes
// nil and serves none.
func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
app := zip.New(zip.Config{
AppName: name,
Logger: deps.Logger,
ReadBufferSize: cfg.ReadBufferSize,
BodyLimit: cfg.BodyLimit,
MCP: zip.MCPConfig{Source: tools},
// Cloud's refusal renderer, in place of zip's default — which reads only a
// *zip.HTTPError and answers 500 for everything else, so a propagated 402
// or 403 reached the console as a dead card. See errmap.go.
ErrorHandler: ErrorHandler,
// Static Server fallback for responses the ProductionHeaders middleware
// cannot reach — the transport's own pre-routing errors (431/400) and any
// fiber path that bypasses the chain. Set to this deployment's brand so
// those bytes read Server: <brand>, never the framework default "zip" or
// "fasthttp" (zip>=v1.8.1 propagates this onto the fasthttp transport).
// Handled responses are still branded per-Host by ProductionHeaders.
ServerHeader: cfg.Brand,
})
// Canonical middleware pipeline. Order matters:
// 1. Recover — panic → JSON 500
// 2. RequestID — generate / propagate X-Request-Id
// 3. Tracing — one OTel SERVER span per /v1/* request, over ZAP
// 4. Logger — request-line log
// 5. SanitizeIdentity — establish a VALIDATED principal (see Identify)
app.Use(middleware.Recover())
app.Use(middleware.RequestID())
// Production response-header posture — the Stripe/Cloudflare/GitHub-grade
// signals plus a security floor, from ONE home in the framework so every
// service inherits the same wire posture. Registered right after RequestID
// (before the site edge and the business chain) so its headers ride out on
// every response: success, error, 404, AND the public-site static bytes.
// - Server: the white-label brand of the request Host (BrandForHostOK) — a
// lux/zoo caller is never served "hanzo" and no response leaks the
// framework name; an unmatched Host falls back to this deployment's own
// brand (cfg.Brand), never a framework/single-brand default.
// - X-Api-Version: the build version (brand-neutral key) for support correlation.
// - HSTS + nosniff: the always-safe security floor (no X-Frame-Options/CSP
// here — the console SPA owns its own framing rules).
// X-Request-Id stays owned by RequestID above; the two compose.
app.Use(middleware.ProductionHeaders(middleware.ProductionHeadersConfig{
Brand: func(host string) string { b, _ := BrandForHostOK(host); return b },
Neutral: cfg.Brand,
Version: cfg.Version,
HSTS: true,
}))
// Markdown content negotiation. Registered here — outermost of the business
// chain, just inside Recover/RequestID — so its post-Continue transform sees
// the FINAL response body and re-serializes it via zap-proto/md when the
// caller asked for markdown (Accept: text/markdown or ?format=md). JSON stays
// the default for machines; cfg.MarkdownDefaultPrefixes lets designated
// agent endpoints (/v1/code/, /v1/agents/…) default to markdown. Touches NO
// handler and fails safe (a render error leaves the JSON intact). See
// middleware_markdown.go.
app.Use(MarkdownNegotiation(cfg.MarkdownDefaultPrefixes))
// Request tracing. Sits right after RequestID (so the span carries the
// request_id) and BEFORE identity/audit/billing/handlers, so the whole
// authenticated pipeline nests under one span and the span CONTEXT it writes
// via SetContext parents every downstream span (agent.run → agent.step →
// chat) into a single trace. Spans ship over the SAME global provider installed
// by InstallTelemetry, landing in hanzoai/datastore.
// Health/readiness/metrics + non-/v1 paths are skipped (see traceable). See
// middleware_tracing.go.
app.Use(TracingMiddleware())
// No request logger is installed here: zip reports every request natively —
// method, path, status, duration, trace and span, and the caller when the
// environment parked one — through the app's own logger. A second line per
// request would say less and cost the same.
// Public site edge (clients/sites). Installed FIRST — after Recover/RequestID/
// Logger, BEFORE SanitizeIdentity + BillingGate — so a request whose Host is a
// published-site host (`<slug>.hanzo.app`) is served the site's static bytes
// from OUR S3 and returns HERE, never entering the authenticated/billed API
// pipeline. A published site is a PUBLIC artifact: no IAM JWT, no balance gate.
// For every other Host this middleware calls Continue() and the pipeline below
// runs unchanged. The slug→{org,bucket,prefix} resolver is the projects store,
// injected at its Mount via sites.SetResolver; until then a site host 404s
// honestly. Org isolation (org+prefix come only from the store keyed by the
// validated slug; object keys are rooted-clean) lives in clients/sites.
// The edge asks the app that owns the store when it is not in this process,
// which in production is always: the pod boots ~25 single-app processes, so
// the registry projects.Mount writes is nil here. Co-resident still wins with
// no hop — currentResolver prefers the in-process one.
sites.SetFallbackResolver(planeSites{})
app.Use(sites.New(sites.ConfigFromEnv(cfg.Domain), deps.Logger).Middleware())
// Edge policy — the role this program absorbs because nothing in front of it
// installs middleware. Runs BEFORE identity by design:
// - EdgeCORS answers the browser OPTIONS preflight (which carries no
// credentials) and short-circuits it, so a preflight never reaches auth.
// No-op unless CLOUD_CORS_ORIGINS is set (the shared ingress owns CORS on
// the recommended rollout — enabling both would double the ACAO header).
// - EdgeRateLimit caps an ANONYMOUS per-IP flood before the JWKS/validate/
// downstream work it would trigger — the one gap ScopeRateLimit (which keys
// on the validated org, below) structurally can't see. Keyed on the
// public client IP; in-cluster direct callers (no X-Forwarded-For) are
// exempt, matching the standalone gateway's public-only scope. See
// middleware_edge.go.
app.Use(EdgeCORS(deps.GatewayPolicy))
app.Use(EdgeRateLimit(deps.GatewayPolicy))
Identify(app, cfg)
return app
}
// Identify gives an app a trustworthy answer to who is calling, and makes that
// answer reachable from every route beneath it. App does this for every program,
// which is the only reason it can no longer be skipped.
//
// The two halves are one function because each is wrong without the other, and
// wrong in a way nothing reports. IdentityMiddleware deletes the authority
// headers a client sent and re-mints them from a verified IAM token, so it runs
// first: what it produces is the only principal in the process anyone may trust.
// The enrichment then parks that principal on the request context, which is the
// only path by which a typed op reaches it — a zip.Get[In, Out] handler receives a
// context and its decoded In and nothing else. Reversed, it parks whatever the
// caller claimed for itself. Installed alone, the boundary validates a caller and
// then every typed op reads an empty org and refuses that same caller, which
// reaches the wire as a 403 from a service behaving exactly as built.
//
// That last failure is the reason this is a function rather than two lines of
// advice. It is what the o11y binary did while assembling its own app, and its
// subsystem then compensated from inside its own Mount, on a group node that
// owned no routes — a program zip refuses to compose, which is the outage.
func Identify(app *zip.App, cfg *Config) {
// The identity trust boundary. HIP-0519 says identity is verified once, at the
// edge, and that is the shape to reach. It rests on ONE assumption: the gateway
// is the only ingress. That assumption does not hold here yet, and the estate's
// own red-team probe says so — with this middleware removed, a request carrying
// a forged X-Org-Id, X-User-Id and X-User-IsAdmin reads another org's secret
// VALUE from the in-cluster KMS listener:
//
// PROBE (b) forged org + forged X-User-Id + IsAdmin → 200 {"value":"…"}
//
// So this stays until service listeners are unreachable except through the
// gateway. Removing it is a network-policy change first and a code change
// second, and doing the code half alone is a cross-tenant secret read.
// red_orgscope_isolation_test.go and TestAudit_AnonRequestNotAttributedToForgedOrg
// fail the moment it is dropped; they are the gate on that work, not obstacles
// to it.
app.Use(IdentityMiddleware(cfg))
// Besides the validated org, this carries the request a proxying subsystem
// forwards identity from and the slot a creator writes 201 or 202 into. It must
// precede every typed route, because fiber runs middleware in registration
// order and one installed after its leaves never runs. A subsystem whose routes
// are spread across several top-level nouns owns no single prefix to hang it
// on, which is the other reason it belongs to whoever composes the app. See
// typed.go.
app.Use(Bridge())
}
+115 -39
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}
@@ -150,19 +150,9 @@ func MountAccount(app cloud.Router, deps cloud.Deps) error {
// routesAccount wires the specific self-service routes (order 48).
func routesAccount(s *cloud.Service[state], app cloud.Router) error {
// Bridge FIRST: a typed op receives only a context, so the request facts its
// signature drops — here the VALIDATED principal every route resolves its caller
// from — reach it by being parked there. fiber runs middleware in registration
// order, so this must precede the leaves below. Serve installs one app-wide too
// and nesting is harmless (the inner one is what the handler sees); this one is
// what makes the subsystem self-sufficient when it is mounted on a bare app,
// which is exactly what its own tests do.
//
// It goes through Use, not Group(prefix, mw): account's routes are spread across
// six top-level nouns, so it owns no single prefix to hang a group on — and
// Router.Use is the door that fans middleware out over the prefixes the
// composition root declared for this subsystem, which is precisely that set.
app.Use(cloud.Bridge())
// The composer owns cloud.Bridge: the fused host installs it once at its root
// and the plugin constructor does the same for a plugin program, so no
// subsystem installs it.
// The typed registrars take the App behind the Router: a typed op is a route
// PLUS a registry entry, and the registry lives on the App (scope.go). A
@@ -226,14 +216,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
}
@@ -582,13 +589,68 @@ type onboardResp struct {
// Additional is true when the caller already had an organization and this one
// was created WITHOUT moving them into it — they reach it via the org switcher.
Additional bool `json:"additional"`
// AccessKey is the identifier of the org-scoped credential provisioning minted
// with the organization. Present on a first run that actually minted one.
AccessKey string `json:"accessKey,omitempty"`
// AccessSecret is that credential's confidential half, returned ONCE — on the
// response that mints it and never again. IAM keeps only its argon2id digest
// and blanks the plaintext, so this is the single moment it exists in a form
// its owner can read; a replay of the same provision re-reveals nothing.
AccessSecret string `json:"accessSecret,omitempty"`
}
// hasHomeOrg reports whether the caller already OWNS an organization — the fact
// that separates a FIRST-RUN onboarding from an ADDITIONAL one.
//
// Carrying an X-Org-Id is NOT that fact, and reading it as one is what left a
// fresh sign-up unable to get a workspace. Federated sign-up files a brand-new
// user under the sign-up APPLICATION's own organization (iam
// internal/oidc/federation.go: `org := app.Organization`, which for hanzo-console
// is the brand org — the same value hanzoai/account publishes as SignupOrg), so
// the very first request a new customer ever makes already carries an owner.
// Taken for a home it sent them down the ADDITIONAL branch, which creates an org
// and leaves them OUTSIDE it, and answered `personal: true` with a 409 that was
// true of the landing org and useless to the person who had just signed up.
//
// The orgs a sign-up can land in are exactly the ones this package already
// refuses to hand to a customer — onboarding.go's reservedOrgs, the brand/staff
// and IAM system orgs. One list, one fact, asked twice: an org no customer may
// CREATE is likewise an org no customer can be said to OWN. Naming the set rather
// than the single brand constant is also what keeps a white-labelled deployment
// correct, where the landing org is that brand's own.
//
// STANDING BEATS THE LANDING, and that is not a nicety. A SuperAdmin IS a member
// of the reserved `admin` org — that membership is the whole definition — so
// treating it as a landing and moving them out would strip the privilege. An org
// ADMIN therefore always counts as owning their org. Only IAM may attest to that,
// so it is read from the authoritative row; a header would let a caller elect
// their own move.
//
// A caller already in a real tenant is spared the read entirely: that org is
// theirs whatever standing they hold in it, so an invited member creating a
// second org is never yanked out of the team that invited them.
func hasHomeOrg(ctx context.Context, iam *iamClient, cr caller) (bool, error) {
if cr.owner == "" {
return false, nil // no org at all — unambiguously a first run
}
if !isReservedOrg(cr.owner) {
return true, nil // a real tenant: theirs, and never to be moved out of
}
row, err := iam.getUserRow(ctx, cr.id)
if err != nil {
// Fail closed: unresolved standing must never be read as "no standing",
// because that answer is the one that MOVES the user.
return false, zip.Errorf(http.StatusBadGateway, "could not resolve your account: %v", err)
}
return row.IsAdmin, nil
}
// Onboard creates the caller's organization. Two flows, keyed on whether the caller
// already has a home org (mirrors app/onboard/route.ts):
//
// - FIRST-RUN (no owner): create + MOVE the user in as admin, so their next JWT
// carries the new owner and the cloud scopes everything to it.
// - FIRST-RUN (no home org): create + MOVE the user in as admin, so their next
// JWT carries the new owner and the cloud scopes everything to it. This is the
// path a fresh OAuth sign-up takes, from the sign-up application's org.
// - ADDITIONAL (owner set): create the org but do NOT move the user — a move
// changes their IAM owner (stripping a SuperAdmin's status + orphaning their
// current org). They reach the new org via the OrgSwitcher, which re-scopes
@@ -608,7 +670,10 @@ func (o ops) onboard(ctx context.Context, in *onboardReq) (*onboardResp, error)
body := *in
rctx := c.Context()
additional := cr.owner != ""
additional, herr := hasHomeOrg(rctx, s.State.iam, cr)
if herr != nil {
return nil, herr
}
if additional && body.Personal {
return nil, zip.ErrConflict("you already have an organization; name the new one explicitly")
}
@@ -661,12 +726,20 @@ func (o ops) onboard(ctx context.Context, in *onboardReq) (*onboardResp, error)
return &onboardResp{Org: slug, DisplayName: displayName, Additional: false}, nil
}
// onboardFirstRun drives the ONE atomic IAM provision for a zero-org caller (create
// org + move them in as admin + mint the hashed org-scoped credential), replacing
// the create-org + move-user pair so a mid-flight retry converges on the founder's
// own org instead of orphaning it. The org starts at a ZERO balance — usage is
// pre-paid, so there is no signup grant. Split out so the provisioning glue is
// unit-tested against mock IAM without the CSRF/routing/principal shell.
// onboardFirstRun drives the ONE atomic IAM provision for a caller with no home
// org (create org + move them in as admin + mint the hashed org-scoped
// credential), replacing the create-org + move-user pair so a mid-flight retry
// converges on the founder's own org instead of orphaning it. The org starts at a
// ZERO balance — usage is pre-paid, so there is no signup grant. Split out so the
// provisioning glue is unit-tested against mock IAM without the CSRF/routing/
// principal shell.
//
// The minted credential travels back on THIS response because this is the only
// moment it can: IAM stores the argon2id digest and blanks the plaintext, so the
// secret exists in readable form exactly once, in the answer to the call that
// minted it. Dropping it here left a customer holding an account whose credential
// had been issued and could never be obtained. It is revealed, never persisted in
// the clear, and a replay (which mints nothing) carries no secret at all.
func onboardFirstRun(ctx context.Context, iam *iamClient, callerID, slug, displayName string, personal bool) (onboardResp, error) {
row, err := iam.getUserRow(ctx, callerID)
if err != nil {
@@ -676,7 +749,10 @@ func onboardFirstRun(ctx context.Context, iam *iamClient, callerID, slug, displa
if err != nil {
return onboardResp{}, zip.Errorf(http.StatusBadGateway, "could not provision the organization: %v", err)
}
return onboardResp{Org: res.Org, DisplayName: displayName, Additional: false}, nil
return onboardResp{
Org: res.Org, DisplayName: displayName, Additional: false,
AccessKey: res.AccessKey, AccessSecret: res.AccessSecret,
}, nil
}
// resolveOnboardName derives the base slug + display name from the request, or a
+42 -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
}
@@ -226,12 +255,20 @@ func mountApp(t *testing.T, base, clientID, clientSecret string) *zip.App {
return mount(t, "hanzo")
}
// compose installs what a host installs. A subsystem never installs cloud.Bridge:
// the program's composer owns it — serve.go at the root of the fused host, the
// plugin constructor for a plugin program. In a test the test is the composer, so
// it owes the same install; skipping it drives a program where every org-scoped op
// answers 403 for a reason production callers never see.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
// mount mounts the account subsystem on a bare app — exactly what production
// registers (account@48). The caller sets the IAM env (IAM_URL / IAM_MINT_CLIENT_*)
// before calling.
func mount(t *testing.T, brand string) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
deps := cloud.Deps{Logger: luxlog.New("test"), Brand: brand}
if err := MountAccount(app, deps); err != nil {
t.Fatalf("MountAccount: %v", err)
@@ -257,7 +294,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 +322,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 +574,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)
}
@@ -713,6 +750,7 @@ func TestAccountClaimsNothingUnderIAM(t *testing.T) {
t.Setenv("IAM_MINT_CLIENT_SECRET", "s3cr3t")
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := MountAccount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
t.Fatalf("MountAccount: %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)
}
+485
View File
@@ -0,0 +1,485 @@
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})
compose(app)
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)
}
}
+1 -1
View File
@@ -106,7 +106,7 @@ func commerceServiceToken() string { return getenv("COMMERCE_SERVICE_TOKEN", "")
// s2sBillingCall reports whether the request carries the verified COMMERCE_SERVICE_TOKEN
// as its Bearer — a trusted IN-PROC service-to-service caller (the metering cap-gate's
// authorize, the SuperAdmin cap-oversight Forward). Safety rests on the edge: the gateway
// 401s a public Bearer that is not an IAM JWT / hk-|pk-|sk- API key (the 64-hex service
// 401s a public Bearer that is not an IAM JWT / pk-|sk- API key (the 64-hex service
// token is a JWT candidate that fails to parse), so an EXTERNAL client can never reach a
// handler holding it — only in-proc commerce-transport dispatch does. Constant-time
// compare; the token is never logged.
+18 -3
View File
@@ -62,10 +62,25 @@ func PinBillingSubject() zip.Handler {
// Not a validated customer — admit ONLY a trusted in-proc S2S caller that
// names its own org (same admission billingData makes), leaving its query
// untouched. Everything else is refused before the read runs.
if s2sBillingCall(c) && c.Org() != "" {
return c.Next()
//
// The two refusals are DIFFERENT answers and must not share a status. A
// service token is a credential: presenting one and omitting X-Org-Id is
// an authenticated request that names no scope, which is 403. Presenting
// nothing is not signed in, which is 401 — and the difference is load
// bearing on the customer path, because a browser re-authenticates on 401
// and merely reports 403. These routes moved here from cloud's billing
// app, which answered 401 deliberately ("a customer's own billing action,
// so no identity is 401 sign in, never the wildcard's admin 403"); serving
// them in-process silently made every one of them 403, so an expired
// session on the saved-cards screen showed a permission error instead of
// sending the customer to sign in.
if s2sBillingCall(c) {
if c.Org() != "" {
return c.Next()
}
return zip.ErrForbidden("X-Org-Id is required to scope a service-token billing read")
}
return zip.ErrForbidden("sign in to view billing")
return zip.ErrUnauthorized("sign in to view billing")
}
subject := account.Payer(account.Credential{
+14 -4
View File
@@ -40,11 +40,19 @@ 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")})
// MountAccount installs the identity middleware PinBillingSubject relies on; mounting
// it keeps the probe on the same trust plane as the real co-resident registration.
// compose installs the identity middleware PinBillingSubject relies on; mounting
// the real subsystem keeps the probe on the same trust plane as the co-resident
// registration.
compose(app)
if err := MountAccount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
t.Fatalf("MountAccount: %v", err)
}
@@ -114,8 +122,10 @@ func TestPinBillingSubject_RefusesUnvalidated(t *testing.T) {
app := pinApp(t)
code, _ := callH(t, app, http.MethodGet, "/probe?userId=victim",
map[string]string{"X-Org-Id": "victim"}, "")
if code != http.StatusForbidden {
t.Fatalf("unvalidated caller: want 403, got %d", code)
// 401, not 403: no credential was presented at all, and a browser only
// re-authenticates on 401. A forged X-Org-Id is not a credential.
if code != http.StatusUnauthorized {
t.Fatalf("unvalidated caller: want 401, got %d", code)
}
}
+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)
}
+154 -18
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")),
@@ -123,16 +122,23 @@ func (c *iamClient) provision(ctx context.Context, owner, name, orgSlug string,
// userRow is the subset of an IAM user the onboarding path reads to resolve the
// caller's authoritative (owner, name) — a zero-org caller's owner is not on its
// token, so provision needs it from the row.
// token, so provision needs it from the row — and whether they ADMIN the org they
// are in, which is what tells a home org from a place they merely landed.
type userRow struct {
Owner string `json:"owner"`
Name string `json:"name"`
// IsAdmin is IAM's org-admin bit: standing in Owner, as opposed to mere
// membership of it. It is read from the ROW and never from a header — the
// decision it feeds moves a user between organizations, so a caller must not
// be able to elect their own move.
IsAdmin bool `json:"isAdmin"`
}
// 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 +205,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) ─────────────────────────────────────────────
@@ -312,9 +350,7 @@ func (c *iamClient) mintUserKey(ctx context.Context, id, typ string) (string, er
}
// prefixForType is the one place the wire type and the credential prefix are tied
// together: publishable keys are pk-, secret keys are sk-. hk- is sk- under an older
// name, so a legacy secret key satisfies neither — deliberately: this gate runs only
// on a FRESH mint, and IAM has not minted an hk- since v1.33.9.
// together: publishable keys are pk-, secret keys are sk-.
func prefixForType(typ string) string {
if typ == keyTypePublishable {
return "pk-"
@@ -389,8 +425,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
}
@@ -400,12 +504,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
}
+74
View File
@@ -0,0 +1,74 @@
package account
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// TestOnboardFirstRun_RevealsTheCredentialItMinted — provisioning mints the org's
// credential and the secret half is shown ONCE, on the response that mints it
// (IAM stores only its argon2id digest and blanks the plaintext, so there is no
// second chance to read it). Dropping it on the floor left a customer holding an
// account whose credential had been issued and could never be obtained.
func TestOnboardFirstRun_RevealsTheCredentialItMinted(t *testing.T) {
iamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/iam/users/get":
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"data": map[string]any{"owner": "hanzo", "name": "dave"},
})
case "/v1/iam/admin/provision":
_, _ = io.ReadAll(r.Body)
_ = json.NewEncoder(w).Encode(map[string]any{
"org": "dave", "accessKey": "pk-live-abc", "accessSecret": "sk-live-xyz",
})
default:
http.NotFound(w, r)
}
}))
defer iamSrv.Close()
iam := &iamClient{base: iamSrv.URL, clientID: "c", clientSecret: "s", serviceToken: "svc", http: &http.Client{}}
resp, err := onboardFirstRun(t.Context(), iam, "hanzo/dave", "dave", "Dave", true)
if err != nil {
t.Fatalf("onboardFirstRun: %v", err)
}
if resp.AccessKey != "pk-live-abc" {
t.Fatalf("accessKey = %q, want the minted pk- (the caller has no other way to learn it)", resp.AccessKey)
}
if resp.AccessSecret != "sk-live-xyz" {
t.Fatalf("accessSecret = %q, want the one-time reveal of the minted sk-", resp.AccessSecret)
}
}
// TestOnboardFirstRun_RevealsNothingItDidNotMint — on a replay IAM returns the
// access key but no secret (it holds only the digest). The response must then
// carry no secret rather than an empty field a client could mistake for one.
func TestOnboardFirstRun_RevealsNothingItDidNotMint(t *testing.T) {
iamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/iam/users/get":
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "data": map[string]any{"owner": "hanzo", "name": "dave"},
})
case "/v1/iam/admin/provision":
_ = json.NewEncoder(w).Encode(map[string]any{"org": "dave", "accessKey": "pk-live-abc"})
default:
http.NotFound(w, r)
}
}))
defer iamSrv.Close()
iam := &iamClient{base: iamSrv.URL, clientID: "c", clientSecret: "s", serviceToken: "svc", http: &http.Client{}}
resp, err := onboardFirstRun(t.Context(), iam, "hanzo/dave", "dave", "Dave", true)
if err != nil {
t.Fatalf("onboardFirstRun: %v", err)
}
if resp.AccessSecret != "" {
t.Fatalf("accessSecret = %q, want empty — a replay re-reveals nothing", resp.AccessSecret)
}
}
+130
View File
@@ -0,0 +1,130 @@
package account
import (
"net/http"
"testing"
)
// The day-one path: a brand-new user signs up through OAuth and asks for their
// own workspace.
//
// Federated sign-up files the new user under the sign-up APPLICATION's own
// organization (iam internal/oidc/federation.go: `org := app.Organization`), so
// the very first request they ever make already carries an X-Org-Id — the brand
// org, e.g. "hanzo". That org is one this package already refuses to hand to a
// customer (onboarding.go's reservedOrgs), so landing in it is not owning it.
//
// Read as "already has an org" it sent them down the ADDITIONAL branch, which
// creates an org and leaves the user OUTSIDE it, and answered `personal: true`
// with 409 "you already have an organization" — thirty seconds after signing up,
// about an org that was never theirs.
// TestOnboard_OAuthSignup_GetsItsOwnOrg drives a real OAuth-shaped signup end to
// end through the mounted route: a validated principal whose org is the sign-up
// application's, asking for a personal workspace. It must end OWNING its own org.
func TestOnboard_OAuthSignup_GetsItsOwnOrg(t *testing.T) {
f := newFakeIAM()
// The row federated sign-up wrote: owner is the sign-up application's org, and
// the user admins nothing there — they were deposited, not enrolled.
f.user["hanzo/dave"] = map[string]any{
"owner": "hanzo", "name": "dave", "type": "normal-user", "isAdmin": false,
}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
code, body := call(t, app, http.MethodPost, "/v1/orgs", "dave", "hanzo", `{"personal":true}`)
if code != http.StatusOK {
t.Fatalf("OAuth signup asking for its own workspace: want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if resp.Additional {
t.Fatalf("a fresh signup's FIRST org must not be an additional one: %+v", resp)
}
if resp.Org != "dave" {
t.Fatalf("personal org slug = %q, want %q", resp.Org, "dave")
}
// The whole point: they must end up IN it. An org they do not own is the bug.
if f.movedTo["hanzo/dave"] != "dave" {
t.Fatalf("signup must be moved into the org it just created, movedTo=%v", f.movedTo)
}
if owner, _ := f.createdOrgs[0]["owner"].(string); owner != adminOrg {
t.Fatalf("created org must be owned by %q, got %q", adminOrg, owner)
}
}
// TestOnboard_OAuthSignup_NamedOrgAlsoMoves is the same first run through the
// other door — a named org rather than a personal one. It took the ADDITIONAL
// branch silently: 200, an org created, and the founder left outside it.
func TestOnboard_OAuthSignup_NamedOrgAlsoMoves(t *testing.T) {
f := newFakeIAM()
f.user["hanzo/dave"] = map[string]any{"owner": "hanzo", "name": "dave", "isAdmin": false}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
code, body := call(t, app, http.MethodPost, "/v1/orgs", "dave", "hanzo", `{"name":"Acme Rockets"}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if resp.Additional {
t.Fatalf("a fresh signup's first named org must not be additional: %+v", resp)
}
if f.movedTo["hanzo/dave"] != "acme-rockets" {
t.Fatalf("founder must be moved into their own org, movedTo=%v", f.movedTo)
}
}
// TestOnboard_SuperAdminKeepsTheirOrg holds the line the landing-org rule must not
// cross. A SuperAdmin's privilege IS their membership of the reserved `admin` org
// (owner == "admin"), so treating that as a landing and moving them out would
// strip the very thing that makes them one. Standing beats the landing, and only
// IAM may attest to it — a header would let a caller elect their own move.
func TestOnboard_SuperAdminKeepsTheirOrg(t *testing.T) {
f := newFakeIAM()
f.user["admin/root"] = map[string]any{"owner": "admin", "name": "root", "isAdmin": true}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
// A named additional org: created, but the SuperAdmin is NOT moved.
code, body := call(t, app, http.MethodPost, "/v1/orgs", "root", "admin", `{"name":"Side Project"}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if !resp.Additional {
t.Fatalf("a SuperAdmin's new org is an ADDITIONAL one: %+v", resp)
}
if len(f.movedTo) != 0 {
t.Fatalf("a SuperAdmin must never be moved out of the admin org, movedTo=%v", f.movedTo)
}
// And the 409 stays correct where it was always correct: asking for a personal
// workspace when you already hold one is still a conflict.
code, _ = call(t, app, http.MethodPost, "/v1/orgs", "root", "admin", `{"personal":true}`)
if code != http.StatusConflict {
t.Fatalf("personal-while-orged: want 409, got %d", code)
}
}
// TestOnboard_MemberOfATenantIsNotFirstRun keeps an invited teammate where they
// are. Their org is a real tenant, not a landing, so their new org is additional
// however little standing they hold in it — a move would yank them out of the
// team that invited them.
func TestOnboard_MemberOfATenantIsNotFirstRun(t *testing.T) {
f := newFakeIAM()
f.user["acme/bob"] = map[string]any{"owner": "acme", "name": "bob", "isAdmin": false}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
code, body := call(t, app, http.MethodPost, "/v1/orgs", "bob", "acme", `{"name":"Side Project"}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if !resp.Additional {
t.Fatalf("a tenant member's new org is additional: %+v", resp)
}
if len(f.movedTo) != 0 {
t.Fatalf("a tenant member must never be moved, movedTo=%v", f.movedTo)
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ func TestOnboardFirstRun_ProvisionsOnce(t *testing.T) {
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &provBody)
_ = json.NewEncoder(w).Encode(map[string]any{
"org": "dave", "accessKey": "hk-x", "accessSecret": "sk-x",
"org": "dave", "accessKey": "sk-x", "accessSecret": "secret-x",
})
default:
http.NotFound(w, r)
+1 -1
View File
@@ -1,6 +1,6 @@
package account
// Per-IP rate limiting for the abuse-sensitive console write routes (hk- key
// Per-IP rate limiting for the abuse-sensitive console write routes (API key
// mint/rotate/revoke, HUSD wallet top-up). This is DISTINCT from commerce's spend-cap
// (ScopeRateLimit): it caps request FREQUENCY per client IP to blunt brute-force /
// enumeration / resource-exhaustion, restoring the edge protection cloud loses when a
-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
+14 -35
View File
@@ -10,7 +10,7 @@ import (
func init() {
zip.Describe("DELETE /v1/keys", zip.Doc{
Description: "RevokeKey revokes the caller's own API key of the requested class. The class is\nthe same field mint takes — `?type=publishable`, defaulting to secret — so\nrevoking the key that ships in a browser bundle does not sign its holder out of\ntheir own API: the other key keeps working.\n\nRevoking is how a key is replaced when it does not need replacing; minting the\nsame class again rotates it in one step. IAM drops the credential immediately,\nbut the gateway caches keys for a few minutes, so a request that beat the cache\nexpiry may still be served.\n\nFor callers written against the older shape, the class is also accepted in a JSON\nrequest body, read only when `?type=` is absent.",
Description: "Revokes the caller's own API key of the requested class. The class is\nthe same field mint takes — `?type=publishable`, defaulting to secret — so\nrevoking the key that ships in a browser bundle does not sign its holder out of\ntheir own API: the other key keeps working.\n\nRevoking is how a key is replaced when it does not need replacing; minting the\nsame class again rotates it in one step. IAM drops the credential immediately,\nbut the gateway caches keys for a few minutes, so a request that beat the cache\nexpiry may still be served.\n\nFor callers written against the older shape, the class is also accepted in a JSON\nrequest body, read only when `?type=` is absent.",
Fields: map[string]string{
"keyTypeIn.type": "Type is the key class to act on: \"secret\" (sk-, session-equivalent, belongs\non a server) or \"publishable\" (pk-, org-identifying, safe in a browser\nbundle). Omitted means secret, which is what every existing caller means.",
"revokedKey.ok": "OK is true when the key was revoked. A failure is an error status, never a\nfalse here.",
@@ -18,18 +18,8 @@ func init() {
},
Example: json.RawMessage(`{"type":"publishable"}`),
})
zip.Describe("GET /v1/commerce/topup/rails", zip.Doc{
Description: "TopupRails 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.",
@@ -39,7 +29,7 @@ func init() {
},
})
zip.Describe("GET /v1/embed", zip.Doc{
Description: "EmbedStatus reports whether one of this brand's shared embedded apps (cms, erp,\nhelp) may be framed by the caller and is actually running, so a console module\ncan choose between the embed and the provision panel.\n\nIt answers two questions the browser cannot answer for itself. ENTITLEMENT is\nserver-authoritative: each app is a single shared per-BRAND instance, so only a\nmember of the owning brand org — or a SuperAdmin — is given the embed URL; every\nother caller gets phase \"not-entitled\" and no URL. REACHABILITY is a probe of\nthat origin, which a cross-origin page cannot read for itself.\n\nThe probed host is always <app>.<this deployment's own brand domain>: no part of\nit comes from the request, so this can never be steered into probing an\narbitrary origin.",
Description: "Reports whether one of this brand's shared embedded apps (cms, erp,\nhelp) may be framed by the caller and is actually running, so a console module\ncan choose between the embed and the provision panel.\n\nIt answers two questions the browser cannot answer for itself. ENTITLEMENT is\nserver-authoritative: each app is a single shared per-BRAND instance, so only a\nmember of the owning brand org — or a SuperAdmin — is given the embed URL; every\nother caller gets phase \"not-entitled\" and no URL. REACHABILITY is a probe of\nthat origin, which a cross-origin page cannot read for itself.\n\nThe probed host is always <app>.<this deployment's own brand domain>: no part of\nit comes from the request, so this can never be steered into probing an\narbitrary origin.",
Fields: map[string]string{
"embedStatusReq.app": "App is the embedded app to report on: cms (Content Studio), erp or help.",
"embedStatusResp.app": "App is the app this verdict is about.",
@@ -52,7 +42,7 @@ func init() {
Example: json.RawMessage(`{"app":"cms"}`),
})
zip.Describe("GET /v1/keys", zip.Doc{
Description: "GetKey returns the caller's own API keys — every type they hold, read\nAUTHORITATIVELY from IAM rather than from the session claim, which lags a key\nminted moments ago. No secret material comes back: a secret key is represented\nby its prefix, and only a publishable key (public by construction) carries its\nfull value.\n\nA transient IAM read failure reports an empty set rather than a 5xx, so the\npage shows the honest empty state and never a fabricated key.",
Description: "Returns the caller's own API keys — every type they hold, read\nAUTHORITATIVELY from IAM rather than from the session claim, which lags a key\nminted moments ago. No secret material comes back: a secret key is represented\nby its prefix, and only a publishable key (public by construction) carries its\nfull value.\n\nA transient IAM read failure reports an empty set rather than a 5xx, so the\npage shows the honest empty state and never a fabricated key.",
Fields: map[string]string{
"apiKey.createdAt": "CreatedAt is when the key last changed, as IAM records it.",
"apiKey.key": "Key is the FULL value, and is present for a publishable key only: it is\npublic by construction and useless to its holder if it cannot be read back.",
@@ -61,21 +51,8 @@ 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: "WalletTopup 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: "MintKey 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.",
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{
"keyTypeIn.type": "Type is the key class to act on: \"secret\" (sk-, session-equivalent, belongs\non a server) or \"publishable\" (pk-, org-identifying, safe in a browser\nbundle). Omitted means secret, which is what every existing caller means.",
"mintedKey.accessKey": "AccessKey is the same value under its predecessor name, carried so callers\nwritten against the older field keep working. One value, two names.",
@@ -85,13 +62,15 @@ func init() {
Example: json.RawMessage(`{"type":"publishable"}`),
})
zip.Describe("POST /v1/orgs", zip.Doc{
Description: "Onboard creates the caller's organization. Two flows, keyed on whether the caller\nalready has a home org (mirrors app/onboard/route.ts):\n\n - FIRST-RUN (no owner): create + MOVE the user in as admin, so their next JWT\n carries the new owner and the cloud scopes everything to it.\n - ADDITIONAL (owner set): create the org but do NOT move the user — a move\n changes their IAM owner (stripping a SuperAdmin's status + orphaning their\n current org). They reach the new org via the OrgSwitcher, which re-scopes\n X-Org-Id without touching IAM membership. A personal-org request from someone\n who already has an org is meaningless → 409.",
Description: "Onboard creates the caller's organization. Two flows, keyed on whether the caller\nalready has a home org (mirrors app/onboard/route.ts):\n\n - FIRST-RUN (no home org): create + MOVE the user in as admin, so their next\n JWT carries the new owner and the cloud scopes everything to it. This is the\n path a fresh OAuth sign-up takes, from the sign-up application's org.\n - ADDITIONAL (owner set): create the org but do NOT move the user — a move\n changes their IAM owner (stripping a SuperAdmin's status + orphaning their\n current org). They reach the new org via the OrgSwitcher, which re-scopes\n X-Org-Id without touching IAM membership. A personal-org request from someone\n who already has an org is meaningless → 409.",
Fields: map[string]string{
"onboardReq.name": "Name is the organization's display name. Ignored when personal is true, which\nderives the name from the caller's own username instead.",
"onboardReq.personal": "Personal asks for the caller's own workspace: the name is derived from their\nusername and the slug auto-suffixes to stay unique. Meaningless — and refused\n— for a caller who already has an organization.",
"onboardResp.additional": "Additional is true when the caller already had an organization and this one\nwas created WITHOUT moving them into it — they reach it via the org switcher.",
"onboardResp.displayName": "DisplayName is the organization's human name.",
"onboardResp.org": "Org is the created organization's slug, which is what X-Org-Id carries.",
"onboardReq.name": "Name is the organization's display name. Ignored when personal is true, which\nderives the name from the caller's own username instead.",
"onboardReq.personal": "Personal asks for the caller's own workspace: the name is derived from their\nusername and the slug auto-suffixes to stay unique. Meaningless — and refused\n— for a caller who already has an organization.",
"onboardResp.accessKey": "AccessKey is the identifier of the org-scoped credential provisioning minted\nwith the organization. Present on a first run that actually minted one.",
"onboardResp.accessSecret": "AccessSecret is that credential's confidential half, returned ONCE — on the\nresponse that mints it and never again. IAM keeps only its argon2id digest\nand blanks the plaintext, so this is the single moment it exists in a form\nits owner can read; a replay of the same provision re-reveals nothing.",
"onboardResp.additional": "Additional is true when the caller already had an organization and this one\nwas created WITHOUT moving them into it — they reach it via the org switcher.",
"onboardResp.displayName": "DisplayName is the organization's human name.",
"onboardResp.org": "Org is the created organization's slug, which is what X-Org-Id carries.",
},
Example: json.RawMessage(`{"name":"Acme"}`),
})
+7 -11
View File
@@ -115,11 +115,12 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
func routes(app cloud.Router, s *cloud.Service[core.State]) {
o := ops{s: s}
z := cloud.ZipApp(app)
// The bridge FIRST: fiber runs middleware in registration order, so one installed
// 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())
// Every op below takes the request off the context, and whoever composes the app
// parks it there — at the root, ahead of these leaves, since fiber runs
// middleware in registration order. This surface installs none of its own: one
// it installed for itself could only hang on a /v1/admin node, and every op
// below registers through the root, so that node would carry middleware over an
// empty subtree and zip refuses to compose it.
// Org-scoped panels — AdmitScoped. Cross-tenant reads are impossible for a
// non-super caller.
@@ -144,11 +145,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 +249,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)
+3
View File
@@ -39,6 +39,7 @@ func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, pat
func mountService(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[core.State], *fiber.App) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
s := &cloud.Service[core.State]{State: core.State{
IAM: iam.New(iamURL),
Commerce: commerce.New(commerceURL, "test-token"),
@@ -731,6 +732,7 @@ func TestMount_NilGuards(t *testing.T) {
t.Error("Mount(nil app) must error")
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := Mount(app, cloud.Deps{}); err == nil {
t.Error("Mount(nil logger) must error")
}
@@ -747,6 +749,7 @@ func servePlatformEmpty(t *testing.T) {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
app := zip.New(zip.Config{AppName: "platform"})
compose(app)
zip.Post[struct{}, plane.Fleet](app, "/platform/fleet",
func(context.Context, *struct{}) (*plane.Fleet, error) {
return &plane.Fleet{}, nil
+8 -6
View File
@@ -34,10 +34,12 @@ func mountWithStore(t *testing.T) (*auditstore.Recorder, func(method, path strin
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &cloud.Service[core.State]{State: core.State{AdminOrg: "admin", AuditStore: rec}}
// Mirror the real mount: the request bridge, then the typed ops. A typed op sees
// the caller only through the bridge, so registering routes without it would test
// a wiring that cannot exist.
app.Group("/v1/admin").Use(cloud.Bridge())
// Stand in for the composer: the principal enrichment at the root, then the
// typed ops — the order cloud.App gives every production program. A typed op
// sees the caller only through what the enrichment parks, and a group at
// /v1/admin would be a node of its own with no routes beneath it, which zip
// refuses to compose.
app.Use(cloud.Bridge())
Routes(app, s)
fa := app.Fiber()
@@ -206,13 +208,13 @@ func TestAdminAudit_DeniedWithoutSuperAdmin(t *testing.T) {
func TestAdminAudit_VerifyWithoutStore(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &cloud.Service[core.State]{State: core.State{AdminOrg: "admin"}} // no auditStore
app.Group("/v1/admin").Use(cloud.Bridge())
app.Use(cloud.Bridge())
Routes(app, s)
req := httptest.NewRequest("GET", "/v1/admin/audit/verify", nil)
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)
}
+5 -5
View File
@@ -86,8 +86,8 @@ func newCockpitFakes(t *testing.T) *cockpitFakes {
admin bool
}
users := map[string][]u{
"acme": {{"acme", "anna", "anna@acme.test", "hk-anna-secret", true}, {"acme", "bob", "bob@acme.test", "", false}},
"globex": {{"globex", "gwen", "gwen@globex.test", "hk-gwen-secret", true}},
"acme": {{"acme", "anna", "anna@acme.test", "sk-anna-secret", true}, {"acme", "bob", "bob@acme.test", "", false}},
"globex": {{"globex", "gwen", "gwen@globex.test", "sk-gwen-secret", true}},
}
f.iam = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -186,7 +186,7 @@ func newCockpitFakes(t *testing.T) *cockpitFakes {
fmt.Fprintf(w, `{"user":%q,"currency":"usd","available":%d,"balance":%d}`, user, bal, bal)
case strings.HasSuffix(r.URL.Path, "/subscriptions"):
if org == "acme" && user == "acme" {
io.WriteString(w, `{"subscriptions":[{"status":"active","plan":{"name":"Pro","price":5000,"currency":"usd","interval":"month"}}]}`)
io.WriteString(w, `{"subscriptions":[{"status":"active","mrrCents":5000,"plan":{"name":"Pro","price":5000,"currency":"usd","interval":"month"}}]}`)
} else {
io.WriteString(w, `{"subscriptions":[]}`)
}
@@ -261,14 +261,14 @@ func TestCustomers_ListRealFleet(t *testing.T) {
}
// TestCustomerDetail_RealAndNoSecretLeak proves the detail is real AND that the
// hk- access key VALUE never appears in the response (presence only).
// sk- access key VALUE never appears in the response (presence only).
func TestCustomerDetail_RealAndNoSecretLeak(t *testing.T) {
f := newCockpitFakes(t)
resp, body := f.do("GET", "/v1/admin/customers/acme", adminHdr(), "")
if resp.StatusCode != 200 {
t.Fatalf("detail: %d (%s)", resp.StatusCode, body)
}
if strings.Contains(string(body), "hk-anna-secret") {
if strings.Contains(string(body), "sk-anna-secret") {
t.Fatalf("SECRET LEAK: the access key value appears in the customer detail response")
}
var env struct {
+28 -35
View File
@@ -32,6 +32,8 @@ import (
"strings"
"time"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/cloud/apps/admin/money"
"github.com/hanzoai/cloud/apps/commerce/transport"
)
@@ -118,21 +120,28 @@ type Plan struct {
}
// subscriptionsWire is the /v1/billing/subscriptions list shape Plan folds over.
//
// MRRCents is commerce's own figure for what the subscription contributes per
// month — interval-normalized and multiplied by its seats. This surface used to
// re-derive it here from Price and Interval, with its own copy of commerce's
// normalization and no knowledge of the seat count at all, so a 10-seat plan
// read as one seat. Commerce bills Price x quantity; it is the authority on
// what that subscription is worth, and this is a display surface.
type subscriptionsWire struct {
Subscriptions []struct {
Status string `json:"status"`
Plan struct {
Name string `json:"name"`
Price money.Cents `json:"price"`
Interval string `json:"interval"`
Status string `json:"status"`
MRRCents money.Cents `json:"mrrCents"`
Plan struct {
Name string `json:"name"`
} `json:"plan"`
} `json:"subscriptions"`
}
// Plan reads a subject's subscription tier + MRR in ONE decode (GET
// /v1/billing/subscriptions), so the customer + revenue surfaces share a single
// upstream read. Only "active"/"trialing" subscriptions count. Honest
// zero/"pay-as-you-go" (not an error) when commerce is unwired.
// upstream read. MRR counts what commerce says counts; "active"/"trialing" both
// mark the subject subscribed. Honest zero/"pay-as-you-go" (not an error) when
// commerce is unwired.
func (c *Client) Plan(ctx context.Context, subject string) (Plan, error) {
out := Plan{Name: "pay-as-you-go"}
if !c.Ready() {
@@ -147,9 +156,20 @@ func (c *Client) Plan(ctx context.Context, subject string) (Plan, error) {
return out, fmt.Errorf("commerce plan decode: %w", err)
}
for _, s := range w.Subscriptions {
// Revenue and entitlement are two questions, and this loop answers
// both. commerce owns the revenue one — Status.CountsTowardMRR — so
// this surface and commerce's own rollup can no longer report a
// different MRR for the same account. They did: this counted trials as
// revenue and the rollup did not, so the money board and the SaaS board
// disagreed by the whole trial cohort.
//
// A trial is not revenue, but it IS a live plan, so it still names the
// plan and marks the subject subscribed.
if subscription.Status(s.Status).CountsTowardMRR() {
out.MRR += s.MRRCents
}
switch strings.ToLower(strings.TrimSpace(s.Status)) {
case "active", "trialing":
out.MRR += monthlyNormalized(s.Plan.Price, s.Plan.Interval)
out.Active = true
if name := strings.TrimSpace(s.Plan.Name); name != "" && out.Name == "pay-as-you-go" {
out.Name = name
@@ -159,21 +179,6 @@ func (c *Client) Plan(ctx context.Context, subject string) (Plan, error) {
return out, nil
}
// monthlyNormalized normalizes a plan price to a monthly figure by its billing
// interval so annual and monthly plans are comparable in one MRR sum.
func monthlyNormalized(price money.Cents, interval string) money.Cents {
switch strings.ToLower(strings.TrimSpace(interval)) {
case "year", "yearly", "annual", "annually":
return price / 12
case "week", "weekly":
return price * 52 / 12
case "day", "daily":
return price * 365 / 12
default: // month/monthly and anything unrecognized → treat as monthly
return price
}
}
// Entry is one ledger row. Kind is "deposit" (credit) or "withdraw" (usage). At is
// the RFC3339 event time analytics buckets on.
type Entry struct {
@@ -301,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
+134 -9
View File
@@ -1,16 +1,141 @@
package commerce
import "testing"
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestMonthlyNormalizedCents proves annual/monthly normalization for MRR.
func TestMonthlyNormalizedCents(t *testing.T) {
if got := monthlyNormalized(12_000, "year"); got != 1_000 {
t.Errorf("yearly $120 → monthly = %d, want 1000", got)
// planClient points a Client at a stub commerce serving one canned
// /v1/billing/subscriptions body.
func planClient(t *testing.T, body string) *Client {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return New(srv.URL, "test-token")
}
// Plan must READ commerce's mrrCents, not re-derive MRR from price and
// interval. It used to re-derive it, with its own copy of commerce's
// normalization and no reading of quantity at all — so a 10-seat $20/seat plan
// showed $20 here and $200 in commerce's own rollup. The interval arithmetic is
// pinned where it lives now, in commerce's api/billing.
func TestPlanReadsCommerceMRR(t *testing.T) {
c := planClient(t, `{"subscriptions":[
{"status":"active","mrrCents":20000,"plan":{"name":"team"}},
{"status":"active","mrrCents":1000,"plan":{"name":"pro"}}
]}`)
got, err := c.Plan(context.Background(), "org-1")
if err != nil {
t.Fatalf("Plan: %v", err)
}
if got := monthlyNormalized(2_000, "month"); got != 2_000 {
t.Errorf("monthly must pass through, got %d", got)
if got.MRR != 21000 {
t.Errorf("MRR = %d, want 21000 (the sum of what commerce reported)", got.MRR)
}
if got := monthlyNormalized(2_000, ""); got != 2_000 {
t.Errorf("unknown interval must be treated as monthly, got %d", got)
if !got.Active {
t.Error("Active = false with an active subscription")
}
if got.Name != "team" {
t.Errorf("Name = %q, want team (the first active plan)", got.Name)
}
}
// The seat-inclusive figure must survive verbatim. This is the case the old
// re-derivation got wrong: it saw price 2000 and reported 2000.
func TestPlanDoesNotRederiveFromPrice(t *testing.T) {
// price and interval are still on the wire; they must NOT be consulted.
c := planClient(t, `{"subscriptions":[
{"status":"active","mrrCents":20000,
"plan":{"name":"team","price":2000,"interval":"month"}}
]}`)
got, err := c.Plan(context.Background(), "org-1")
if err != nil {
t.Fatalf("Plan: %v", err)
}
if got.MRR != 20000 {
t.Errorf("MRR = %d, want 20000 — price/interval must not be re-normalized here", got.MRR)
}
}
// Revenue and entitlement part ways here, and both answers are pinned.
//
// This asserted MRR 1500 from the trialing row, because the surface counted
// "active" and "trialing" alike. commerce's rollup never did, so the money
// board and the SaaS board reported different revenue for the same account.
// subscription.Status.CountsTowardMRR settles it: a trial is not revenue —
// nobody has been charged — so the expected total is 0, not a weakened
// assertion.
//
// The trial still names the plan and still marks the subject subscribed. It IS
// a live plan; it just is not money yet.
func TestPlanCountsNoRevenueForTrialOrCanceled(t *testing.T) {
c := planClient(t, `{"subscriptions":[
{"status":"canceled","mrrCents":50000,"plan":{"name":"enterprise"}},
{"status":"trialing","mrrCents":1500,"plan":{"name":"pro"}}
]}`)
got, err := c.Plan(context.Background(), "org-1")
if err != nil {
t.Fatalf("Plan: %v", err)
}
if got.MRR != 0 {
t.Errorf("MRR = %d, want 0 (a trial is not revenue; canceled is over)", got.MRR)
}
if !got.Active {
t.Error("Active = false, want true — a trialing subject is subscribed")
}
if got.Name != "pro" {
t.Errorf("Name = %q, want pro", got.Name)
}
}
// An active subscription alongside a trial contributes exactly its own MRR, so
// the trial neither adds to nor suppresses real revenue.
func TestPlanCountsActiveAlongsideTrial(t *testing.T) {
c := planClient(t, `{"subscriptions":[
{"status":"active","mrrCents":9900,"plan":{"name":"pro"}},
{"status":"trialing","mrrCents":1500,"plan":{"name":"enterprise"}}
]}`)
got, err := c.Plan(context.Background(), "org-1")
if err != nil {
t.Fatalf("Plan: %v", err)
}
if got.MRR != 9900 {
t.Errorf("MRR = %d, want 9900 (the trial adds nothing)", got.MRR)
}
}
// No subscriptions is an honest zero and "pay-as-you-go", never an error and
// never a fabricated tier.
func TestPlanWithNoSubscriptions(t *testing.T) {
c := planClient(t, `{"subscriptions":[]}`)
got, err := c.Plan(context.Background(), "org-1")
if err != nil {
t.Fatalf("Plan: %v", err)
}
if got.MRR != 0 || got.Active || got.Name != "pay-as-you-go" {
t.Errorf("Plan = %+v, want {pay-as-you-go 0 false}", got)
}
}
// Guard the decode shape itself: mrrCents is the field read. If that tag ever
// drifted from what commerce emits, every board would silently report zero
// revenue rather than fail.
func TestSubscriptionsWireReadsMRRCents(t *testing.T) {
var w subscriptionsWire
if err := json.Unmarshal([]byte(`{"subscriptions":[{"mrrCents":4242}]}`), &w); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(w.Subscriptions) != 1 || w.Subscriptions[0].MRRCents != 4242 {
t.Fatalf("decoded %+v, want one subscription with MRRCents 4242", w.Subscriptions)
}
}
+15
View File
@@ -0,0 +1,15 @@
package admin
import (
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// compose stands in for the composer. Production programs are built by
// cloud.App, which installs the principal enrichment once at the root before
// any route; a test that mounts this subsystem on a bare app owns that duty
// itself, exactly once, here. A test that sends no identity is unaffected —
// with nothing validated there is nothing to park — so anonymous cases still
// refuse, and principal-carrying cases reach the handler as they do in
// production.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
+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
+2 -2
View File
@@ -266,8 +266,8 @@ func newFakeCommerceFinance() *httptest.Server {
io.WriteString(w, `{"consumedCents":15000,"overageCents":0,"balance":{"balanceCents":0,"availableCents":0}}`)
case strings.HasSuffix(r.URL.Path, "/subscriptions"):
io.WriteString(w, `{"subscriptions":[
{"status":"active","plan":{"price":5000,"currency":"usd","interval":"month"}},
{"status":"canceled","plan":{"price":9900,"currency":"usd","interval":"month"}}
{"status":"active","mrrCents":5000,"plan":{"price":5000,"currency":"usd","interval":"month"}},
{"status":"canceled","mrrCents":9900,"plan":{"price":9900,"currency":"usd","interval":"month"}}
]}`)
default:
w.WriteHeader(404)
+1 -1
View File
@@ -63,7 +63,7 @@ type Org struct {
// User is the IAM User subset mapped into OperatorUser. AccessKey is decoded
// ONLY to derive API-key PRESENCE (hasApiKey) for the customer detail — its VALUE
// is never surfaced in any admin response (the hk- key is a credential, not a
// is never surfaced in any admin response (the key is a credential, not a
// display field), so no secret leaves this binary.
type User struct {
Owner string `json:"owner"`
+12 -5
View File
@@ -245,22 +245,29 @@ func activeSubs() string {
// headlineSQL: run-rate MRR (paying, non-trial), active-sub count, paying-customer
// count, and trial count — one pass over the active-subs state.
//
// The revenue predicate is `status = 'active'`, which is commerce's
// subscription.Status.CountsTowardMRR spelled in SQL — this board reads the
// warehouse, so it cannot call the Go function, and the two must be kept in
// step by hand. It used to say `status != 'trialing'`, which also counted
// past_due and unpaid as run-rate revenue, so this board and the money board
// reported different MRR for the same account in both directions at once.
func headlineSQL() string {
return "SELECT sumIf(mrr_cents, status != 'trialing') AS mrr, " +
return "SELECT sumIf(mrr_cents, status = 'active') AS mrr, " +
"count() AS active_subs, " +
"uniqExactIf(org, status != 'trialing' AND mrr_cents > 0) AS paying, " +
"uniqExactIf(org, status = 'active' AND mrr_cents > 0) AS paying, " +
"countIf(status = 'trialing') AS trials FROM " + activeSubs()
}
func byCategorySQL() string {
return "SELECT category, sumIf(mrr_cents, status != 'trialing') AS mrr, count() AS subs " +
return "SELECT category, sumIf(mrr_cents, status = 'active') AS mrr, count() AS subs " +
"FROM " + activeSubs() + " GROUP BY category ORDER BY mrr DESC"
}
func byPlanSQL() string {
return "SELECT plan, any(plan_name) AS name, any(category) AS category, " +
"countIf(status = 'active') AS active, countIf(status = 'trialing') AS trialing, " +
"sum(seats) AS seats, sumIf(mrr_cents, status != 'trialing') AS mrr " +
"sum(seats) AS seats, sumIf(mrr_cents, status = 'active') AS mrr " +
"FROM " + activeSubs() + " GROUP BY plan ORDER BY mrr DESC"
}
@@ -296,7 +303,7 @@ func orgCountSQL() string {
}
func perOrgSubsSQL() string {
return "SELECT org, sumIf(mrr_cents, status != 'trialing') AS mrr, sum(seats) AS seats, " +
return "SELECT org, sumIf(mrr_cents, status = 'active') AS mrr, sum(seats) AS seats, " +
"argMax(plan_name, mrr_cents) AS plan, argMax(category, mrr_cents) AS category, " +
"argMax(status, mrr_cents) AS status, min(first_ts) AS since " +
"FROM " + activeSubs() + " GROUP BY org"
+1
View File
@@ -29,6 +29,7 @@ func spec(t *testing.T) (map[string]any, []string) {
Logger: luxlog.New("test"),
OpenAPI: zip.OpenAPIConfig{Title: "cloud", Version: "v1.0.0"},
})
compose(app)
routes(app, &cloud.Service[core.State]{State: core.State{AdminOrg: "admin"}})
var live []string
-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{
+2 -1
View File
@@ -19,6 +19,7 @@ import (
func ctxWith(t *testing.T, headers map[string]string, fn func(c *zip.Ctx)) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
app.Get("/probe", func(c *zip.Ctx) error {
fn(c)
return c.NoContent(204)
@@ -27,7 +28,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)
}
+15
View File
@@ -0,0 +1,15 @@
package admission
import (
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// compose stands in for the composer. Production programs are built by
// cloud.App, which installs the principal enrichment once at the root before
// any route; a test that mounts this subsystem on a bare app owns that duty
// itself, exactly once, here. A test that sends no identity is unaffected —
// with nothing validated there is nothing to park — so anonymous cases still
// refuse, and principal-carrying cases reach the handler as they do in
// production.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
+9 -9
View File
@@ -57,7 +57,7 @@ import (
//
// THE RULE (per request, on a governed host in waitlist mode):
//
// carries a Hanzo API key (hk-/sk-/…) → allow (paid inference; possession-gated)
// carries a Hanzo API key (pk-/sk-) → allow (paid inference; possession-gated)
// exempt path (health/iam/waitlist) → allow
// unauthenticated → 302 waitlist (browser) / 401 (API)
// waitlist mode OFF (or host un-governed) → allow (c.Next)
@@ -155,7 +155,7 @@ func Enforce(cfg EnforceConfig) zip.Handler {
}
}
// MONEY-CRITICAL EXEMPTION: a request bearing a Hanzo API KEY (hk-/sk-/pk-/…)
// MONEY-CRITICAL EXEMPTION: a request bearing a Hanzo API KEY (pk-/sk-)
// is NEVER waitlist-gated. Paid inference on api.hanzo.ai authenticates by KEY
// POSSESSION + is metered downstream in the `ai` subsystem — SanitizeIdentity
// does NOT mint a session principal for an API key (auth_identity.go isAPIKey →
@@ -206,13 +206,13 @@ func bounce(c *zip.Ctx, waitlistURL string) error {
return c.NoContent(http.StatusFound)
}
// The Hanzo API-key families — a published key (pk-), a secret key (sk-), and hk-
// (sk- under an older name) — are cloud.APIKeyPrefixes, and this package READS that
// list rather than restating it. It used to hold its own copy "so admission stays
// self-contained (no cloud-internal import)", which was never true: waitlist.go in
// this same package already imports cloud. So the copy bought nothing and cost the
// one thing a copy always costs — a second place to edit, with the two agreeing only
// by hand. A key family added to the authority now reaches this gate by construction.
// The Hanzo API-key families — a published key (pk-) and a secret key (sk-) — are
// cloud.APIKeyPrefixes, and this package READS that list rather than restating it.
// It used to hold its own copy "so admission stays self-contained (no
// cloud-internal import)", which was never true: waitlist.go in this same package
// already imports cloud. So the copy bought nothing and cost the one thing a copy
// always costs — a second place to edit, with the two agreeing only by hand. A key
// family added to the authority now reaches this gate by construction.
// carriesAPIKey reports whether the request authenticates with a Hanzo API key —
// in the Authorization header (Bearer or Basic-username) or the common api-key /
+10 -8
View File
@@ -38,6 +38,7 @@ func gateApp(t *testing.T, approvalStatus string) *zip.App {
}, time.Minute)
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai", Approvals: approvals, Gate: testGate}))
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
return app
@@ -46,7 +47,7 @@ func gateApp(t *testing.T, approvalStatus string) *zip.App {
type greq struct {
host, path, user, org, accept string
admin, approvedHdr, setApprov bool
authorization string // raw Authorization header (e.g. "Bearer hk-…")
authorization string // raw Authorization header (e.g. "Bearer sk-…")
apiKeyHeader string // raw api-key header value
}
@@ -79,7 +80,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)
}
@@ -150,11 +151,11 @@ func TestRule_UnauthenticatedBrowser_BouncedToWaitlist(t *testing.T) {
// inference cluster-wide.
func TestRule_APIKeyInference_NeverGated(t *testing.T) {
app := gateApp(t, "pending")
// The three families cloud actually mints, mirroring APIKeyPrefixes in
// auth_identity.go. fw_ and hz_ were dropped there as dead entries that were
// never minted and only widened what counts as a credential, so asserting them
// here would push that surface back open.
for _, key := range []string{"hk-43f50b6b", "sk-hz-abc", "pk-hz-obs"} {
// The two families cloud actually mints, mirroring APIKeyPrefixes in
// auth_identity.go. Every other spelling was dropped there as a dead entry that
// was never minted and only widened what counts as a credential, so asserting
// one here would push that surface back open.
for _, key := range []string{"sk-hz-abc", "pk-hz-obs"} {
// The exact paid-inference shape: Bearer key, JSON accept, NO session/user, on a
// GATED host — the exemption, not mode, must carry it through.
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/embeddings"} {
@@ -168,7 +169,7 @@ func TestRule_APIKeyInference_NeverGated(t *testing.T) {
}
}
// The api-key / x-api-key header form is exempt too.
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/v1/chat/completions", accept: "application/json", apiKeyHeader: "hk-headerform"})
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/v1/chat/completions", accept: "application/json", apiKeyHeader: "sk-headerform"})
if code != 200 {
t.Fatalf("api-key header inference = %d, want 200", code)
}
@@ -214,6 +215,7 @@ func TestRule_ForwardHeaderApproved_ThroughWithoutLookup(t *testing.T) {
// host, so Enforce never gates pre-boot.
func TestEnforce_DefaultGate_FailsOpenPreBoot(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai",
Approvals: newApprovalsWithLookup(func(context.Context, string, string) (string, bool) { return "pending", true }, time.Minute)}))
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
+5 -3
View File
@@ -348,9 +348,11 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// gated user can still resolve mode.
//
// 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())
// back to reaches it from that context. Whoever composes the app parks it there,
// at the root, ahead of every leaf; this surface installs no middleware of its
// own. One that it installed for itself could only hang on a /v1/flags/waitlist
// node, and the leaf below registers through the root, so that node would carry
// middleware over an empty subtree and zip refuses to compose it.
zip.Get(cloud.ZipApp(app), "/v1/flags/waitlist", waitlistOps{}.mode)
log.Info("admission gate ready", "services", n)
return nil
+2 -1
View File
@@ -24,6 +24,7 @@ import (
func mountGate(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Brand: "hanzo"}); err != nil {
t.Fatalf("Mount: %v", err)
}
@@ -39,7 +40,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
@@ -8,7 +8,7 @@ import (
func init() {
zip.Describe("GET /v1/flags/waitlist", zip.Doc{
Description: "WaitlistMode reports whether ONE host is currently gated by the launch waitlist.\nIt resolves the host to the service that governs it and reads that service's\nwaitlist switch, so a guard sitting in front of a hosted surface can decide in one\ncall whether to show the waitlist or the product. It answers for the ONE host\nasked about and never enumerates the registry, which is why it needs no\ncredential. It FAILS OPEN: an unregistered host, an unmounted registry and a store\nfault all answer known=false with mode=false, so a request is never gated pre-boot\nor on a registry fault.",
Description: "Reports whether ONE host is currently gated by the launch waitlist.\nIt resolves the host to the service that governs it and reads that service's\nwaitlist switch, so a guard sitting in front of a hosted surface can decide in one\ncall whether to show the waitlist or the product. It answers for the ONE host\nasked about and never enumerates the registry, which is why it needs no\ncredential. It FAILS OPEN: an unregistered host, an unmounted registry and a store\nfault all answer known=false with mode=false, so a request is never gated pre-boot\nor on a registry fault.",
Fields: map[string]string{
"waitlistModeView.host": "Host is the queried host, normalized (lowercased, port stripped).",
"waitlistModeView.known": "Known is false when no registered service claims this host, or when the\nregistry is unavailable — the guard then lets the request through, which is\nwhy the two cases answer alike.",
+5 -8
View File
@@ -119,14 +119,11 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// method are projected from. The exception is named at its registration below.
func routes(app cloud.Router, s *cloud.Service[state]) {
g := app.Group("/v1/ads")
// The Bridge FIRST, on the subtree ads owns: a typed op receives only a
// context, so the validated org has to be parked there, and fiber runs
// middleware in registration order — one installed after these leaves would
// never run. cloud.Listen installs one app-wide too; nesting is harmless (the
// inner one is what the handler sees), and having it here is what makes this
// package's own tests — which mount on a bare app — exercise the same
// tenancy the binary does.
g.Use(cloud.Bridge())
// A typed op receives only a context, so the validated org it reads is parked
// there by cloud.Bridge. This subsystem does not install it: the program's
// composer does, once at the root, after the identity check that mints the org
// and before any subsystem registers a route — an order only the composer can
// hold.
// Ops are declared ON THE GROUP: every zip.Router is an OpTarget, and the op's
// path is the group's prefix composed with the leaf — the identity every
+5 -7
View File
@@ -6,11 +6,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -33,11 +32,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "ads", dir)
db, err := sqlpool.Open("ads", dir)
if err != nil {
return nil, fmt.Errorf("open ads store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+11 -4
View File
@@ -35,13 +35,20 @@ var untypedByDesign = map[string]string{
"zip can declare a body-tolerant op.",
}
// compose installs what a HOST installs. A subsystem never installs cloud.Bridge
// (routes() says why): the program's composer does, once at the root. In a test
// the test IS the composer, so it owes the same install — skipping it does not
// test a stricter program, it tests one where every org-scoped op answers 403
// for a reason production could never produce.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
// mountApp mounts the ads surface on a fresh in-memory app with a temp store,
// exactly as the unified binary does — and, deliberately, with NO app-wide
// cloud.Bridge, so the bridge these ops read their tenant through has to be the
// one routes() installs itself.
// composed exactly as the unified binary is: cloud.Bridge at the root, the
// subsystem's routes beneath it.
func mountApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
t.Fatalf("Mount: %v", err)
}
@@ -65,7 +72,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)
}
+3
View File
@@ -56,6 +56,9 @@ func init() {
},
Example: json.RawMessage(`{"name":"Spring Launch","platform":"meta","objective":"conversions","budget":50000}`),
})
zip.Describe("POST /v1/ads/campaigns/:id/launch", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("PUT /v1/ads/campaigns/:id", zip.Doc{
Description: "Replaces the user-owned fields of one of the caller org's\ncampaigns and answers the stored row. It is a full replace, not a patch: every\nfield is written from the request, so an omitted one is cleared. externalId is\nlaunch-owned and is never touched here, so editing a campaign cannot break its\nlink to a live provider execution.",
Fields: map[string]string{
+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)
+5 -6
View File
@@ -9,11 +9,11 @@ import (
"fmt"
"strings"
// cek is the ONE opener; the ONE Hanzo SQLite driver registers "sqlite".
// sqlpool.Open is the ONE opener (cek + the single-connection cap); the ONE
// Hanzo SQLite driver registers "sqlite".
// Mirrors clients/referrals / clients/crm — one storage pattern.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -182,11 +182,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "affiliates", dir)
db, err := sqlpool.Open("affiliates", dir)
if err != nil {
return nil, fmt.Errorf("open affiliates store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+12 -3
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"
)
@@ -118,11 +118,20 @@ func init() {
// Mount wires POST /v1/agent (+ reads) into cloud, injecting the ai completion and
// the tool plane. The caller identity comes from cloud's validated principal.
func Mount(app *zip.App, deps cloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("agent.Mount: nil app")
}
_, err := hz.Mount(app, hz.Deps{
// hanzoai/agent registers TYPED ops, and the op registry lives on the concrete
// App — so this is the named hole (cloud.ZipApp), not a widened parameter. The
// signature stays the fleet's one MountFunc, and agent installs no app-wide
// middleware (hanzoai/agent calls Use nowhere), so it mounts SCOPED: taking the
// concrete type used to cost it the whole binary's middleware grant.
zapp := cloud.ZipApp(app)
if zapp == nil {
return fmt.Errorf("agent.Mount: router is not a zip app — the typed op registry is unreachable")
}
_, err := hz.Mount(zapp, hz.Deps{
Logger: deps.Logger,
DataDir: deps.DataDir,
Brand: deps.Brand,
+20 -12
View File
@@ -334,19 +334,27 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
mounted = s
o := agentOps{s: s}
// Bridge FIRST, and at the door this SUBSYSTEM is, not on one node inside it: 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.
//
// IT IS INSTALLED ON THE ROUTER, NOT ON THE /v1/agents GROUP, because this
// surface is not composed under that group. A group's middleware wraps the
// routes in its OWN subtree, and three quarters of this surface is registered
// somewhere else: the collection root and the two sub-planes go on the Router by
// absolute path (zip.Get(zapp, "/v1/agents"), mountSessions(s, app),
// mountTargets(s, app)) and only /metrics, /activity and the :ref leaves are
// composed beneath g. So a Bridge on g parked no org for /v1/agents/targets or
// /v1/agents/sessions, and every op there answered 403 "X-Org-Id required" to a
// request that carried one. Serve installs one app-wide, which is why serving
// was unaffected and only the tests — which Mount onto a bare app — could see
// it; a gate whose absence just one door down is invisible in production is the
g := app.Group("/v1/agents")
// Bridge FIRST, and at the TOP of the whole surface: 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. fiber runs middleware in registration order, so
// one installed further down never runs for the leaves above it: this used to
// sit inside mountTargets, below, which left every leaf registered before that
// call — this file's, mountSessions' — with no org on the context the moment
// they became typed ops. Serve installs one app-wide too; nesting is harmless
// (the inner one is what the handler sees) and the tests mount this subsystem
// on a bare app with no Serve, so the subsystem's own install is what makes
// them pass.
g.Use(cloud.Bridge())
// cloud.Bridge parks the validated org on the context a typed op receives; it
// is the composer's install — once at the root of every program — so this
// package does not install its own.
//
// The root of the surface. Declared on the App with its WHOLE path, not on the
// group with an empty leaf: joining "/v1/agents" with "" yields "/v1/agents/",
// a different path from the one these two have always served.
+2 -1
View File
@@ -88,6 +88,7 @@ func mountBilled(t *testing.T, commerceURL string, ai types.AIClient) *zip.App {
t.Fatalf("metering.New: %v", err)
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
// AIFallbackModel="best" arms the agent runner's failover so the retry/failover
// tests exercise the real escalation path; it never fires for a run whose model
// answers (or fails non-transiently), so the other billed tests are unaffected.
@@ -252,7 +253,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)
}
+8 -1
View File
@@ -15,6 +15,12 @@ import (
"github.com/zap-proto/zip"
)
// compose installs what the program's composer installs — cloud.Bridge, once at
// the app root. A subsystem never installs its own, so a test app owes the same
// root install; without it every org-scoped op answers a 403 no production
// program would produce.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
// mountApp mounts the agents surface with a deterministic fake AI so run() is
// exercised end-to-end over HTTP without a real gateway. Pass a nil interface
// to exercise the no-inference fail-closed path.
@@ -40,6 +46,7 @@ func mountAppDir(t *testing.T, dir string) *zip.App {
func mountAppIn(t *testing.T, dir string, ai types.AIClient, defaultModel string) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: dir, AI: ai, AIDefaultModel: defaultModel}); err != nil {
t.Fatalf("Mount: %v", err)
}
@@ -69,7 +76,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 -2
View File
@@ -203,8 +203,7 @@ func toEventView(e Event) eventView {
//
// The typed ops are declared on the GROUP, so each op's path is the group's
// prefix composed with its leaf — the same composition the router does, and the
// identity every projection keys on. cloud.Bridge is installed once, at the top
// of Mount, ahead of this call.
// identity every projection keys on.
func mountSessions(s *cloud.Service[state], app cloud.Router) {
o := sessionOps{s: s}
g := app.Group("/v1/agents")
+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 -7
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
@@ -624,12 +624,6 @@ type patchTargetIn struct {
// captured as a ref. The static /v1/agents/targets precedes /v1/agents/targets/:id.
func mountTargets(s *cloud.Service[state], app cloud.Router) {
g := app.Group("/v1/agents")
// cloud.Bridge is installed ONCE, at the top of Mount, ahead of every leaf on
// this prefix. It used to be installed here, which was too late for the leaves
// registered before this call: fiber runs middleware in registration order, so
// the sessions and agent-CRUD routes above would have had no org on the context
// the moment they became typed ops.
//
// TYPED ops, declared on the group itself: zip.Get and friends take any
// Router since v1.18.0, so the prefix is part of each op's path and every
// projection — the document, the MCP tool, the CLI command, the call plane —
+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.
+45 -23
View File
@@ -10,14 +10,14 @@ import (
func init() {
zip.Describe("DELETE /v1/agents/:ref", zip.Doc{
Description: "DeleteAgent removes an agent and every run recorded against it. Answers 204.",
Description: "Removes an agent and every run recorded against it. Answers 204.",
Fields: map[string]string{
"agentRef.ref": "Ref is the agent's public id (the agent_… handle create and list return) or\nits org-unique name, from the path. Either resolves the same agent.",
},
Example: json.RawMessage(`{"ref":"helper"}`),
})
zip.Describe("DELETE /v1/agents/targets/:id", zip.Doc{
Description: "DeleteTarget deregisters one machine. Only its owner, or an org admin, may\nremove it; an unknown id, a cross-org id and a machine owned by someone else\nall answer the same not-found, so a probe learns nothing about what exists.",
Description: "Deregisters one machine. Only its owner, or an org admin, may\nremove it; an unknown id, a cross-org id and a machine owned by someone else\nall answer the same not-found, so a probe learns nothing about what exists.",
Fields: map[string]string{
"targetDeleted.deleted": "Deleted is true when the target was removed.",
"targetDeleted.id": "ID is the target that was removed.",
@@ -26,20 +26,20 @@ func init() {
Example: json.RawMessage(`{"id":"tgt_1"}`),
})
zip.Describe("GET /v1/agents", zip.Doc{
Description: "ListAgents returns every agent defined in the caller's org, each with the\nnumber of runs recorded against it.",
Description: "Returns every agent defined in the caller's org, each with the\nnumber of runs recorded against it.",
Fields: map[string]string{
"agentList.agents": "Agents is the org's agents, each carrying its recorded run count.",
},
})
zip.Describe("GET /v1/agents/:ref", zip.Doc{
Description: "GetAgent returns one agent with its system prompt and its 20 most recent runs.\nThe ref is the agent's public id or its org-unique name — a created agent is\nimmediately gettable by whatever create handed back.",
Description: "Returns one agent with its system prompt and its 20 most recent runs.\nThe ref is the agent's public id or its org-unique name — a created agent is\nimmediately gettable by whatever create handed back.",
Fields: map[string]string{
"agentRef.ref": "Ref is the agent's public id (the agent_… handle create and list return) or\nits org-unique name, from the path. Either resolves the same agent.",
},
Example: json.RawMessage(`{"ref":"helper"}`),
})
zip.Describe("GET /v1/agents/:ref/runs", zip.Doc{
Description: "ListAgentRuns returns one agent's execution history, newest first — each run's\ninput, its output or its error, and how long it took. Every row is a run that\nactually happened.",
Description: "Returns one agent's execution history, newest first — each run's\ninput, its output or its error, and how long it took. Every row is a run that\nactually happened.",
Fields: map[string]string{
"runList.runs": "Runs is the agent's executions, newest first.",
"runsQuery.limit": "Limit caps how many runs come back, newest first. Absent, zero or out of\nrange (1..200) reads as 50.",
@@ -48,7 +48,7 @@ func init() {
Example: json.RawMessage(`{"ref":"helper","limit":20}`),
})
zip.Describe("GET /v1/agents/activity", zip.Doc{
Description: "AgentActivity serves the org-wide recent-activity feed. Events are REAL: each\nrecorded run is an invoked (ok) or failed (error) event; each agent's own\ncreate/update timestamps are created/updated events. Merged, newest first,\ncapped. Nothing is invented — an org with no agents and no runs gets [].",
Description: "Serves the org-wide recent-activity feed. Events are REAL: each\nrecorded run is an invoked (ok) or failed (error) event; each agent's own\ncreate/update timestamps are created/updated events. Merged, newest first,\ncapped. Nothing is invented — an org with no agents and no runs gets [].",
Fields: map[string]string{
"activityFeed.activity": "Activity is the merged run/create/update events, newest first, capped at 50.",
"activityView.agent": "agent name",
@@ -57,14 +57,14 @@ func init() {
},
})
zip.Describe("GET /v1/agents/builds", zip.Doc{
Description: "ListBuilds returns the public index of every published build, most recently\nupdated first, so a gallery can link straight to the story behind each product.\nPUBLIC, no tenancy: publishing is the author's act, and only published root\nsessions appear here.",
Description: "Returns the public index of every published build, most recently\nupdated first, so a gallery can link straight to the story behind each product.\nPUBLIC, no tenancy: publishing is the author's act, and only published root\nsessions appear here.",
Fields: map[string]string{
"buildList.builds": "Builds is every published build, most recently updated first.",
"buildsQuery.limit": "Limit caps the page. Absent, zero or over 500 reads as 100.",
},
})
zip.Describe("GET /v1/agents/builds/:org/:project", zip.Doc{
Description: "ReadBuild returns the readable build of one product: the agent session that\nproduced it, turn by turn — the prompts, the reasoning, the commits each turn\nproduced — plus the exact `git log` that re-derives every commit binding from\ngit itself, so nothing here has to be taken on trust.\n\nPUBLIC, no tenancy: it answers only for a session its author explicitly\npublished, which is what makes it safe to be anonymous. An unpublished session\nis invisible here no matter who asks; its owner reads it through the org-scoped\n/v1/agents/sessions routes, which need a validated principal.",
Description: "Returns the readable build of one product: the agent session that\nproduced it, turn by turn — the prompts, the reasoning, the commits each turn\nproduced — plus the exact `git log` that re-derives every commit binding from\ngit itself, so nothing here has to be taken on trust.\n\nPUBLIC, no tenancy: it answers only for a session its author explicitly\npublished, which is what makes it safe to be anonymous. An unpublished session\nis invisible here no matter who asks; its owner reads it through the org-scoped\n/v1/agents/sessions routes, which need a validated principal.",
Fields: map[string]string{
"buildRef.org": "Org is the org that published the build, from the path.",
"buildRef.project": "Project is the product's slug, from the path.",
@@ -73,7 +73,7 @@ func init() {
Example: json.RawMessage(`{"org":"hanzo","project":"landing"}`),
})
zip.Describe("GET /v1/agents/metrics", zip.Doc{
Description: "AgentMetrics serves the invocations-over-time histogram for the org's Agents\ndashboard. Every point is a REAL count of recorded runs in that time bucket —\none series line per agent that ran in the window. The Resource Usage rollup is\nall-null because this store meters no CPU/memory/storage/cost; the console\nrenders those as \"—\" rather than a fabricated figure. No runs => empty series\n(an honest \"not connected / no activity yet\"), never a synthesized trend.",
Description: "Serves the invocations-over-time histogram for the org's Agents\ndashboard. Every point is a REAL count of recorded runs in that time bucket —\none series line per agent that ran in the window. The Resource Usage rollup is\nall-null because this store meters no CPU/memory/storage/cost; the console\nrenders those as \"—\" rather than a fabricated figure. No runs => empty series\n(an honest \"not connected / no activity yet\"), never a synthesized trend.",
Fields: map[string]string{
"metricsQuery.range": "Range is the window to bucket: 24H, 7D or 30D. Anything else reads as 30D.",
"metricsView.range": "echoes the requested window (24H|7D|30D)",
@@ -85,7 +85,7 @@ func init() {
Example: json.RawMessage(`{"range":"7D"}`),
})
zip.Describe("GET /v1/agents/sessions", zip.Doc{
Description: "ListSessions returns the caller org's live sessions, newest first — each with\nits event count, its direct-child count and a one-line preview of its latest\nevent. With no filter it returns ROOT sessions only, so a dashboard shows one\nrow per flow rather than one per subagent; ?root= or ?parent= descends.",
Description: "Returns the caller org's live sessions, newest first — each with\nits event count, its direct-child count and a one-line preview of its latest\nevent. With no filter it returns ROOT sessions only, so a dashboard shows one\nrow per flow rather than one per subagent; ?root= or ?parent= descends.",
Fields: map[string]string{
"sessionList.sessions": "Sessions is the matching sessions, each with its event and child counts and\na one-line preview of its latest event.",
"sessionQuery.limit": "Limit caps the page. Absent, zero or over 500 reads as 100.",
@@ -102,7 +102,7 @@ func init() {
Example: json.RawMessage(`{"status":"running","limit":20}`),
})
zip.Describe("GET /v1/agents/sessions/:id", zip.Doc{
Description: "GetSession returns one session with its direct child sessions and its 50 most\nrecent events, oldest of those first.",
Description: "Returns one session with its direct child sessions and its 50 most\nrecent events, oldest of those first.",
Fields: map[string]string{
"sessionRef.id": "ID is the session to act on, from the path.",
"sessionView.host": "Execution context (mission-control): the machine/repo/cwd a card shows and\nthe run-target a session is dispatched to. Omitted when a surface didn't report it.",
@@ -114,7 +114,7 @@ func init() {
Example: json.RawMessage(`{"id":"sess_1"}`),
})
zip.Describe("GET /v1/agents/sessions/:id/control", zip.Doc{
Description: "DrainSessionControl returns the steering commands (pause/resume/stop/message)\nrecorded against the caller's own session that are newer than the cursor,\noldest first, with the cursor to poll from next. It is how a locally started\n`hanzo code` session — which is not task-backed, so nothing forwards its\ncommands to an execution engine — consumes what the dashboard posted. Read-only\nand bounded at 200 per poll, so a steady poll is cheap and an applied command is\nnever redelivered.",
Description: "Returns the steering commands (pause/resume/stop/message)\nrecorded against the caller's own session that are newer than the cursor,\noldest first, with the cursor to poll from next. It is how a locally started\n`hanzo code` session — which is not task-backed, so nothing forwards its\ncommands to an execution engine — consumes what the dashboard posted. Read-only\nand bounded at 200 per poll, so a steady poll is cheap and an applied command is\nnever redelivered.",
Fields: map[string]string{
"controlDrain.commands": "Commands is the session's control commands newer than the cursor, oldest first.",
"controlDrain.cursor": "Cursor is the seq to send as `after` on the next poll — the highest seq in\nthis page, or the cursor sent in when the page is empty.",
@@ -124,7 +124,7 @@ func init() {
Example: json.RawMessage(`{"id":"sess_1","after":12}`),
})
zip.Describe("GET /v1/agents/sessions/:id/tree", zip.Doc{
Description: "SessionTree returns the subagent-flow graph rooted at this session: the session,\nits children, their children, each node carrying its own event count. One\nindexed read pulls the whole flow (every node of a flow shares a root id), so\nthe shape is assembled in memory rather than by walking the store per node.",
Description: "Returns the subagent-flow graph rooted at this session: the session,\nits children, their children, each node carrying its own event count. One\nindexed read pulls the whole flow (every node of a flow shares a root id), so\nthe shape is assembled in memory rather than by walking the store per node.",
Fields: map[string]string{
"sessionRef.id": "ID is the session to act on, from the path.",
"sessionView.host": "Execution context (mission-control): the machine/repo/cwd a card shows and\nthe run-target a session is dispatched to. Omitted when a surface didn't report it.",
@@ -135,8 +135,11 @@ func init() {
},
Example: json.RawMessage(`{"id":"sess_1"}`),
})
zip.Describe("GET /v1/agents/sessions/stream", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("GET /v1/agents/targets", zip.Doc{
Description: "ListTargets returns every machine registered to the caller's org, newest\nfirst, each with its live session load.",
Description: "Returns every machine registered to the caller's org, newest\nfirst, each with its live session load.",
Fields: map[string]string{
"GPU.memory": "VRAM bytes, 0 = unknown",
"GPU.model": "\"GB10\", \"8060S\", \"RTX 4090\"",
@@ -153,7 +156,7 @@ func init() {
},
})
zip.Describe("GET /v1/agents/targets/:id", zip.Doc{
Description: "GetTarget returns one registered machine, with its live session load.",
Description: "Returns one registered machine, with its live session load.",
Fields: map[string]string{
"GPU.memory": "VRAM bytes, 0 = unknown",
"GPU.model": "\"GB10\", \"8060S\", \"RTX 4090\"",
@@ -171,15 +174,16 @@ func init() {
Example: json.RawMessage(`{"id":"tgt_1"}`),
})
zip.Describe("PATCH /v1/agents/:ref", zip.Doc{
Description: "UpdateAgent changes an agent in place. Every field is optional; a field the\nrequest omits keeps its stored value. The resulting mode+schedule are\nre-validated together, so a partial update can never leave a long-running\nagent without the cron the scheduler needs to fire it, and a transition INTO\nlong-running counts against the per-org cap on scheduled agents.",
Description: "Changes an agent in place. Every field is optional; a field the\nrequest omits keeps its stored value. The resulting mode+schedule are\nre-validated together, so a partial update can never leave a long-running\nagent without the cron the scheduler needs to fire it, and a transition INTO\nlong-running counts against the per-org cap on scheduled agents.",
Fields: map[string]string{
"updateAgentIn.ref": "Ref is the agent to update — its public id or org-unique name, from the path.",
},
Example: json.RawMessage(`{"ref":"helper","instructions":"be terse and cite sources"}`),
})
zip.Describe("PATCH /v1/agents/sessions/:id", zip.Doc{
Description: "PatchSession updates a session's surface-owned truth: its status, its title,\nthe run-target it is dispatched to, and the product it built plus whether that\nbuild's story is public. A FINISHED session stays finished — reopening a\ndone/error run would fabricate liveness — and publishing is refused unless the\nsession names the project it built, because the public build route is keyed on\n(org, project).",
Description: "Updates a session's surface-owned truth: its status, its title,\nthe run-target it is dispatched to, and the product it built plus whether that\nbuild's story is public. A FINISHED session stays finished — reopening a\ndone/error run would fabricate liveness — and publishing is refused unless the\nsession names the project it built, because the public build route is keyed on\n(org, project).",
Fields: map[string]string{
"patchSessionIn.cwd": "Cwd is where the session is working NOW.\n\nIt was write-once — captured at register and never again — which is right\nfor a run that starts in a directory and stays there, and wrong for a linked\nshell, which is a place a person moves around in. The console showed the\ndirectory `hanzo link` happened to be run from and kept showing it after the\nshell had walked away, so the field answered \"which work is this\" with an\nanswer that was true once. A pointer, so an unchanged path is an omitted\nfield rather than a repeated write.",
"patchSessionIn.id": "ID is the session to update, from the path.",
"patchSessionIn.project": "Project tags the product this session built; Published is the author's\ndecision to let anyone read the story (provenance.go). Both are pointers so\n\"absent\" and \"cleared\" are different requests.",
"patchSessionIn.target": "Target re-dispatches a session to a run-target (the #48 association). \"\" detaches.",
@@ -193,7 +197,7 @@ func init() {
Example: json.RawMessage(`{"id":"sess_1","status":"done"}`),
})
zip.Describe("PATCH /v1/agents/targets/:id", zip.Doc{
Description: "PatchTarget updates one machine in place. Every field is optional; a field the\nrequest omits is left alone. A metrics patch IS a heartbeat — the server stamps\nits own clock, so a client can neither forge nor backdate staleness.",
Description: "Updates one machine in place. Every field is optional; a field the\nrequest omits is left alone. A metrics patch IS a heartbeat — the server stamps\nits own clock, so a client can neither forge nor backdate staleness.",
Fields: map[string]string{
"GPU.memory": "VRAM bytes, 0 = unknown",
"GPU.model": "\"GB10\", \"8060S\", \"RTX 4090\"",
@@ -212,11 +216,14 @@ func init() {
Example: json.RawMessage(`{"id":"tgt_1","status":"draining"}`),
})
zip.Describe("POST /v1/agents", zip.Doc{
Description: "CreateAgent defines an agent in the caller's org: a model, a system prompt\n(instructions) and a set of tool names. The name must be unique in the org and\nmatch ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. An omitted model takes the\ndeployment's configured default; a named one is checked against the gateway's\nserved catalog, so a model this deployment never serves is refused here rather\nthan failing at run time. A long-running agent must carry a 5-field cron\nschedule (the scheduler would otherwise never fire it) and counts against a\nper-org cap on scheduled agents.",
Description: "Defines an agent in the caller's org: a model, a system prompt\n(instructions) and a set of tool names. The name must be unique in the org and\nmatch ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. An omitted model takes the\ndeployment's configured default; a named one is checked against the gateway's\nserved catalog, so a model this deployment never serves is refused here rather\nthan failing at run time. A long-running agent must carry a 5-field cron\nschedule (the scheduler would otherwise never fire it) and counts against a\nper-org cap on scheduled agents.",
Example: json.RawMessage(`{"name":"helper","model":"enso-flash","instructions":"be terse"}`),
})
zip.Describe("POST /v1/agents/:ref/run", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("POST /v1/agents/sessions", zip.Doc{
Description: "RegisterSession opens a live agent session in the caller's org — the row every\nsurface (the CLI's outer agent, hanzo.bot, the console, chat) hangs its\nactivity off. A session with a parentSessionId becomes a subagent of that\nsession and inherits its root, so one flow is one tree; without one it is\nitself a root. Registering with a terminal status records a session that has\nalready finished.",
Description: "Opens a live agent session in the caller's org — the row every\nsurface (the CLI's outer agent, hanzo.bot, the console, chat) hangs its\nactivity off. A session with a parentSessionId becomes a subagent of that\nsession and inherits its root, so one flow is one tree; without one it is\nitself a root. Registering with a terminal status records a session that has\nalready finished.",
Fields: map[string]string{
"registerReq.host": "Execution context — where this session runs (all optional).",
"registerReq.project": "The readable build (provenance.go): which product this session builds, and\nwhether its story may be read by the world.",
@@ -230,8 +237,23 @@ func init() {
},
Example: json.RawMessage(`{"agent":"hanzo-dev","title":"ship the landing page","host":"gpu-01"}`),
})
zip.Describe("POST /v1/agents/sessions/:id/events", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("POST /v1/agents/sessions/:id/message", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("POST /v1/agents/sessions/:id/pause", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("POST /v1/agents/sessions/:id/resume", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("POST /v1/agents/sessions/:id/stop", zip.Doc{
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
})
zip.Describe("POST /v1/agents/targets", zip.Doc{
Description: "RegisterTarget registers a machine as an agent target, or re-links one that is\nalready registered. Re-linking is idempotent and keyed on org+host+owner, so a\nmachine that reconnects refreshes its own row rather than piling up duplicates;\nit answers 200, while a first registration answers 201.",
Description: "Registers a machine as an agent target, or re-links one that is\nalready registered. Re-linking is idempotent and keyed on org+host+owner, so a\nmachine that reconnects refreshes its own row rather than piling up duplicates;\nit answers 200, while a first registration answers 201.",
Fields: map[string]string{
"GPU.memory": "VRAM bytes, 0 = unknown",
"GPU.model": "\"GB10\", \"8060S\", \"RTX 4090\"",
@@ -258,7 +280,7 @@ func init() {
Example: json.RawMessage(`{"id":"tgt_1"}`),
})
zip.Describe("POST /v1/agents/targets/:id/key", zip.Doc{
Description: "MintTargetClaimKey mints (or rotates) the claim key a `hanzo code --serve`\ndaemon presents to claim work for this machine, and returns it ONCE: only its\nSHA-256 hash is stored. Rotating supersedes any prior daemon, so only the\nmachine's owner — or an org admin — may call it; every other caller gets the\nsame not-found an unknown id gets, and learns nothing about what exists.",
Description: "Mints (or rotates) the claim key a `hanzo code --serve`\ndaemon presents to claim work for this machine, and returns it ONCE: only its\nSHA-256 hash is stored. Rotating supersedes any prior daemon, so only the\nmachine's owner — or an org admin — may call it; every other caller gets the\nsame not-found an unknown id gets, and learns nothing about what exists.",
Fields: map[string]string{
"claimKeyOut.claimKey": "ClaimKey is the capability itself. It is returned ONCE and never again — only\nits SHA-256 hash is stored — so a daemon that loses it mints a new one.",
"claimKeyOut.targetId": "TargetID is the machine the key authenticates.",
@@ -267,7 +289,7 @@ func init() {
Example: json.RawMessage(`{"id":"tgt_1"}`),
})
zip.Describe("POST /v1/agents/targets/:id/runs/:runId/report", zip.Doc{
Description: "ReportRoutedRun completes a claimed run: it delivers the terminal result to the\nrun's durable owner, which is what lets that workflow finish. Scoped to (org,\ntarget, run) and claim-key authenticated, so a machine can only ever report a\nrun it legitimately holds. Idempotent — a report for an unknown or\nalready-finished run answers delivered:false rather than failing, because the\nsession's terminal state was already set by the machine's own stream.",
Description: "Completes a claimed run: it delivers the terminal result to the\nrun's durable owner, which is what lets that workflow finish. Scoped to (org,\ntarget, run) and claim-key authenticated, so a machine can only ever report a\nrun it legitimately holds. Idempotent — a report for an unknown or\nalready-finished run answers delivered:false rather than failing, because the\nsession's terminal state was already set by the machine's own stream.",
Fields: map[string]string{
"reportOut.delivered": "Delivered is true when a waiting durable owner received this result. False\nmeans there was none to deliver to — an unknown or already-finished run — which\nis a clean no-op, not an error.",
"reportRunIn.branch": "Branch, CommitSha and Diffstat describe what the run produced; Error is the\nfailure when OK is false. Each is clamped, never rejected.",
+89 -11
View File
@@ -21,13 +21,15 @@ import (
"fmt"
aimod "github.com/hanzoai/ai"
webtools "github.com/hanzoai/ai/agent/builtin_tool/web"
aictl "github.com/hanzoai/ai/controllers"
aiobject "github.com/hanzoai/ai/object"
airouters "github.com/hanzoai/ai/routers"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/websearch"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// The MODEL API IS THE DOOR'S REGISTRY, and it is asked rather than described.
@@ -92,7 +94,15 @@ func aiProse() map[string]openapi.Said {
// Mount installs the money, ingest and telemetry wiring, then mounts ai. A nil
// callback is left alone — cloud leaves one nil exactly when that subsystem
// isn't co-resident, and the module's own fallback applies.
func Mount(app *zip.App, deps cloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
// The typed MCP op and hanzoai/ai's own mount both register on the concrete
// App, which cloud.ZipApp is the named hole for. ai's app-wide reach is
// DECLARED as Plugin.Global at its composition root — it is a policy fact, not
// something a parameter type should be able to grant on its own.
zapp := cloud.ZipApp(app)
if zapp == nil {
return fmt.Errorf("ai.Mount: router is not a zip app — the typed op registry is unreachable")
}
// One provider, one wire. cloud.Listen installed the process-global tracer
// provider before MountAll; DECLARE it to ai here so ai emits every gen_ai span
// through THAT provider instead of forking its own. Without this ai's
@@ -110,6 +120,79 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if cloud.TracerProviderInstalled() {
aiobject.AdoptHostTracerProvider()
}
// THE WEB, FOR EVERY RESPONSES-API AGENT.
//
// ai's builtin registry declares web_search / fetch_url / deep_research but holds
// no backend for the two this host serves — agent/builtin_tool/web must stay a
// leaf package (object imports agent, agent imports the registry), and websearch
// lives here in any case. This is where that seam is closed, beside the balance
// and tier readers, for the same reason they are here: this package links both
// sides and the host does not.
//
// In-process, never over api.hanzo.ai. The edge validates a CUSTOMER credential
// and answers 401 to a service; routing our own calls back through it is what
// once fail-closed every completion at 503 on a healthy pod.
//
// deep_research is deliberately NOT installed, and the reason is MONEY rather
// than plumbing.
//
// Research carries an explicit per-answer FEE — 25 cents, apps/answer/mode.go —
// charged through Bill.Gate on the request path, where a payer has been
// resolved and can be refused. A tool call has no payer. Installing this seam
// with a direct call to the engine would therefore be an unbilled 25-cent
// operation an agent may invoke in a loop: free inference, arrived at by the
// exact route this codebase keeps closing.
//
// That apps/answer makes it awkward is not an accident to route around:
// Params is built from request-scoped billing context and Sink's methods are
// unexported, so the money gate is structurally hard to bypass. Wiring this
// properly means giving the package an entry that takes a payer and charges
// it — a billing decision, not an adapter.
//
// The two tools above are different in kind, not merely cheaper: their HTTP
// routes gate on AUTHENTICATION (a validated principal or the service key),
// and the agent request that reaches this tool was already authenticated and
// metered at /v1/responses. Using them in-process is consistent with how they
// are reached over HTTP; deep_research is not.
//
// Until then the tool reports that it is unavailable in this deployment — the
// honest answer, and specifically NOT an empty result: an agent told "no
// results" concludes the web holds nothing on the subject and answers from
// memory in a confident voice.
webtools.SetSearch(func(ctx context.Context, query string, limit int) ([]webtools.SearchResult, error) {
hits := websearch.Search(ctx, query, "")
if limit > 0 && len(hits) > limit {
hits = hits[:limit]
}
out := make([]webtools.SearchResult, 0, len(hits))
for _, h := range hits {
out = append(out, webtools.SearchResult{Title: h.Title, URL: h.URL, Snippet: h.Content})
}
return out, nil
})
// 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
@@ -171,16 +254,11 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// can only ever refuse slightly early, which is the safe direction for a
// fail-closed gate. Nothing is billed from this number; it decides
// admission only.
a, err := bal.Amount.Parse()
cents, err := bal.Amount.FloorMinor()
if err != nil {
return 0, fmt.Errorf("plane balance read: %w", err)
}
minor := a.Minor() // big.Int of cents, truncated toward zero by Rescale
if !minor.IsInt64() {
return 0, fmt.Errorf("plane balance read: %s %s exceeds int64 cents",
bal.Amount.Decimal, bal.Amount.Currency)
}
return minor.Int64(), nil
return cents, nil
})
}
// The DEBIT crosses the same way, for the same reason — and it must key on the SAME
@@ -231,7 +309,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// The MCP door's inventory, registered BEFORE the wildcard below so the
// reading order is the routing order (see mcp.go — the router would pick the
// static path over All("/v1/*") either way).
mountMCP(app)
mountMCP(zapp)
// The door: ONE `app.All("/v1/*")` (hanzoai/ai mount.go) adapting the legacy
// beego ControllerRegister through zip.AdaptNetHTTP, so ai's ~200 real routes —
// /v1/chat/completions, /v1/models, /v1/messages and the rest — reach the wire
@@ -244,5 +322,5 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// projects routers.App's own table through it, so the published surface is ai's
// 192 paths rather than one wildcard. Typed request and response schemas for them
// are still work in github.com/hanzoai/ai, where those handlers live.
return aimod.Mount(app, deps)
return aimod.Mount(zapp, deps)
}
+12 -11
View File
@@ -20,25 +20,26 @@ import (
// DOWN, never up: rounding up would admit a request the balance cannot cover, and the
// debit that follows is exact — so the difference lands as a negative balance nobody
// authorized. Rounding down can only refuse slightly early.
func TestBalanceIsRoundedDownExplicitly(t *testing.T) {
//
// This used to be asserted by GREPPING ai.go for `a.Minor()` and a comment claiming it
// "truncates toward zero". It does not — money.Amount.Minor() is Rescale, which rounds
// HALF-AWAY-FROM-ZERO — so the test passed while the property it named was false, and
// 4.995 was admitted against a 5.00 charge. A test that reads the source can only
// confirm the code still says what it said; it cannot notice that the sentence is
// wrong. The arithmetic is asserted where the rounding now lives, plane/money_test.go.
// What is left here is the one thing only this package can say: that THIS gate still
// asks for the floored figure, and has not drifted back to the helper that refuses.
func TestBalanceGateDoesNotCallRefusingMinor(t *testing.T) {
src, err := os.ReadFile("ai.go")
if err != nil {
t.Fatalf("read ai.go: %v", err)
}
body := string(src)
// It must not call the refusing helper and hope.
if strings.Contains(body, "bal.Amount.Minor()") {
t.Error("Money.Minor() refuses sub-cent amounts — the gate must round explicitly")
}
// It must parse and take minor units itself, which truncates toward zero.
for _, want := range []string{"bal.Amount.Parse()", "a.Minor()", "minor.IsInt64()"} {
if !strings.Contains(body, want) {
t.Errorf("missing %q — the rounding choice must be visible at the call site", want)
}
}
// And it must not silently widen: an out-of-range balance is an error, not a clamp.
if !strings.Contains(body, "exceeds int64 cents") {
t.Error("an amount too large for int64 must error, never wrap into a wrong balance")
if !strings.Contains(body, "bal.Amount.FloorMinor()") {
t.Error("the balance gate must floor: rounding up admits spend the balance cannot cover")
}
}
+62 -94
View File
@@ -15,18 +15,18 @@ package ai
// and an inventory nobody can read is how a door that serves nothing passes for
// a healthy one.
//
// So this file adds exactly one op, and it reports THREE numbers that are three
// different questions, never one number standing in for all of them:
// So this file adds exactly one op, and it reports what THIS PROCESS's door
// actually carries — read from the live registry, never from a description of it.
//
// published every tool this BUILD can serve: the union of every subsystem's
// committed catalogue, the same plugin/<app>/mcp.json bytes the host
// hands zip at Load. A property of the artifact, true in any process.
// served — what THIS PROCESS's door actually composed. Read from the LIVE
// composition (App.Plugins + App.MCPTools), so a subsystem that did
// not mount is missing from it. This is the number that can be zero
// while `published` is nine hundred, and saying so is the whole point.
// local — the part of `served` this process registered itself, as opposed to
// composing from a child's catalogue.
// IT USED TO REPORT A THIRD NUMBER, `published`: every tool the BUILD could
// serve, summed over the committed plugin/<app>/mcp.json catalogues. Those files
// are gone. They were a second source for a fact each child already knows, and
// they were wrong — plugin/o11y/mcp.json held 12 tools while the o11y binary at
// the same commit served 365 — so the fleet-wide question is answered by ASKING
// the fleet now, at the one door, which also NAMES every subsystem it could not
// reach (package fleet). No process but the host can ask that question, and a
// subsystem inventing an answer to it is precisely the green surface this file
// was written against.
//
// A deployment manifest answers what was INTENDED; only the process answers what
// it LOADED, and during a rolling upgrade the two disagree by design (scope.go
@@ -39,20 +39,16 @@ package ai
// the PATH-derived id (get_v1_o11y_logs, get_v1_analytics_top), and a path lives
// under the subtree the manifest grants exactly one subsystem, so two defaults
// cannot meet; an op that DOES name itself escapes that, so the name is checked
// instead — zip refuses a Load whose catalogue claims a name another plugin
// already owns (a boot failure), and manifest's TestEveryCatalogueToolIsAnOpOfItsOwnApp
// turns that boot failure into a red build. A hand-written id in this package
// therefore carries its subsystem: aiMCPTools, never mcpTools.
// instead — the fleet door refuses to serve one name from two apps and logs both
// (fleet/mcp.go). A hand-written id in this package therefore carries its
// subsystem: aiMCPTools, never mcpTools.
import (
"context"
"encoding/json"
"sort"
"sync"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/plugin"
"github.com/zap-proto/zip"
)
@@ -90,52 +86,49 @@ func mountMCP(app *zip.App) {
zip.Get(app, "/v1/ai/mcp/tools", o.tools, zip.WithOperationID("aiMCPTools"))
}
// aiMCPQuery narrows the answer to one subsystem.
// aiMCPQuery asks for the tool names as well as the counts.
//
// A typed op's Go type name IS its schema name and the fleet's schema namespace
// is FLAT, so every name in this file carries the product prefix.
type aiMCPQuery struct {
// App names one subsystem whose tool NAMES to list. Empty answers counts
// only: nine hundred names is a page no operator reads and no model can
// afford to be handed by accident.
App string `json:"app"`
// Names asks for this process's tool NAMES and not only how many there are.
// Off by default: a list of names is a page, and the question this op exists
// to answer ("is the door up and does it have anything behind it") is answered
// by the count.
Names bool `json:"names"`
}
// aiMCPSurface is what the one MCP door carries, from this process's vantage.
type aiMCPSurface struct {
// Published is every tool this BUILD can serve — the union of every
// subsystem's committed catalogue, which is a property of the artifact and
// therefore the same answer in every process.
Published int `json:"published"`
// Served is what THIS PROCESS's door actually composed. It is the number that
// can be far smaller than Published — a host that mounted nothing serves
// nothing — and the only one that describes the door a client is talking to.
Served int `json:"served"`
// Local is the part of Served this process registered ITSELF, rather than
// composing from a mounted child's catalogue.
Local int `json:"local"`
// Apps is one row per subsystem the build publishes, in manifest order.
Apps []aiMCPApp `json:"apps"`
}
// aiMCPApp is one subsystem's contribution to the door.
type aiMCPApp struct {
// Name is the subsystem, as the manifest names it.
Name string `json:"name"`
// Tools is how many tools its committed catalogue publishes.
// Tools is how many tools THIS PROCESS's door carries: its own typed-op
// registry, projected. It is the only number a subsystem can state honestly —
// what the FLEET's door carries is a question only the host can ask, and it
// asks it by asking every subsystem (POST /v1/mcp, tools/list).
Tools int `json:"tools"`
// Served reports that THIS process actually mounted it, so its tools are on
// the door a client can call rather than only in the build.
Served bool `json:"served"`
// Names are its tool names, present only for the subsystem the query named.
// Apps is one row per subsystem this deployment composes, in manifest order.
Apps []aiMCPApp `json:"apps"`
// Names are this process's own tool names, present only when the query asked
// for them.
Names []string `json:"names,omitempty"`
}
// Tools reports what this binary's MCP door carries: every tool the build
// publishes, how many of them this process actually serves, and which subsystem
// each belongs to. It is the answer to "is the door up and does it have anything
// behind it" — a question a status code cannot answer, since an empty door and a
// full one are both 200.
// aiMCPApp is one subsystem, as this process sees it.
type aiMCPApp struct {
// Name is the subsystem, as the manifest names it.
Name string `json:"name"`
// Served reports that THIS process mounted it, so its tools are on this
// process's door rather than behind a sibling this process only knows the name
// of.
Served bool `json:"served"`
}
// Tools reports what THIS PROCESS's MCP door carries: how many tools its own
// registry projects, optionally their names, and which subsystems this process
// composed. It is the answer to "is this door up and does it have anything behind
// it" — a question a status code cannot answer, since an empty door and a full
// one are both 200. What the FLEET's door carries is the fleet door's own answer:
// POST /v1/mcp, tools/list, which asks every subsystem and names the ones that
// did not reply.
func (o mcpOps) tools(ctx context.Context, in *aiMCPQuery) (*aiMCPSurface, error) {
// A TOOL CALL IS AN API CALL. The gate is the op's own, read from the bit
// cloud.Bridge parked, so it holds identically over REST and over MCP — and
@@ -143,56 +136,31 @@ func (o mcpOps) tools(ctx context.Context, in *aiMCPQuery) (*aiMCPSurface, error
if !principal.ValidatedFrom(ctx) {
return nil, zip.ErrForbidden(mcpGate)
}
return surface(o.app, in.App), nil
return surface(o.app, in.Names), nil
}
// surface reads the door. The published half comes from the committed
// catalogues — the same bytes the host hands zip — and the served half from the
// live composition, never from a list of what was meant to mount.
func surface(app *zip.App, only string) *aiMCPSurface {
cat := published()
// surface reads the door: this process's own registry, and which subsystems it
// actually composed.
//
// Both halves come from the LIVE app — App.MCPTools and App.Plugins — never from
// a list of what was meant to mount, and never from an artifact. There is no
// build-time half left to disagree with them.
func surface(app *zip.App, names bool) *aiMCPSurface {
tools := app.MCPTools()
mounted := map[string]bool{}
for _, p := range app.Plugins() {
mounted[p.Name] = true
}
out := &aiMCPSurface{
Local: len(app.MCPTools()),
Apps: make([]aiMCPApp, 0, len(manifest.Apps)),
}
out.Served = out.Local
out := &aiMCPSurface{Tools: len(tools), Apps: make([]aiMCPApp, 0, len(manifest.Apps))}
for _, a := range manifest.Apps {
row := aiMCPApp{Name: a.Name, Tools: len(cat[a.Name]), Served: mounted[a.Name]}
if row.Served {
out.Served += row.Tools
out.Apps = append(out.Apps, aiMCPApp{Name: a.Name, Served: mounted[a.Name]})
}
if names {
out.Names = make([]string, 0, len(tools))
for _, t := range tools {
out.Names = append(out.Names, t["name"].(string))
}
if only != "" && only == a.Name {
row.Names = cat[a.Name]
}
out.Published += row.Tools
out.Apps = append(out.Apps, row)
sort.Strings(out.Names)
}
return out
}
// published is the build's catalogue — subsystem → its tool names — parsed ONCE.
// The bytes are the artifact each app's own binary projected from its own
// registry at build time (plugin/embed.go), so this reads what the door serves
// rather than a description of it.
var published = sync.OnceValue(func() map[string][]string {
out := make(map[string][]string, len(manifest.Apps))
for _, a := range manifest.Apps {
var tools []struct {
Name string `json:"name"`
}
if json.Unmarshal(plugin.Tools(a.Name), &tools) != nil {
continue // an app that has not been described yet publishes nothing
}
names := make([]string, 0, len(tools))
for _, t := range tools {
names = append(names, t.Name)
}
sort.Strings(names)
out[a.Name] = names
}
return out
})
+97 -231
View File
@@ -2,22 +2,24 @@
package ai
// mcp_test.go drives the REAL door, never a description of it.
// mcp_test.go drives ai's REAL door, never a description of it.
//
// The fleet's door is composed, not written: the host Loads every subsystem
// with the catalogue that subsystem's own binary projected from its own typed-op
// registry, and zip serves the union at one JSON-RPC endpoint. So the honest way
// to test it is to compose it — every manifest row, its real committed
// catalogue — and then ASK it. A remote mount (Plugin.Addr set) records the
// plugin and installs its catalogue without spawning anything (zip load.go), so
// the composition under test is the production one and the test costs no
// processes.
// It used to compose the WHOLE fleet here — every manifest row, loaded as a
// remote mount carrying that app's committed plugin/<app>/mcp.json — and assert
// the union. That composition is gone with the artifact: the fleet's door is no
// longer the concatenation of files this package can read, it is what the
// subsystems answer when the host asks them, and the only honest place to test
// that is against subsystems that are RUNNING (fleet/mcp_test.go, which starts
// real children on real sockets and goes red on a short list).
//
// Every assertion below reads a BODY. A tools/list that 200s with an empty array
// is the exact failure this fleet has shipped, and a status code cannot tell it
// from a full one.
// What remains here is what belongs here: ai's own op, on ai's own door, and the
// property that a tool call IS an API call — the same gate, the same words, both
// projections. Every assertion reads a BODY. A tools/list that 200s with an empty
// array is the exact failure this fleet has shipped, and a status code cannot
// tell it from a full one.
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -31,7 +33,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/plugin"
)
// door is the framework's default MCP path. This file asserts what is BEHIND the
@@ -39,37 +40,41 @@ import (
// owns, and pinning it here would be a second place for it to be written down.
const door = "/mcp"
// fleet composes a host exactly as cmd/cloud does — every manifest row, its real
// catalogue — minus the subsystems named in `without`, which is how a test
// UNMOUNTS one. Nothing is spawned: every plugin is a remote mount at an address
// no request in this file ever reaches, because none of these tests calls a tool
// that belongs to a child.
func fleet(t *testing.T, without ...string) *zip.App {
// served is ai's own op, mounted on its own door, with the identity boundary's
// carrier installed exactly as cloud.Listen installs it.
func served(t *testing.T) *zip.App {
t.Helper()
skip := map[string]bool{}
for _, n := range without {
skip[n] = true
}
app := zip.New(zip.Config{AppName: "cloud", Logger: luxlog.New("aimcptest"), DisableStartupMessage: true})
for i, a := range manifest.Apps {
// A co-resident app routes no prefix of its own, so there is nothing to
// mount — the same skip cmd/cloud's mount() makes.
if a.Coresident || skip[a.Name] {
continue
}
p := zip.Plugin{
Name: a.Name,
Addr: fmt.Sprintf("127.0.0.1:%d", 1+i), // never dialled: no test here calls a child's tool
Tools: plugin.Tools(a.Name),
}
if err := app.Add(zip.Load(p, a.Prefixes...)); err != nil {
t.Fatalf("compose %s: %v", a.Name, err)
}
}
app.Prepare()
app := zip.New(zip.Config{AppName: "ai", Logger: luxlog.New("aimcptest"), DisableStartupMessage: true})
app.Use(cloud.Bridge())
mountMCP(app)
return app
}
// rpc posts one JSON-RPC message to the door, optionally as a validated caller
// (the headers SanitizeIdentity mints), and returns the body.
func rpc(t *testing.T, app *zip.App, msg, user, org string) string {
t.Helper()
req := httptest.NewRequest(http.MethodPost, door, strings.NewReader(msg))
req.Header.Set("Content-Type", "application/json")
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("POST %s: %v", door, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("POST %s answered %d — the door must answer JSON-RPC, body: %s",
door, resp.StatusCode, trunc(string(b)))
}
return string(b)
}
// list asks the door for its tools and returns their names, in the order the
// door served them.
func list(t *testing.T, app *zip.App) []string {
@@ -89,6 +94,13 @@ func list(t *testing.T, app *zip.App) []string {
}
out := make([]string, 0, len(env.Result.Tools))
for _, tl := range env.Result.Tools {
// An op present with an EMPTY description is a SILENT failure: the model
// pays context for a nameless tool it cannot choose. That exact bug shipped
// once here (zipdoc blind to group prefixes), so it is a gate, not a hope.
if strings.TrimSpace(tl.Description) == "" {
t.Errorf("tool %q has an EMPTY description — the prose zipdoc lifts IS what a model "+
"reads to pick it. Write the doc comment and run: go generate -run zipdoc ./apps/ai/...", tl.Name)
}
if tl.Name == "" || len(tl.InputSchema) == 0 {
t.Errorf("a tool arrived with no name or no inputSchema: %+v", tl)
}
@@ -97,31 +109,6 @@ func list(t *testing.T, app *zip.App) []string {
return out
}
// rpc posts one JSON-RPC message to the door, optionally as a validated caller
// (the headers SanitizeIdentity mints), and returns the body.
func rpc(t *testing.T, app *zip.App, msg, user, org string) string {
t.Helper()
req := httptest.NewRequest(http.MethodPost, door, strings.NewReader(msg))
req.Header.Set("Content-Type", "application/json")
if user != "" {
req.Header.Set("X-User-Id", user)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("POST %s: %v", door, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("POST %s answered %d — the door must answer JSON-RPC, body: %s",
door, resp.StatusCode, trunc(string(b)))
}
return string(b)
}
func trunc(s string) string {
if len(s) > 400 {
return s[:400] + "…"
@@ -129,136 +116,6 @@ func trunc(s string) string {
return s
}
// TestTheDoorCarriesEverySubsystemsTools: the aggregation, measured.
//
// The number is the union of every subsystem's committed catalogue, and it is
// asserted EXACTLY — a door that lists nothing, or one that quietly drops an
// app, is a different number. The floor beside it is there because "exactly
// equal to a thing computed the same way" is satisfiable by two zeros.
func TestTheDoorCarriesEverySubsystemsTools(t *testing.T) {
app := fleet(t)
got := list(t, app)
want := 0
for _, a := range manifest.Apps {
want += len(published()[a.Name])
}
if len(got) != want {
t.Fatalf("the door served %d tools; the fleet's catalogues publish %d", len(got), want)
}
if want < 900 {
t.Fatalf("the fleet publishes only %d tools — a catalogue is missing or empty; "+
"regenerate: make -f mk/fleet.mk describe-apps", want)
}
// A name is dispatch, so two owners make it unroutable.
seen := map[string]bool{}
for _, n := range got {
if seen[n] {
t.Errorf("the door lists %q twice — a tool name is dispatch and cannot have two owners", n)
}
seen[n] = true
}
// Spot-check that a real subsystem's real op actually arrived, so this
// cannot pass on a list of the right size made of the wrong things.
for _, want := range []string{"get_v1_o11y_logs", "get_v1_analytics_top", "createOrganization"} {
if !seen[want] {
t.Errorf("the door does not carry %q", want)
}
}
t.Logf("the one door carries %d tools across %d subsystems", len(got), len(manifest.Apps))
}
// TestUnmountingASubsystemLeavesTheDoor: THE MUTATION, as a property.
//
// A registry that silently lists nothing is the defect this estate has shipped
// twice, and the reason it survived is that no test could tell a full door from
// an empty one. This one can: unmount o11y and its tools must be GONE — by count
// and by name — while every other subsystem's stay.
func TestUnmountingASubsystemLeavesTheDoor(t *testing.T) {
const gone = "o11y"
full := list(t, fleet(t))
cut := list(t, fleet(t, gone))
n := len(published()[gone])
if n == 0 {
t.Fatalf("%s publishes no tools, so unmounting it proves nothing — pick a subsystem that does", gone)
}
if len(full)-len(cut) != n {
t.Fatalf("unmounting %s changed the door by %d tools; its catalogue holds %d",
gone, len(full)-len(cut), n)
}
left := map[string]bool{}
for _, s := range cut {
left[s] = true
}
for _, name := range published()[gone] {
if left[name] {
t.Errorf("%s is unmounted but the door still lists its tool %q", gone, name)
}
}
// And the rest of the fleet is untouched: an unmount must not take a sibling
// with it.
for _, name := range published()["analytics"] {
if !left[name] {
t.Errorf("unmounting %s also lost analytics' tool %q", gone, name)
}
}
}
// TestTheInventoryAgreesWithTheDoor: the anti-green-surface gate.
//
// ai's op reports what the door carries. If it can report a number the door does
// not serve, it is exactly the instrument this fleet keeps mistaking for the
// mechanism. So it is measured AGAINST the door, on the same app, twice — whole,
// and with a subsystem unmounted.
func TestTheInventoryAgreesWithTheDoor(t *testing.T) {
for _, without := range [][]string{nil, {"o11y"}, {"o11y", "iam", "admin"}} {
app := fleet(t, without...)
if got, want := surface(app, "").Served, len(list(t, app)); got != want {
t.Errorf("without %v: the inventory says %d tools are served; the door serves %d",
without, got, want)
}
}
// Published is a property of the BUILD, so unmounting cannot move it — that
// is the whole reason the two numbers are two fields.
whole, cut := surface(fleet(t), ""), surface(fleet(t, "o11y"), "")
if whole.Published != cut.Published {
t.Errorf("unmounting a subsystem moved `published` (%d → %d) — it reports what the "+
"BUILD can serve, and only `served` reports what this process did",
whole.Published, cut.Published)
}
if whole.Served == cut.Served {
t.Errorf("unmounting a subsystem did NOT move `served` (%d) — then it is not reading "+
"the live composition", whole.Served)
}
// The row for an unmounted subsystem says so, and still reports what it would
// have contributed.
for _, row := range cut.Apps {
if row.Name != "o11y" {
continue
}
if row.Served {
t.Error("o11y is unmounted and its row says served")
}
if row.Tools != len(published()["o11y"]) {
t.Errorf("o11y's row publishes %d tools; its catalogue holds %d", row.Tools, len(published()["o11y"]))
}
}
}
// ── the gate ────────────────────────────────────────────────────────────────
// served is ai's own op, mounted on its own door, with the identity boundary's
// carrier installed exactly as cloud.Listen installs it.
func served(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{AppName: "ai", Logger: luxlog.New("aimcptest"), DisableStartupMessage: true})
app.Use(cloud.Bridge())
mountMCP(app)
app.Prepare()
return app
}
// get drives the REST projection of the same op.
func get(t *testing.T, app *zip.App, user string) (int, string) {
t.Helper()
@@ -267,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)
}
@@ -343,19 +200,17 @@ func TestAToolCarriesItsOpsGateExactly(t *testing.T) {
if err := json.Unmarshal([]byte(text), &got); err != nil {
t.Fatalf("the tool result is not the op's Out: %v\ntext: %s", err, trunc(text))
}
want := surface(app, "")
if got.Published != want.Published || got.Served != want.Served || got.Local != want.Local {
t.Fatalf("the tool answered {published:%d served:%d local:%d}; the op answers {published:%d served:%d local:%d}",
got.Published, got.Served, got.Local, want.Published, want.Served, want.Local)
if want := surface(app, false); got.Tools != want.Tools || len(got.Apps) != len(want.Apps) {
t.Fatalf("the tool answered {tools:%d apps:%d}; the op answers {tools:%d apps:%d}",
got.Tools, len(got.Apps), want.Tools, len(want.Apps))
}
if got.Published < 900 || len(got.Apps) != len(manifest.Apps) {
t.Fatalf("the tool's body is not the fleet's inventory: published=%d over %d rows",
got.Published, len(got.Apps))
if len(got.Apps) != len(manifest.Apps) {
t.Fatalf("the inventory names %d subsystems; the manifest holds %d", len(got.Apps), len(manifest.Apps))
}
// This process registered ai's op and nothing else, so its door serves
// exactly what it declared — and says so.
if got.Local != 1 || got.Served != 1 {
t.Errorf("this process registered 1 typed op; it reports local=%d served=%d", got.Local, got.Served)
if got.Tools != 1 {
t.Errorf("this process registered 1 typed op; it reports tools=%d", got.Tools)
}
// And the same op over HTTP, validated, answers the same body — one op, two
@@ -368,9 +223,43 @@ func TestAToolCarriesItsOpsGateExactly(t *testing.T) {
if err := json.Unmarshal([]byte(body), &rest); err != nil {
t.Fatalf("the REST body is not the op's Out: %v", err)
}
if rest.Published != got.Published || rest.Served != got.Served {
t.Errorf("REST answered {published:%d served:%d}, MCP answered {published:%d served:%d}",
rest.Published, rest.Served, got.Published, got.Served)
if rest.Tools != got.Tools || len(rest.Apps) != len(got.Apps) {
t.Errorf("REST answered {tools:%d apps:%d}, MCP answered {tools:%d apps:%d}",
rest.Tools, len(rest.Apps), got.Tools, len(got.Apps))
}
}
// TestTheInventoryReadsTheLiveRegistry: the anti-green-surface gate, at the only
// scope a subsystem can honestly answer for.
//
// The op must report what THIS PROCESS's door actually carries, so registering a
// second typed op has to move the number. If it does not, the op is reading
// something other than the registry — which is precisely the instrument this
// fleet keeps mistaking for the mechanism, and the shape of the artifact that was
// just deleted.
func TestTheInventoryReadsTheLiveRegistry(t *testing.T) {
app := served(t)
one := surface(app, true)
if got := len(list(t, app)); one.Tools != got {
t.Fatalf("the inventory says %d tools; the door serves %d", one.Tools, got)
}
if len(one.Names) != one.Tools {
t.Fatalf("names=%v does not match tools=%d", one.Names, one.Tools)
}
if len(one.Names) == 0 || one.Names[0] != "aiMCPTools" {
t.Fatalf("names=%v, want the op this process registered", one.Names)
}
// A SECOND op on the same app: the number moves, or nothing is being read.
type probeIn struct {
X string `json:"x"`
}
zip.Get(app, "/v1/ai/mcp/probe", func(context.Context, *probeIn) (*probeIn, error) { return nil, nil },
zip.WithOperationID("aiMCPProbe"), zip.WithSummary("a second op, to prove the count is read and not remembered"))
two := surface(app, true)
if two.Tools != one.Tools+1 {
t.Fatalf("registering an op moved the inventory from %d to %d — it is not reading the live registry",
one.Tools, two.Tools)
}
}
@@ -388,26 +277,3 @@ func TestAiIsOnItsOwnDoor(t *testing.T) {
"path-derived namespace and must name its owner", names[0])
}
}
// TestOneNameOneOwner: the namespace holds across the whole fleet.
//
// zip refuses a Load whose catalogue claims a name another plugin already owns —
// a BOOT failure. fleet() performs that composition for real, over every
// manifest row, so a collision fails this file before any assertion runs. This
// test states the invariant the composition proves, and names the count so a
// silently emptied catalogue cannot satisfy it.
func TestOneNameOneOwner(t *testing.T) {
owner := map[string]string{}
for _, a := range manifest.Apps {
for _, name := range published()[a.Name] {
if held, dup := owner[name]; dup {
t.Errorf("tool %q is claimed by both %q and %q", name, held, a.Name)
}
owner[name] = a.Name
}
}
if len(owner) < 900 {
t.Fatalf("only %d distinct tool names across the fleet", len(owner))
}
t.Logf("%d distinct tool names, one owner each", len(owner))
}
+7 -10
View File
@@ -8,17 +8,14 @@ import (
func init() {
zip.Describe("GET /v1/ai/mcp/tools", zip.Doc{
Description: "Tools reports what this binary's MCP door carries: every tool the build\npublishes, how many of them this process actually serves, and which subsystem\neach belongs to. It is the answer to \"is the door up and does it have anything\nbehind it\" — a question a status code cannot answer, since an empty door and a\nfull one are both 200.",
Description: "Tools reports what THIS PROCESS's MCP door carries: how many tools its own\nregistry projects, optionally their names, and which subsystems this process\ncomposed. It is the answer to \"is this door up and does it have anything behind\nit\" — a question a status code cannot answer, since an empty door and a full\none are both 200. What the FLEET's door carries is the fleet door's own answer:\nPOST /v1/mcp, tools/list, which asks every subsystem and names the ones that\ndid not reply.",
Fields: map[string]string{
"aiMCPApp.name": "Name is the subsystem, as the manifest names it.",
"aiMCPApp.names": "Names are its tool names, present only for the subsystem the query named.",
"aiMCPApp.served": "Served reports that THIS process actually mounted it, so its tools are on\nthe door a client can call rather than only in the build.",
"aiMCPApp.tools": "Tools is how many tools its committed catalogue publishes.",
"aiMCPQuery.app": "App names one subsystem whose tool NAMES to list. Empty answers counts\nonly: nine hundred names is a page no operator reads and no model can\nafford to be handed by accident.",
"aiMCPSurface.apps": "Apps is one row per subsystem the build publishes, in manifest order.",
"aiMCPSurface.local": "Local is the part of Served this process registered ITSELF, rather than\ncomposing from a mounted child's catalogue.",
"aiMCPSurface.published": "Published is every tool this BUILD can serve — the union of every\nsubsystem's committed catalogue, which is a property of the artifact and\ntherefore the same answer in every process.",
"aiMCPSurface.served": "Served is what THIS PROCESS's door actually composed. It is the number that\ncan be far smaller than Published — a host that mounted nothing serves\nnothing — and the only one that describes the door a client is talking to.",
"aiMCPApp.name": "Name is the subsystem, as the manifest names it.",
"aiMCPApp.served": "Served reports that THIS process mounted it, so its tools are on this\nprocess's door rather than behind a sibling this process only knows the name\nof.",
"aiMCPQuery.names": "Names asks for this process's tool NAMES and not only how many there are.\nOff by default: a list of names is a page, and the question this op exists\nto answer (\"is the door up and does it have anything behind it\") is answered\nby the count.",
"aiMCPSurface.apps": "Apps is one row per subsystem this deployment composes, in manifest order.",
"aiMCPSurface.names": "Names are this process's own tool names, present only when the query asked\nfor them.",
"aiMCPSurface.tools": "Tools is how many tools THIS PROCESS's door carries: its own typed-op\nregistry, projected. It is the only number a subsystem can state honestly —\nwhat the FLEET's door carries is a question only the host can ask, and it\nasks it by asking every subsystem (POST /v1/mcp, tools/list).",
},
})
}
+28 -75
View File
@@ -26,7 +26,7 @@
//
// - LLM lens (REAL today): hanzo.cloud_usage, the live per-org usage ledger the
// cloud o11y path already writes (requests, tokens, spend, models, errors).
// - Web/commerce lens: event.event on the o11y-owned event plane — what this
// - Web/commerce lens: event.fact (signal='act') on the o11y-owned event plane — what this
// package's own doors ingest (as facts, landed by the sink in warehouse.go).
//
// The accepted batch is also handed to registered SINKS (forward.go) — apps/
@@ -62,9 +62,9 @@
// GET /v1/insights/health the insights surface is serving
// GET /v1/analytics/health subsystem health (datastore connectivity + lens tables)
//
// WRITE (the ingest doors — see doors, event.go)
// POST /v1/event the canonical wire (object | array | {batch:[…]})
// POST /v1/insights/e the PostHog wire — a second WIRE, not a second name
// WRITE (the ingest door — see doors, event.go)
// POST /v1/event the canonical wire (object | array | {batch:[…]});
// decodeEvent sniffs the PostHog wire here too
// POST /v1/event/:project/envelope|store the Sentry error wire, same door
//
// The six reads above /v1/analytics/health are TYPED ops, so each publishes its
@@ -92,7 +92,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/datastore"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/sites"
planeops "github.com/hanzoai/cloud/plane"
"github.com/hanzoai/types"
luxlog "github.com/luxfi/log"
@@ -123,11 +122,13 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
// build carries no per-subsystem state — analytics reads the shared warehouse. It
// records the informative mount line, installs the site-host ingest carve, and brings
// up the event sink.
// records the informative mount line and brings up the event sink.
func build(b cloud.Base) (state, error) {
b.Log.Info("analytics surface", "warehouse", "hanzo", "brand", b.Brand)
installHostCarve(b)
// The key→project resolver this door refuses without. The FALLBACK only:
// projects.Mount installs the in-process one when it shares this process, and
// currentKeyResolver prefers it.
SetFallbackKeyResolver(planeKeys{})
startSink(b.Log)
return state{}, nil
}
@@ -175,51 +176,6 @@ func Shutdown(context.Context) error {
return nil
}
// installHostCarve wires the published-site-host beacon ingest (the twin of base's
// sites.SetBaseHostHandler): a page served on a site host can POST its OWN analytics
// beacon to an ingest door and have it ingested onto the event plane under the site's
// resolved Org — the server-supplied, host-derived tenant, never a body/header claim.
//
// It goes STRAIGHT to the ANONYMOUS lane (publicIngest), and this is the honest
// description of the door rather than a policy applied to it: sites.Middleware runs
// BEFORE the identity boundary (serve.go — sites at 241, IdentityMiddleware at 267),
// so on a site host c.User()/c.Org() are still RAW client headers and NOTHING here can
// be vouched for. A published site is a public artifact and its beacons are anonymous
// by construction, so they get the anonymous capability: the pageview/error allowlist
// and the field projection (no revenue, no personId, no groupId, no property bag), the
// 50-event / 64 KiB bounds, the per-IP and per-peer rate caps, and the DNT gate.
//
// The Site's org is the anonymous TENANT, so a customer's own site analytics keep
// landing in the customer's org — the same host-derived tenant this host is already
// trusted for when the file plane serves its bytes and the Base carve serves its data.
// A caller wanting FULL capability presents a credential to api.hanzo.ai/v1/event,
// which sits behind the identity boundary where a credential can actually be checked.
//
// Gated by the SAME already-existing flag the anonymous ingest path uses —
// CLOUD_ANALYTICS_PUBLIC_CAPTURE (publicCaptureEnabled, default ON) — so a site
// host accepts its own beacons out of the box, and turning public capture off also
// removes this carve (a site host then 405s a beacon POST, unchanged). sites.Middleware
// gates the carve on method POST and on the exact path set handed to it here, so the
// authenticated GET read lenses are never hijacked.
//
// That set is doors (event.go) — the SAME list routes registers — so a site host
// carves exactly the doors an API host routes. sites is handed each path already
// bound to its handler, which is why it holds no path literal of its own: the map it
// looks a beacon up in IS the dispatch, so membership and wire are one decision and
// a door added or deleted tomorrow moves both surfaces at once.
func installHostCarve(b cloud.Base) {
if !publicCaptureEnabled() {
b.Log.Info("analytics public-host ingest carve disabled", "flag", publicCaptureEnv)
return
}
carve := make(map[string]func(string, *zip.Ctx) error, len(doors))
for _, d := range doors {
carve[d.path] = d.anon
}
sites.SetAnalyticsHost(carve)
b.Log.Info("analytics public-host ingest carve enabled", "flag", publicCaptureEnv, "doors", len(carve))
}
// zipdoc lifts the doc comment off each typed op and off each field of its In and
// Out 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
@@ -243,18 +199,10 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// A typed op receives ONLY a context, so the validated org has to be PARKED
// there — never carried as an In field, which is caller-supplied and would make
// a cross-tenant read something the caller asserts for itself. cloud.Bridge
// parks it, and it is installed FIRST because fiber runs middleware in
// registration order: one installed below a leaf never runs for that leaf.
//
// On a scoped mount Use installs it once per prefix the subsystem DECLARES
// (scope.go), which is why plugin/analytics/main.go now declares all six of
// this app's prefixes: with only the /v1/<name> default, the typed reads at
// /v1/errors and /v1/insights/* would sit outside every prefix this subsystem
// could gate. Serve installs one app-wide too; nesting is harmless, and the
// tests mount this subsystem on a bare app with no Serve, so the subsystem's
// own install is what makes them pass.
app.Use(cloud.Bridge())
// parks it, and the COMPOSER installs it, not this subsystem: the fused host
// once at its root (serve.go), and a plugin program's constructor likewise. An
// install here would hang middleware on prefixes with no routes beneath them,
// a program zip refuses to compose.
o := readOps{s: s}
// The read lenses, declared on the GROUP: each op's path is the prefix composed
// with its leaf — the same composition the router does, and the identity every
@@ -295,6 +243,11 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
app.Post(d.path, cloud.Handle(s, d.ingest))
}
// The tag that feeds the canonical door, on the same origin as the door
// (tag.go). GET, static, unauthenticated: it is the install path for a
// surface with no bundler, and the page supplies the key.
app.Get(tagPath, zip.AdaptNetHTTP(http.HandlerFunc(serveTag)))
// The Sentry error wire, on the SAME door: POST /v1/event/{project}/envelope|store.
// The project segment is variable, so the door's owner carries the route and
// relays to the o11y PROCESS over the plane socket (plane.ObsErrorPost). It
@@ -342,9 +295,9 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// has never served, and op.Path is the identity every projection reads.
zip.Get(cloud.ZipApp(app), "/v1/errors", o.errors)
// /v1/insights — console reads over the SAME engine. The PostHog-wire INGEST at
// /v1/insights/e is a door and is registered in the loop above. Flags live at
// /v1/flags.
// /v1/insights — console READS over the SAME engine, and nothing else: the
// PostHog-wire ingest that used to sit at /v1/insights/e is retired onto the one
// door, so this group registers no door of its own. Flags live at /v1/flags.
ig := app.Group("/v1/insights")
zip.Get(ig, "/health", o.insightsHealth)
zip.Get(ig, "/events", o.insightsEvents)
@@ -547,7 +500,7 @@ func (o readOps) overview(ctx context.Context, in *windowQuery) (*Overview, erro
}
s := o.s
// Ensure the ai-owned ledger table exists (idempotent, latched) so a fresh
// warehouse yields honest zeros, not an error. We NEVER create event.event —
// warehouse yields honest zeros, not an error. We NEVER create event.fact —
// the plane's DDL owner is hanzoai/o11y (exactly the stance this lens has
// always taken for tables it does not own).
if err := datastore.EnsureCloudUsage(ctx); err != nil {
@@ -567,7 +520,7 @@ func (o readOps) overview(ctx context.Context, in *windowQuery) (*Overview, erro
llm := buildLLMOverview(firstRow(llmRows))
// Web/commerce lens — one events query over the plane; degrades to honest-empty
// if event.event is absent (not yet provisioned) or errors. A pageview is
// if event.fact is absent (not yet provisioned) or errors. A pageview is
// kind='page' (the plane's discriminator, not a magic name) and revenue is the
// numeric read-back of the attributes entry the writer stamped (fact.go
// attributesOf), so the sum is the same fact forward-era rows carried in a
@@ -575,7 +528,7 @@ func (o readOps) overview(ctx context.Context, in *windowQuery) (*Overview, erro
ewhere, eargs := eventsWhere(org, start, end)
eventsSQL := "SELECT countIf(kind = 'page') AS pageviews, uniqExact(distinct_id) AS visitors, " +
"uniqExact(session_id) AS sessions, countIf(name = 'order_completed') AS orders, " +
"sum(toFloat64OrZero(attributes['revenue'])) AS revenue FROM " + eventsTable + " WHERE " + ewhere
"sum(toFloat64OrZero(attributes['revenue'])) AS revenue FROM " + factTable + " WHERE " + ewhere
eventsRows, eerr := datastore.Query(ctx, eventsSQL, eargs...)
eventsOK := eerr == nil
if eerr != nil {
@@ -700,10 +653,10 @@ func (o readOps) top(ctx context.Context, in *topQuery) (*Top, error) {
prodSQL := fmt.Sprintf("SELECT attributes['product_id'] AS productId, countIf(name = 'order_completed') AS orders, "+
"sum(toFloat64OrZero(attributes['revenue'])) AS revenue, sum(toUInt64OrZero(attributes['quantity'])) AS units "+
"FROM %s WHERE %s AND attributes['product_id'] != '' "+
"GROUP BY productId ORDER BY revenue DESC LIMIT %d", eventsTable, ewhere, limit)
"GROUP BY productId ORDER BY revenue DESC LIMIT %d", factTable, ewhere, limit)
prodRows, perr := datastore.Query(ctx, prodSQL, eargs...)
// Behavior lenses over event.event — WHERE people go / WHAT they look at
// Behavior lenses over event.fact's act rows — WHERE people go / WHAT they look at
// (topPages) and where they come FROM (topReferrers organic/referral,
// topSources campaigns). Each is ONE pageview breakdown that degrades to
// honest-empty if the events table is absent or the query errors — never a 500
@@ -787,7 +740,7 @@ type healthReport struct {
type healthLenses struct {
// LLM is the live per-org usage ledger lens (hanzo.cloud_usage).
LLM healthLens `json:"llm"`
// Events is the web/commerce lens (event.event), honest-empty until the
// Events is the web/commerce lens (event.fact, signal='act'), honest-empty until the
// collector emits.
Events healthLens `json:"events"`
}
@@ -845,7 +798,7 @@ func health(s *cloud.Service[state], c *zip.Ctx) error {
if connected {
res.Lenses = &healthLenses{
LLM: healthLens{Table: llmTable, Available: tableExists(ctx, llmTable)},
Events: healthLens{Table: eventsTable, Available: tableExists(ctx, eventsTable)},
Events: healthLens{Table: factTable, Available: tableExists(ctx, factTable)},
}
}
switch {
+29 -12
View File
@@ -34,6 +34,9 @@ func TestLLMWhereBindsOrgPositionally(t *testing.T) {
if strings.Contains(sql, org) {
t.Fatalf("org %q must NOT be interpolated into sql: %q", org, sql)
}
// hanzo.cloud_usage is the LLM LEDGER, not the event plane: it has no signal
// column and its tenant key is `organization`. So it keeps its own arg order —
// the plane's leading-tenant rule is about the plane's sort key, not a style.
if len(args) != 3 {
t.Fatalf("want 3 bound args (start,end,org), got %d: %v", len(args), args)
}
@@ -62,8 +65,19 @@ func TestEventsWhereBindsOrgPositionally(t *testing.T) {
if strings.Contains(sql, "maxpower") {
t.Fatalf("org must not be interpolated: %q", sql)
}
if got, ok := args[2].(string); !ok || got != "maxpower" {
t.Fatalf("org must be trailing bound arg, got %v", args[2])
// THE TENANT LEADS. org is the first bound value of every read on the plane,
// because it is the first column of the sort key — and the signal is the second,
// because it is the first column of the partition key. Anything that narrows
// further comes after both.
if got, ok := args[0].(string); !ok || got != "maxpower" {
t.Fatalf("org must be the FIRST bound arg, got %v", args[0])
}
if !strings.Contains(sql, "signal = ?") {
t.Fatalf("eventsWhere must bind the signal — one table means the signal is a "+
"predicate, and a lens that omits it reads every other signal as a product event: %q", sql)
}
if got, ok := args[1].(string); !ok || got != string(signalAct) {
t.Fatalf("signal must be the SECOND bound arg, got %v", args[1])
}
}
@@ -185,8 +199,8 @@ func TestBuildTopProductsHonestEmpty(t *testing.T) {
if tp.Items == nil || len(tp.Items) != 0 {
t.Fatalf("items must be an empty (non-nil) slice, got %#v", tp.Items)
}
if tp.Reason == "" || tp.Source != eventsTable {
t.Fatalf("must carry honest reason + source (%s), got %+v", eventsTable, tp)
if tp.Reason == "" || tp.Source != factTable {
t.Fatalf("must carry honest reason + source (%s), got %+v", factTable, tp)
}
}
@@ -212,11 +226,14 @@ func TestBreakdownSQLBindsOrgPositionally(t *testing.T) {
if !strings.Contains(sql, "time >= ? AND time < ?") {
t.Fatalf("time bounds must be parameterized: %q", sql)
}
if len(args) != 3 {
t.Fatalf("want 3 bound args (start,end,org), got %d: %v", len(args), args)
if len(args) != 4 {
t.Fatalf("want 4 bound args (org,signal,start,end), got %d: %v", len(args), args)
}
if got, ok := args[2].(string); !ok || got != org {
t.Fatalf("org must be the trailing bound arg verbatim, want %q got %v", org, args[2])
if got, ok := args[0].(string); !ok || got != org {
t.Fatalf("org must be the FIRST bound arg verbatim, want %q got %v", org, args[0])
}
if got, ok := args[1].(string); !ok || got != string(signalAct) {
t.Fatalf("signal must be the second bound arg, got %v", args[1])
}
if !strings.Contains(sql, "kind = 'page'") {
t.Fatalf("behavior lenses count only kind='page' rows (the plane's discriminator, "+
@@ -277,8 +294,8 @@ func TestBuildBreakdownPctShareOfTotal(t *testing.T) {
if b.Items[0].Pct != 60 || b.Items[1].Pct != 20 {
t.Fatalf("pct must be share of the in-window total: %v / %v", b.Items[0].Pct, b.Items[1].Pct)
}
if b.Source != "event.event" {
t.Fatalf("source want event.event (the plane table the lens reads), got %q", b.Source)
if b.Source != factTable {
t.Fatalf("source want %s (the plane's one fact table), got %q", factTable, b.Source)
}
}
@@ -293,8 +310,8 @@ func TestBuildBreakdownHonestEmpty(t *testing.T) {
if b.Items == nil || len(b.Items) != 0 {
t.Fatalf("items must be an empty (non-nil) slice, got %#v", b.Items)
}
if b.Reason == "" || b.Source != eventsTable {
t.Fatalf("must carry honest reason + source (%s), got %+v", eventsTable, b)
if b.Reason == "" || b.Source != factTable {
t.Fatalf("must carry honest reason + source (%s), got %+v", factTable, b)
}
}
+199
View File
@@ -0,0 +1,199 @@
/*! anon.js THE anonymous-identity chain. ONE implementation, three distributions.
*
* One browser is ONE person on every Hanzo surface, whichever client a page
* happens to have loaded. There were three implementations writing TWO keys
* `hz_anon_id` (the npm client, the hosted tag) and `hz_id` (hz.js) so the same
* visitor was several people depending on which snippet the surface shipped.
*
* The three call sites:
* 1. src/storage.ts the bundled npm client; IMPORTS this file.
* 2. hz.js the no-build script tag; INLINES the marked region.
* 3. hanzoai/cloud apps/analytics/tag.js the tag the door hosts at
* /v1/event.js; vendors this file and its tag.go serves the marked region
* with the tag as one asset, so the door holds no second copy either.
*
* (2) and (3) have no bundler and cannot import anything, which is why the chain
* lives in a file that is plain ES5 rather than in a .ts: the region between the
* BEGIN and END markers is COPIED VERBATIM, and src/anon.test.ts fails if hz.js's
* copy is so much as a byte different. Keep the region ES5, dependency-free,
* `hz`-prefixed (it is spliced into other people's scopes) and unformatted a
* reformat of one copy is a diff against the other.
*/
/* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
/** The ONE anonymous-id key, on every surface and in every distribution. */
var HZ_ANON_KEY = 'hz_anon_id'
/** hz.js used to write `hz_id` a SECOND identity space, so the one-paste tag
* and the npm client were two different people on one page. It is READ and never
* written: an id already in the wild is ADOPTED into the shared identity, because
* minting over one detaches a returning visitor from their own history. */
var HZ_ANON_LEGACY_KEY = 'hz_id'
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
* studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
* ORIGIN-scoped, which is what made one journey arrive as several strangers. */
var HZ_ANON_DOMAIN = 'hanzo.ai'
/** Two years, rewritten on every read, so the cookie rolls forward with the
* visitor instead of expiring two years after first touch. Safari caps a
* SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
* what keeps a returning Safari visitor: each read re-arms the 7-day window. */
var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
/** Last resort for a browser that refuses cookies AND localStorage: without it
* every event in a page load would mint an id of its own. */
var hzAnonMemo
/**
* hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
*
* It has to be v7, and this is the only minter any distribution may use. The
* session rollups on the event plane derive a session's start instant FROM THE ID
* and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
* is not merely unordered there it is DISCARDED, silently, and the rollup stays
* empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
*/
function hzUuidv7(now) {
var b = new Uint8Array(16)
var i
var c = typeof crypto !== 'undefined' ? crypto : undefined
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
var t = Math.floor(now === undefined ? Date.now() : now)
for (i = 5; i >= 0; i--) {
b[i] = t % 256
t = Math.floor(t / 256)
}
b[6] = 0x70 | (b[6] & 0x0f) // version 7
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
var h = ''
for (i = 0; i < 16; i++) {
h += (b[i] + 0x100).toString(16).slice(1)
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
}
return h
}
/** The cookie jar, or null wherever there is no document to read one from. */
function hzAnonJar() {
try {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
return document
} catch (e) {
return null // sandboxed frame with an opaque origin
}
}
/** localStorage, or null when the browser refuses it (Safari private mode). */
function hzAnonStore() {
try {
if (typeof window === 'undefined' || !window.localStorage) return null
return window.localStorage
} catch (e) {
return null
}
}
/** One stored value, or '' — a jar can read as well as refuse to. */
function hzAnonItem(store, name) {
try {
return (store && store.getItem(name)) || ''
} catch (e) {
return ''
}
}
/** The value of cookie `name`, or ''. */
function hzAnonCookie(name) {
var d = hzAnonJar()
if (!d) return ''
var parts = d.cookie.split(';')
for (var i = 0; i < parts.length; i++) {
var eq = parts[i].indexOf('=')
if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
var v = parts[i].slice(eq + 1).trim()
if (!v) continue
try {
return decodeURIComponent(v)
} catch (e) {
return v // not percent-encoded — take it as written
}
}
return ''
}
/** Writes `name` on the registrable domain, for as long as the browser allows. */
function hzAnonWrite(name, value) {
var d = hzAnonJar()
if (!d) return
var host = ''
var secure = false
try {
if (typeof window !== 'undefined' && window.location) {
host = window.location.hostname || ''
// A Secure cookie is refused outright by a non-secure origin, which would
// strand http://localhost dev on the localStorage path.
secure = window.location.protocol === 'https:'
}
} catch (e) {
/* location unreachable — write a host-only, non-secure cookie */
}
// encodeURIComponent leaves a UUID byte-identical while making any value that is
// not one unable to forge a `;` and inject an attribute.
var c = name + '=' + encodeURIComponent(value)
c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
// would be rejected and the whole cookie dropped, so it stays host-only there.
// Prefixing both sides with '.' matches the domain itself and its subdomains
// while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
c += '; Domain=' + HZ_ANON_DOMAIN
}
if (secure) c += '; Secure'
try {
d.cookie = c
} catch (e) {
/* cookies refused — localStorage still carries the id */
}
}
/**
* hzAnonId returns the stable anonymous id for this browser, '' during SSR.
*
* Resolution is strictly ADDITIVE every id that already exists is ADOPTED, and
* only a browser holding none of them is given a new one:
*
* cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
*
* Minting over an id resets a returning visitor and detaches them from their own
* history, so the order is the migration: the cookie is the shared home, the two
* localStorage keys are what the three implementations wrote before it existed,
* and each is read until nothing is left to adopt.
*
* localStorage keeps being written, so a rollback finds everyone where it left
* them, and a browser that refuses cookies still holds one id per origin.
*/
function hzAnonId() {
if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
var s = hzAnonStore()
var id =
hzAnonCookie(HZ_ANON_KEY) ||
hzAnonItem(s, HZ_ANON_KEY) ||
hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
hzAnonMemo ||
hzUuidv7()
hzAnonMemo = id
hzAnonWrite(HZ_ANON_KEY, id)
try {
if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
} catch (e) {
/* quota exhausted, or a private-mode jar that reads but refuses writes */
}
return id
}
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
export { hzAnonId, hzUuidv7 }
+192 -83
View File
@@ -9,6 +9,7 @@ package analytics
import (
"fmt"
"math"
"net/http"
"strings"
"testing"
@@ -49,7 +50,7 @@ const anonClick = `{"batch":[{"type":"event","event":"$click","distinctId":"anon
func TestAnonAutocapture_ClickAdmittedThroughThePublicDoor(t *testing.T) {
roomyRate(t)
app := mountApp(t)
code, body := doHost(t, app, "/v1/event", "", "", "hanzo.ai", anonClick)
code, body := postAnon(t, app, "/v1/event", anonClick, nil)
if code != http.StatusServiceUnavailable {
t.Fatalf("anonymous $click = %d (%s), want 503 ADMITTED — a logged-out interaction is "+
"the bulk of what a heatmap is drawn from, and it was being dropped behind a 200",
@@ -66,7 +67,7 @@ func TestAnonAutocapture_ClickAdmittedOnThePostHogWire(t *testing.T) {
app := mountApp(t)
body := `{"event":"$click","distinct_id":"anon-1",` +
`"properties":{"$current_url":"https://hanzo.ai/pricing","$pathname":"/pricing","$el":"nav/button[cta]"}}`
if code, got := doHost(t, app, "/v1/event", "", "", "insights.hanzo.ai", body); code != http.StatusServiceUnavailable {
if code, got := postAnon(t, app, "/v1/event", body, nil); code != http.StatusServiceUnavailable {
t.Fatalf("anonymous $click on the PostHog wire = %d (%s), want 503 ADMITTED", code, got)
}
}
@@ -84,12 +85,12 @@ func TestAnonAutocapture_StoresTheRealURL(t *testing.T) {
if len(out) != 1 || dropped != 0 {
t.Fatalf("admitPublic = %d admitted / %d dropped, want 1/0", len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("the admitted click must be routable — an unnamed track is dropped by the write core")
}
if f.org != publicTenant {
t.Fatalf("fact org = %q, want %q", f.org, publicTenant)
if f.org != "acme" {
t.Fatalf("fact org = %q, want %q", f.org, "acme")
}
if f.name != "$click" {
t.Fatalf("stored name = %q, want $click", f.name)
@@ -132,13 +133,7 @@ func TestAnonEventName_ArbitraryRefused(t *testing.T) {
"names, never a caller-chosen one", name)
continue
}
if code != http.StatusOK {
t.Errorf("anonymous event %q = %d (%s), want 200 all-dropped", name, code, got)
continue
}
if r := receipt(t, got); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("anonymous event %q receipt = %+v, want accepted:0 dropped:1", name, r)
}
refusedAnon(t, "anonymous event "+name, code, got)
}
}
@@ -157,9 +152,7 @@ func TestAnonEventName_CannotBuyAKind(t *testing.T) {
"admit a refused KIND", kind)
continue
}
if r := receipt(t, got); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("anonymous %s named $click receipt = %+v, want accepted:0 dropped:1", kind, r)
}
refusedAnon(t, "anonymous "+kind+" named $click", code, got)
}
}
@@ -178,7 +171,7 @@ func TestAnonAutocapture_NameIsTheServersNotTheCallers(t *testing.T) {
t.Fatalf("spelling %q stored as %q — the stored name must be the table's value, "+
"never the caller's bytes", wire, out[0].Event)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok || f.name != "$click" {
t.Fatalf("spelling %q normalized to %q (routable=%v), want $click", wire, f.name, ok)
}
@@ -217,7 +210,7 @@ func TestAnonAutocapture_VocabularyIsClosed(t *testing.T) {
if len(out) != 1 || dropped != 0 {
t.Fatalf("%q: admitted %d dropped %d, want 1/0", wire, len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok || f.name != stored {
t.Fatalf("%q normalized to %q (routable=%v), want %q", wire, f.name, ok, stored)
}
@@ -253,7 +246,7 @@ func TestAnonAutocapture_OnlyTheAnnotationCrosses(t *testing.T) {
}
// Through the real normalizer nothing the caller chose reaches the attributes map —
// the dictionary an unbounded anonymous bag would attack.
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
@@ -279,7 +272,7 @@ func TestAnonPageview_StillCarriesNoCallerName(t *testing.T) {
t.Fatalf("projected pageview carries name %q — the kind family must drop the caller's name "+
"and let resolveName supply it", out[0].Event)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok || f.name != "page_viewed" {
t.Fatalf("stored pageview name = %q (routable=%v), want the route's own page_viewed", f.name, ok)
}
@@ -312,7 +305,7 @@ func TestAnonError_NameIsNeverTheCallersExceptionClass(t *testing.T) {
t.Fatalf("class %.20q: admitted %d dropped %d, want 1/0 — an anonymous error still lands",
class, len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("class %.20q: admitted error must stay routable", class)
}
@@ -332,7 +325,7 @@ func TestAnonError_ClassStillGroupsTheIssue(t *testing.T) {
fact := func(class, msg string) fact {
e := foldException(CaptureEvent{Type: "error", Error: &Exception{Type: class, Message: msg}})
out, _ := admitPublic([]CaptureEvent{e})
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("class %q must stay routable", class)
}
@@ -341,13 +334,13 @@ func TestAnonError_ClassStillGroupsTheIssue(t *testing.T) {
// Same failure twice, then the SAME message under a different class — which isolates
// the class as the grouping input, since the message is held constant.
a, b, c := fact("TypeError", "cannot read x"), fact("TypeError", "cannot read x"), fact("RangeError", "cannot read x")
if a.fault == nil || a.fault.class != "TypeError" {
t.Fatalf("class did not survive into the fault body: %+v — grouping is built on it", a.fault)
if a.class != "TypeError" {
t.Fatalf("class did not survive onto the fact: %+v — grouping is built on it", a)
}
if a.fault.group != b.fault.group {
if a.issue != b.issue {
t.Error("the same failure got two groups — grouping is not deterministic")
}
if a.fault.group == c.fault.group {
if a.issue == c.issue {
t.Error("two classes share a group — the fingerprint stopped reading the class, which is " +
"the fact `name` no longer carries")
}
@@ -365,14 +358,14 @@ func TestAnonError_OversizeClassIsDropped(t *testing.T) {
if len(out) != 1 || dropped != 0 {
t.Fatalf("admitted %d dropped %d, want the error to still land", len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
if f.fault.class != "" {
t.Fatalf("stored class len %d — over %d must be dropped, never clipped", len(f.fault.class), maxClass)
if f.class != "" {
t.Fatalf("stored class len %d — over %d must be dropped, never clipped", len(f.class), maxClass)
}
if f.fault.group == "" {
if f.issue == "" {
t.Error("dropping the class must not cost the row its group — the message-shape fallback exists for this")
}
// A class at exactly the bound is a real class and must survive.
@@ -419,7 +412,7 @@ func TestAnonAnnotation_OversizeIsDropped(t *testing.T) {
t.Errorf("%s: reached the projection as %+v — an out-of-bounds annotation is not carried",
tc.what, out[0].Properties)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("%s: want routable", tc.what)
}
@@ -456,7 +449,7 @@ func TestAnonAnnotation_RealClientOutputFits(t *testing.T) {
Type: "event", Event: "$click",
Properties: map[string]any{"$el": label, "$path": trail, "$role": "button"},
}})
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
@@ -486,42 +479,41 @@ func TestAnonAnnotation_BoundsAreDerivedNotInvented(t *testing.T) {
}
}
// TestAnonError_RealOrgNeverTakesACallerChosenName is Red's probe, kept: the attack was
// not theoretical and its worst form went through the published-site host, where the
// projection's tenant is a REAL org rather than $public. Fifty distinct caller-chosen
// classes in ONE request — the batch ceiling, and at the documented rate caps
// 15 000 names/min from a single IP — must produce fifty rows all named `error`.
// ownerOrg is a REAL org — the projected lane files into one (a team guest writes
// into the org that invited it), which is what makes these rules load-bearing.
const ownerOrg = "hanzo"
// TestAnonError_RealOrgNeverTakesACallerChosenName is Red's probe, kept: the projected
// lane files into a REAL org (a team guest writes into the org that invited it), so a
// caller-chosen error class would mint cardinality in that org's ORDER BY key. Fifty
// distinct classes in ONE request — the batch ceiling — must produce fifty rows all
// named `error`.
//
// It asserts on the FACTS the write path emitted, not on the status code, because the
// door returned 200 both before and after: the whole bug lived past the receipt.
// Driven at the projection, which is where the rule lives: admitPublic decides what is
// admitted, normalize stamps the tenant and the name.
func TestAnonError_RealOrgNeverTakesACallerChosenName(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
w := fakeWarehouse(t)
app := firstPartyApp(t)
var b strings.Builder
b.WriteString(`{"batch":[`)
evs := make([]CaptureEvent, 0, maxPublicBatch)
for i := 0; i < maxPublicBatch; i++ {
if i > 0 {
b.WriteByte(',')
}
// Each one distinct, and long enough that a survivor is unmistakable.
fmt.Fprintf(&b, `{"type":"error","path":"/pricing","error":{"type":"RED-%d-%s","message":"boom"}}`,
i, strings.Repeat("N", 200))
evs = append(evs, CaptureEvent{
Type: "error", Path: "/pricing",
Error: &Exception{Type: fmt.Sprintf("RED-%d-%s", i, strings.Repeat("N", 200)), Message: "boom"},
})
}
b.WriteString(`]}`)
if code := postHost(t, app, "yadota.hanzo.ai", "/v1/event", b.String(), nil); code != http.StatusOK {
t.Fatalf("batch = %d, want 200 — the errors are admitted, they are just not caller-named", code)
}
if len(w.facts) != maxPublicBatch {
t.Fatalf("stored %d facts, want %d — the errors must still land", len(w.facts), maxPublicBatch)
admitted, dropped := admitPublic(evs)
if len(admitted) != maxPublicBatch || dropped != 0 {
t.Fatalf("admitted %d dropped %d, want %d/0 — the errors must still land",
len(admitted), dropped, maxPublicBatch)
}
names := map[string]int{}
for _, f := range w.facts {
for _, e := range admitted {
f, ok := normalize(ownerOrg, time.Now(), foldException(e))
if !ok {
t.Fatal("want routable")
}
names[f.name]++
if f.org != ownerOrg {
t.Fatalf("fact landed in %q, want the site's real org %q", f.org, ownerOrg)
t.Fatalf("fact landed in %q, want the real org %q", f.org, ownerOrg)
}
if strings.Contains(f.name, "RED-") || len(f.name) > 64 {
t.Fatalf("caller bytes reached `name`: %.60q (len %d)", f.name, len(f.name))
@@ -568,7 +560,7 @@ func TestAnonAutocapture_CarriesNoException(t *testing.T) {
t.Errorf("%s/%s: the projection carried an exception onto a row that is not a fault", tc.kind, tc.event)
}
// The fold runs AFTER the projection, exactly as ingestDecoded runs it.
f, ok := normalize(publicTenant, time.Now(), foldException(out[0]))
f, ok := normalize("acme", time.Now(), foldException(out[0]))
if !ok {
t.Fatalf("%s/%s: want routable", tc.kind, tc.event)
}
@@ -576,8 +568,8 @@ func TestAnonAutocapture_CarriesNoException(t *testing.T) {
t.Errorf("%s/%s: %d caller bytes reached attributes['$exception'] — an interaction is not a fault",
tc.kind, tc.event, len(v))
}
if f.fault != nil {
t.Errorf("%s/%s: a non-error row grew a fault body", tc.kind, tc.event)
if faulted(f) {
t.Errorf("%s/%s: a non-error row grew error columns", tc.kind, tc.event)
}
}
}
@@ -600,17 +592,17 @@ func TestAnonError_StillCarriesItsException(t *testing.T) {
t.Fatal("the projection dropped the exception from an ERROR — the fix over-reached and the " +
"anonymous error stream is now empty")
}
f, ok := normalize(publicTenant, time.Now(), foldException(out[0]))
f, ok := normalize("acme", time.Now(), foldException(out[0]))
if !ok {
t.Fatal("want routable")
}
if f.name != nameError {
t.Fatalf("stored name = %q, want the server's %q", f.name, nameError)
}
if f.fault == nil || f.fault.class != "TypeError" {
t.Fatalf("the class did not reach the fault body: %+v", f.fault)
if f.signal != signalError || f.class != "TypeError" {
t.Fatalf("the class did not reach the error columns: %+v", f)
}
if f.fault.group == "" {
if f.issue == "" {
t.Error("an anonymous error must still group into an issue")
}
if f.attributes["$exception"] == "" {
@@ -628,22 +620,25 @@ func TestAnonError_StillCarriesItsException(t *testing.T) {
// the door answered 200 before the fix and answers 200 after: the whole bug lived past
// the receipt.
func TestAnonAutocapture_NoExceptionReachesARealOrg(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
w := fakeWarehouse(t)
app := firstPartyApp(t)
body := fmt.Sprintf(
`{"batch":[{"type":"event","event":"$click","url":"https://yadota.hanzo.ai/pricing","path":"/pricing",`+
`"properties":{"$el":"nav/button[cta]"},"error":{"type":"TypeError","message":"%s","stack":"%s"}}]}`,
strings.Repeat("M", 22000), strings.Repeat("S", 10000))
if code := postHost(t, app, "yadota.hanzo.ai", "/v1/event", body, nil); code != http.StatusOK {
t.Fatalf("POST = %d, want 200 — the click is admitted, it just carries no fault", code)
admitted, dropped := admitPublic([]CaptureEvent{{
Type: "event", Event: "$click",
URL: "https://yadota.hanzo.ai/pricing", Path: "/pricing",
Properties: map[string]any{"$el": "nav/button[cta]"},
Error: &Exception{
Type: "TypeError",
Message: strings.Repeat("M", 22000),
Stack: strings.Repeat("S", 10000),
},
}})
if len(admitted) != 1 || dropped != 0 {
t.Fatalf("admitted %d dropped %d, want 1/0 — the click is admitted, it just carries no fault",
len(admitted), dropped)
}
if len(w.facts) != 1 {
t.Fatalf("stored %d facts, want 1", len(w.facts))
// The REAL pipeline order: the projection runs first, foldException second.
f, ok := normalize(ownerOrg, time.Now(), foldException(admitted[0]))
if !ok {
t.Fatal("want routable")
}
f := w.facts[0]
if f.org != ownerOrg {
t.Fatalf("fact landed in %q, want the site's real org %q", f.org, ownerOrg)
}
@@ -653,8 +648,8 @@ func TestAnonAutocapture_NoExceptionReachesARealOrg(t *testing.T) {
if v, ok := f.attributes["$exception"]; ok {
t.Fatalf("%d caller bytes reached a REAL org's attributes dictionary on an interaction row", len(v))
}
if f.fault != nil {
t.Error("an autocapture row grew a fault body in a real org")
if faulted(f) {
t.Error("an autocapture row grew error columns in a real org")
}
// The interaction itself must survive — the point of the lane is the heatmap.
if f.el.label != "nav/button[cta]" {
@@ -704,7 +699,7 @@ func TestAnonLane_CannotMintALensName(t *testing.T) {
} {
out, _ := admitPublic([]CaptureEvent{tc.ev})
for _, adm := range out {
f, ok := normalize(publicTenant, time.Now(), foldException(adm))
f, ok := normalize("acme", time.Now(), foldException(adm))
if !ok {
continue // unroutable is a drop, which is a pass
}
@@ -731,7 +726,7 @@ func TestAnonAutocapture_IsNotTheAdLensClick(t *testing.T) {
if len(out) != 1 {
t.Fatalf("%s must be admitted — it is the heatmap", n)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("%s must be routable", n)
}
@@ -751,3 +746,117 @@ func TestAnonAutocapture_IsNotTheAdLensClick(t *testing.T) {
}
}
}
// faulted reports whether a fact carries any of the ERROR columns. With one table the
// old question — "did this row grow a fault body?" — is answered by the columns rather
// than by a pointer, which is strictly the stronger assertion: a body could be present
// and empty, a column cannot.
func faulted(f fact) bool {
return f.signal == signalError || f.class != "" || f.issue != "" || len(f.frames) > 0
}
// ── the position ────────────────────────────────────────────────────────────
// TestAnonAutocapture_ThePositionCrosses: element identity says WHICH thing was clicked
// and never where on the page it sat, so a heat map cannot be drawn from the annotation
// alone. The bulk of what a heat map is made of is logged-out traffic, so the position
// has to survive THIS lane or it survives for a minority of clicks.
func TestAnonAutocapture_ThePositionCrosses(t *testing.T) {
out, _ := admitPublic([]CaptureEvent{{
Type: "event",
Event: "$click",
Properties: map[string]any{
"$el": "main/button[save]",
"$role": "button",
"$x": float64(640),
"$y": float64(1200),
"$target_fixed": false,
"$viewport_width": float64(1440),
"$viewport_height": float64(900),
},
}})
if len(out) != 1 {
t.Fatal("want 1 admitted event")
}
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
// The warehouse reads these off the attributes map (insights heatmap_mv), so the
// assertion is on the STORED strings, not on the projected bag.
for k, want := range map[string]string{
"$x": "640", "$y": "1200", "$target_fixed": "false",
"$viewport_width": "1440", "$viewport_height": "900",
} {
if got := f.attributes[k]; got != want {
t.Errorf("attributes[%q] = %q, want %q — a click with no position is a count, not a heatmap", k, got, want)
}
}
if f.el.label != "main/button[save]" {
t.Fatalf("the annotation stopped crossing: %+v", f.el)
}
}
// TestAnonAutocapture_PositionIsAClosedSet: admitting a second family must not open the
// bag. A caller's own key still cannot reach the dictionary, and a coordinate that is not
// a number is not a coordinate.
func TestAnonAutocapture_PositionIsAClosedSet(t *testing.T) {
out, _ := admitPublic([]CaptureEvent{{
Type: "event",
Event: "$click",
Properties: map[string]any{
"$el": "main/button",
"$x": float64(10),
"$viewport_width": "1440", // a string is not a coordinate
"$viewport_height": map[string]any{"nope": true},
"$scroll_depth": float64(99), // plausible, unnamed, therefore refused
"tenant_id": "maxpower",
},
}})
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
if f.attributes["$x"] != "10" {
t.Fatalf("the named coordinate did not cross: %v", f.attributes)
}
for _, k := range []string{"$viewport_width", "$viewport_height", "$scroll_depth", "tenant_id"} {
if _, bad := f.attributes[k]; bad {
t.Errorf("key %q reached the attributes dictionary: %v", k, f.attributes)
}
}
}
// TestAnonAutocapture_PositionIsFilteredNotClamped: a clamped coordinate is a click
// somewhere the visitor did not click, and a heat map is a picture of exactly that. Over
// the bound the key is dropped and the interaction still lands.
func TestAnonAutocapture_PositionIsFilteredNotClamped(t *testing.T) {
for _, tc := range []struct {
what string
x any
}{
{"absurdly deep", float64(1 << 24)},
{"absurdly negative", float64(-(1 << 24))},
{"not a number", math.NaN()},
{"infinite", math.Inf(1)},
} {
out, _ := admitPublic([]CaptureEvent{{
Type: "event",
Event: "$click",
Properties: map[string]any{"$el": "main/button", "$x": tc.x, "$y": float64(10)},
}})
if len(out) != 1 {
t.Fatalf("%s: the interaction itself must still land", tc.what)
}
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("%s: want routable", tc.what)
}
if v, bad := f.attributes["$x"]; bad {
t.Errorf("%s: out-of-bounds coordinate was stored as %q — the bound is a filter", tc.what, v)
}
if f.attributes["$y"] != "10" {
t.Errorf("%s: a sound coordinate beside a refused one was lost", tc.what)
}
}
}
+29 -92
View File
@@ -8,13 +8,9 @@
package analytics
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// anon_capability_test.go — the TRUST-LEVEL invariant, proven at EVERY door.
@@ -36,8 +32,13 @@ import (
//
// The observable, as everywhere in this package: 503 ⇒ the event was ADMITTED and
// reached requireDatastore (no warehouse in the harness) — i.e. it would have become a
// row. 200 with {accepted:0,dropped:N} ⇒ the projection refused it before the write
// core. 403 ⇒ refused at the gate. So "must not become a row" is exactly "must not 503".
// row. 401 `ingest_key_required` (refusedAnon, door_honesty_test.go) ⇒ the projection
// refused EVERY event before the write core. 403 ⇒ refused at the gate. So "must not
// become a row" is exactly "must not 503".
//
// That middle observable used to be `200 {accepted:0,dropped:N}`, and the 200 was the
// bug: a caller that lost everything read success. The refusal it records here is
// unchanged — only the door's answer is honest about it now (answer, event.go).
// commerceWire is the attack payload on the canonical/Segment wire: every field that
// poisons a revenue lens or binds an event to someone else's identity, in one event.
@@ -57,22 +58,6 @@ const commercePostHog = `{"event":"order_completed","distinct_id":"attacker",` +
// working on every door, so the fix is a capability drop and not a feature deletion.
const pageviewWire = `{"batch":[{"type":"pageview","distinctId":"anon-1","path":"/pricing"}]}`
// postHostBody is postHost (hostcarve_test.go) with the response body returned, so a
// site-host case can assert the honest {accepted,dropped} receipt and not just a status.
func postHostBody(t *testing.T, app *zip.App, host, path, body string) (int, []byte) {
t.Helper()
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)
if err != nil {
t.Fatalf("Test POST %s%s: %v", host, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// roomyRate installs anonymous counters big enough that no capability test can be
// masked by a 429 from a bucket another test in this package already spent. The rate
// cap itself is pinned by TestPublic_RateLimited / TestPublic_PeerCeiling.
@@ -88,60 +73,14 @@ func TestAnonCommerce_RefusedOnEveryBrandHost(t *testing.T) {
app := mountApp(t)
for _, host := range []string{"hanzo.ai", "api.hanzo.ai", "lux.network", "zoo.ngo", "pars.network", "bootno.de"} {
code, body := doHost(t, app, "/v1/event", "", "", host, commerceWire)
if code != http.StatusOK {
t.Errorf("anonymous commerce on brand host %q = %d (%s), want 200 all-dropped "+
"(a Host header must not name a tenant)", host, code, body)
continue
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("brand host %q receipt = %+v, want accepted:0 dropped:1", host, r)
}
refusedAnon(t, "anonymous commerce on brand host "+host+" (a Host header must not name a tenant)",
code, body)
}
}
// TestAnonCommerce_RefusedAtSiteHostDoor: the published-site carve is the second door
// that reached full capability with no credential. It ran BEFORE the identity boundary
// (serve.go mounts sites at 241, IdentityMiddleware at 267), so nothing there could
// vouch for a caller — yet it wrote into the site's REAL org whatever the body said.
// Anyone could aim it at any customer's org with a Host header.
//
// Before the fix all three paths answered 503 (admitted at full capability).
func TestAnonCommerce_RefusedAtSiteHostDoor(t *testing.T) {
roomyRate(t)
app := carveApp(t, "yadota")
for _, door := range doors {
code, body := postHostBody(t, app, "yadota.hanzo.app", door.path, commerceFor(t, door))
if code == http.StatusServiceUnavailable {
t.Errorf("site-host POST %s: reached the write core at FULL capability — "+
"a Host header alone let a stranger write revenue/groupId/personId into the site's org", door.path)
continue
}
if code != http.StatusOK {
t.Errorf("site-host POST %s = %d (%s), want 200 with an all-dropped receipt", door.path, code, body)
continue
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("site-host POST %s receipt = %+v, want accepted:0 dropped:1", door.path, r)
}
}
}
// TestAnonCommerce_RefusedOnBoundCustomDomain: the carve fires for a bound custom
// domain too, so that door needed the same drop.
func TestAnonCommerce_RefusedOnBoundCustomDomain(t *testing.T) {
roomyRate(t)
app := carveApp(t, "yadota")
code, body := postHostBody(t, app, "yadota.tech", "/v1/event", commerceWire)
if code == http.StatusServiceUnavailable {
t.Fatalf("custom-domain beacon reached the write core at FULL capability")
}
if code != http.StatusOK {
t.Fatalf("custom-domain anonymous commerce = %d (%s), want 200 all-dropped", code, body)
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Fatalf("custom-domain receipt = %+v, want accepted:0 dropped:1", r)
}
}
// The site-host carve is deleted, so "a Host header may not reach the write core" is
// no longer a rule this door enforces — there is no site-host door. apps/sites'
// TestSiteHostNeverIngests pins that a site host serves bytes and is terminal.
// TestAnonIdentity_RefusedAtEveryDoor: `identify` and `group` are the two kinds that
// bind an event to a named person and a named group. A caller nobody vouched for may
@@ -154,36 +93,34 @@ func TestAnonIdentity_RefusedAtEveryDoor(t *testing.T) {
// bodies used to be two canonical-wire literals applied to every door, which only
// worked while every door spoke that wire: the team door accepts a bare ARRAY and
// answers an object body 400, so a shared literal measured decoder tolerance rather
// than the projection. 400 would satisfy this test's INTENT even more strictly than
// 200-all-dropped — nothing is stored either way — but "refused because the kind is
// not writable anonymously" and "refused because the body is the wrong shape" are
// different facts, and this test is about the first one.
// than the projection. Both refusals are 4xx now and nothing is stored either way,
// but "refused because the kind is not writable anonymously" (401) and "refused
// because the body is the wrong shape" (400) are different facts, and this test is
// about the first one — which is exactly what asserting the CODE pins.
for _, pick := range []func(*testing.T, door) string{identifyFor, groupFor} {
for _, d := range doors {
body := pick(t, d)
code, got := doHost(t, app, d.path, "", "", "hanzo.ai", body)
if code != http.StatusOK {
t.Errorf("anonymous %s on %s = %d (%s), want 200 all-dropped", body, d.path, code, got)
continue
}
if r := receipt(t, got); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("anonymous %s on %s receipt = %+v, want accepted:0 dropped:1", body, d.path, r)
}
refusedAnon(t, "anonymous "+body+" on "+d.path, code, got)
}
}
}
// TestPublicCaptureOff_RefusesEveryAnonymousDoor: CLOUD_ANALYTICS_PUBLIC_CAPTURE is the
// ONE anonymous-capture switch and it still governs every door — including the two that
// used to route around the anonymous lane entirely (and therefore around this flag's
// only enforcement point).
func TestPublicCaptureOff_RefusesEveryAnonymousDoor(t *testing.T) {
t.Setenv(publicCaptureEnv, "off")
// TestAnonymousRefusedOnEveryDoor: a keyless beacon is refused on every door, with
// no switch to turn it back on. It used to be ACCEPTED into a reserved tenant and
// answered 200 — the switch that governed it defaulted ON, so the silent-accept was
// the shipped behaviour and only an operator who knew the flag existed could stop it.
// Attribution is the key now, so there is nothing left to gate.
func TestAnonymousRefusedOnEveryDoor(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, d := range doors {
if code, body := doHost(t, app, d.path, "", "", "hanzo.ai", pageviewFor(t, d)); code != http.StatusForbidden {
t.Errorf("public-capture-off anonymous %s = %d (%s), want 403", d.path, code, body)
code, body := doHost(t, app, d.path, "", "", "hanzo.ai", pageviewFor(t, d))
if code != http.StatusUnauthorized {
t.Errorf("anonymous %s = %d (%s), want 401", d.path, code, body)
}
if !strings.Contains(string(body), "ingest_key_required") {
t.Errorf("anonymous %s body = %s, want the ingest_key_required code", d.path, body)
}
}
}
+8 -14
View File
@@ -21,8 +21,8 @@ import (
// while answering 200.
//
// Same observable as anon_capability_test.go: 503 ⇒ ADMITTED (reached the write core,
// no warehouse in the harness); 200 {accepted:0,dropped:N} ⇒ the projection refused it;
// 403 ⇒ refused at the gate.
// no warehouse in the harness); 401 ingest_key_required ⇒ the projection refused every
// event; 403 ⇒ refused at the gate.
// ── 1. the canonical wire could not say what kind it was ─────────────────────
@@ -46,7 +46,7 @@ func TestAnonCanonicalWireCarriesItsKind(t *testing.T) {
{"batch envelope", `{"batch":[{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
code, body := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", tc.body)
code, body := postAnon(t, app, "/v1/event", tc.body, nil)
if code != http.StatusServiceUnavailable {
t.Fatalf("anonymous pageview on the %s shape = %d (%s), want 503 ADMITTED — "+
"all three published shapes of one wire must mean the same thing, or the "+
@@ -72,13 +72,7 @@ func TestAnonCanonicalWireStillCannotWidenItsKind(t *testing.T) {
"the wire may now NAME a kind; it may not ADMIT one", kind)
continue
}
if code != http.StatusOK {
t.Errorf("anonymous kind %q = %d (%s), want 200 all-dropped", kind, code, got)
continue
}
if r := receipt(t, got); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("anonymous kind %q receipt = %+v, want accepted:0 dropped:1", kind, r)
}
refusedAnon(t, "anonymous kind "+kind, code, got)
}
}
@@ -90,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)
}
@@ -101,9 +95,9 @@ func postAuth(t *testing.T, app *zip.App, path, auth, body string) (int, []byte)
// TestUnresolvableAccessKeyBearerRefuses: presented() names the carriers eventTenant
// consults so the two cannot disagree about what "presented" MEANS — and they did.
// ingestKey matches the bearer only for pk- (deliberately: an hk-/sk- bearer is IAM's
// ingestKey matches the bearer only for pk- (deliberately: an sk- bearer is IAM's
// to validate, and widening ingestKey would shadow the identity path). projectKey never
// reads Authorization at all. So an hk-/sk- bearer that FAILED to resolve fell through
// reads Authorization at all. So an sk- bearer that FAILED to resolve fell through
// both and took the ANONYMOUS lane: 200, with the caller's rows filed under $public — a
// partition its owner cannot read.
//
@@ -116,7 +110,7 @@ func TestUnresolvableAccessKeyBearerRefuses(t *testing.T) {
roomyRate(t)
app := mountApp(t)
body := `{"batch":[{"type":"pageview","distinctId":"anon-1","path":"/pricing"}]}`
for _, key := range []string{"hk-nonexistent-0001", "sk-nonexistent-0001", "pk-nonexistent-0001"} {
for _, key := range []string{"sk-nonexistent-0001", "pk-nonexistent-0001"} {
for _, door := range doors {
code, got := postAuth(t, app, door.path, "Bearer "+key, body)
if code != http.StatusForbidden {
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// attribution.go — what a publishable key names, and the seam that answers
// which one.
//
// A key is minted with a project (apps/projects) and this is where a beacon
// carrying it is turned back into (org, project). The projects app owns the row,
// the ingest door reads it, and they are not the same process in production — the
// pod boots one process per app — so this is the same two-resolver seam
// sites.SetResolver already uses: in-process when the store is here, over the
// plane when it is not.
package analytics
import (
"context"
"sync"
)
// Attribution is what a publishable key resolves to: the org that owns the rows, and
// the project that emitted them.
//
// Project is the SERVER's answer to a question the wire also asks — an event
// carries a `product` field naming its emitting surface, and that field is the
// caller's to set. When a key names a project the server's answer wins (see
// attributeProject), which is the difference between a label and an attribution.
type Attribution struct {
Org string
Project string
}
// KeyResolver maps a publishable ingest key to the scope it names.
//
// found=false ⇒ no project holds this key: the honest refusal, and the whole of
// "if the site is missing it stops recording". err ⇒ a real store or transport
// failure, which is NOT a refusal and must not be collapsed into one — a
// transient failure of the owning app would otherwise read exactly like every
// customer's site being deleted at once.
type KeyResolver interface {
Resolve(ctx context.Context, key string) (Attribution, bool, error)
}
var (
keyMu sync.RWMutex
keyResolver KeyResolver
keyFallback KeyResolver
)
// SetKeyResolver installs the in-process resolver. projects.Mount calls it with
// its store — the no-hop answer when ingest and the project store share a process.
func SetKeyResolver(r KeyResolver) {
keyMu.Lock()
keyResolver = r
keyMu.Unlock()
}
// SetFallbackKeyResolver installs the cross-process resolver. The composition
// root calls it with a plane client, for every process that does NOT own the
// project store — which in production is the one serving this door.
func SetFallbackKeyResolver(r KeyResolver) {
keyMu.Lock()
keyFallback = r
keyMu.Unlock()
}
// HasFallbackKeyResolver reports whether a cross-process resolver is installed,
// so the host can prove it wired the door. An unwired seam refuses every beacon
// on the fleet and no test inside this package can see it, because the package
// is correct either way.
func HasFallbackKeyResolver() bool {
keyMu.RLock()
defer keyMu.RUnlock()
return keyFallback != nil
}
func currentKeyResolver() KeyResolver {
keyMu.RLock()
r, fb := keyResolver, keyFallback
keyMu.RUnlock()
if r != nil {
return r
}
return fb
}
// resolveAttribution answers which project a key names. It reports only found/not —
// a store failure is logged by the resolver and read here as "not resolved",
// because this door's caller is a browser that can do nothing with the
// difference. What it must never do is answer with an org and no project: that
// is the silent misfiling this whole change removes.
func resolveAttribution(ctx context.Context, key string) (Attribution, bool) {
r := currentKeyResolver()
if r == nil || key == "" {
return Attribution{}, false
}
at, ok, err := r.Resolve(ctx, key)
if err != nil || !ok || at.Org == "" {
return Attribution{}, false
}
return at, true
}
// attributeProject stamps the resolved project onto every event, replacing
// whatever the caller put in `product`. The key is the evidence and the body is
// not: a page that ships one project's key cannot file its rows under another's
// name. The pure twin of attribute (public.go), which does the same for identity
// on the reduced lane.
func attributeProject(evs []CaptureEvent, project string) []CaptureEvent {
if project == "" {
return evs
}
for i := range evs {
evs[i].Product = project
}
return evs
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"context"
"errors"
"net/http"
"strings"
"testing"
)
// stubKeys installs an in-process key resolver over a fixed table, and clears BOTH
// resolver slots so a leaked fallback cannot answer instead.
func stubKeys(t *testing.T, table map[string]Attribution) {
t.Helper()
keyMu.Lock()
origR, origF := keyResolver, keyFallback
keyMu.Unlock()
SetKeyResolver(fixedKeys(table))
SetFallbackKeyResolver(nil)
t.Cleanup(func() {
SetKeyResolver(origR)
SetFallbackKeyResolver(origF)
})
}
type fixedKeys map[string]Attribution
func (f fixedKeys) Resolve(_ context.Context, key string) (Attribution, bool, error) {
at, ok := f[key]
return at, ok, nil
}
// failingKeys is the owning app being unreachable — an error, never a miss.
type failingKeys struct{}
func (failingKeys) Resolve(context.Context, string) (Attribution, bool, error) {
return Attribution{}, false, errors.New("projects unreachable")
}
const siteKey = "pk-sitekeysitekeysitekeysitekeysitekey00"
// TestProjectKeyAttributesToItsSite is the design: the key names org AND site, so a
// beacon lands in the project's org tagged with the project — an attribution the
// server states rather than accepts.
func TestProjectKeyAttributesToItsSite(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}})
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "", `{"type":"pageview","event":"$pageview"}`,
map[string]string{"Authorization": "Bearer " + siteKey})
if code != http.StatusOK {
t.Fatalf("keyed beacon = %d, want 200", code)
}
if got := w.tenants(t); len(got) != 1 || got[0] != "acme" {
t.Fatalf("tenant = %v, want [acme]", got)
}
if len(w.facts) != 1 || w.facts[0].product != "shop" {
t.Fatalf("product = %q, want shop — the key must name the site", w.facts[0].product)
}
}
// TestProjectKeyOverridesTheBodysProduct: `product` is client-supplied and therefore
// not evidence. When the key names a project the server's answer wins, so a page
// shipping one project's key cannot file its rows under another's name.
func TestProjectKeyOverridesTheBodysProduct(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}})
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "",
`{"type":"pageview","event":"$pageview","product":"someone-elses-site"}`,
map[string]string{"Authorization": "Bearer " + siteKey})
if code != http.StatusOK {
t.Fatalf("keyed beacon = %d, want 200", code)
}
if len(w.facts) != 1 || w.facts[0].product != "shop" {
t.Fatalf("product = %q, want shop — a body claim reached the fact", w.facts[0].product)
}
}
// TestKeyRidesEveryCarrier: the project key travels on all three ingest carriers, so
// a page can use whichever its transport allows. The query carrier is load-bearing:
// navigator.sendBeacon cannot set headers, and that is the transport a real page
// uses on unload.
func TestKeyRidesEveryCarrier(t *testing.T) {
roomyRate(t)
for _, tc := range []struct {
name, path string
hdr map[string]string
}{
{"bearer", "/v1/event", map[string]string{"Authorization": "Bearer " + siteKey}},
{"ingest header", "/v1/event", map[string]string{"x-hanzo-ingest-key": siteKey}},
{"beacon query", "/v1/event?ingest_key=" + siteKey, nil},
} {
t.Run(tc.name, func(t *testing.T) {
stubKeys(t, map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}})
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, tc.path, "", `{"type":"pageview","event":"$pageview"}`, tc.hdr)
if code != http.StatusOK {
t.Fatalf("%s = %d, want 200", tc.name, code)
}
if got := w.tenants(t); len(got) != 1 || got[0] != "acme" {
t.Fatalf("%s tenant = %v, want [acme]", tc.name, got)
}
})
}
}
// TestUnknownKeyRefusedAndWritesNothing: a key that names no project is 403, never a
// downgrade. Filing it anywhere would hide the rows in a partition its owner cannot
// read — the silent failure this change exists to end.
func TestUnknownKeyRefusedAndWritesNothing(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{})
stubResolver(t, func(string) (string, bool) { return "", false })
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "", `{"type":"pageview","event":"$pageview"}`,
map[string]string{"Authorization": "Bearer " + siteKey})
if code != http.StatusForbidden {
t.Fatalf("unknown key = %d, want 403", code)
}
if got := w.tenants(t); len(got) != 0 {
t.Fatalf("an unresolvable key wrote %v", got)
}
}
// TestDeletedSiteStopsRecordingAtTheDoor is the CTO's rule end to end: the same key
// that was landing rows stops landing them the moment its project is gone.
func TestDeletedSiteStopsRecordingAtTheDoor(t *testing.T) {
roomyRate(t)
live := map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}}
stubKeys(t, live)
stubResolver(t, func(string) (string, bool) { return "", false })
w := fakeWarehouse(t)
app := mountApp(t)
body := `{"type":"pageview","event":"$pageview"}`
hdr := map[string]string{"Authorization": "Bearer " + siteKey}
if code := postKeyed(t, app, "/v1/event", "", body, hdr); code != http.StatusOK {
t.Fatalf("precondition: keyed beacon = %d, want 200", code)
}
before := len(w.facts)
delete(live, siteKey) // the project is deleted; the key now names nothing
if code := postKeyed(t, app, "/v1/event", "", body, hdr); code != http.StatusForbidden {
t.Fatalf("after delete = %d, want 403", code)
}
if len(w.facts) != before {
t.Fatalf("a deleted site still wrote %d fact(s)", len(w.facts)-before)
}
}
// TestKeylessBeaconRefusedAndWritesNothing: the defect this change removes. A keyless
// beacon used to be accepted into a reserved tenant and answered {"accepted":1} — its
// owner could not read the partition, so it lost everything behind a 200.
func TestKeylessBeaconRefusedAndWritesNothing(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{})
w := fakeWarehouse(t)
app := mountApp(t)
code, body := doHost(t, app, "/v1/event", "", "", "cloud.hanzo.ai",
`{"type":"pageview","event":"$pageview"}`)
if code != http.StatusUnauthorized {
t.Fatalf("keyless beacon = %d (%s), want 401", code, body)
}
if !strings.Contains(string(body), "ingest_key_required") {
t.Fatalf("body = %s, want the ingest_key_required code", body)
}
if got := w.tenants(t); len(got) != 0 {
t.Fatalf("a keyless beacon wrote %v", got)
}
}
// TestBearerStillAttributes: console.hanzo.ai deliberately carries NO key — it is one
// brand-agnostic image, so a baked-in key would pin lux/zoo white-labels onto hanzo —
// and attributes through its IAM bearer instead. That path must keep working.
//
// A bearer names an ORG and no site, so the fact carries no product. That is the
// honest answer: `product` on the canonical wire is not a caller field at all, and the
// only thing that can state one is a key minted with a project.
func TestBearerStillAttributes(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{})
w := fakeWarehouse(t)
app := mountApp(t)
code, body := doHost(t, app, "/v1/event", "u_console", "hanzo", "console.hanzo.ai",
`{"type":"pageview","event":"$pageview"}`)
if code != http.StatusOK {
t.Fatalf("bearer beacon = %d (%s), want 200", code, body)
}
if got := w.tenants(t); len(got) != 1 || got[0] != "hanzo" {
t.Fatalf("tenant = %v, want [hanzo]", got)
}
if w.facts[0].product != "" {
t.Fatalf("product = %q — a bearer names no site", w.facts[0].product)
}
}
// TestResolverFailureIsNotAMiss: the owning app being unreachable must not read as
// "this site does not exist". Both refuse, but only one is the caller's to fix, and a
// transient failure must never be reported as a deleted project.
func TestResolverFailureIsNotAMiss(t *testing.T) {
at, ok := resolveAttribution(context.Background(), siteKey)
_ = at
if ok {
t.Fatal("precondition")
}
SetKeyResolver(failingKeys{})
SetFallbackKeyResolver(nil)
t.Cleanup(func() { SetKeyResolver(nil); SetFallbackKeyResolver(nil) })
if _, ok := resolveAttribution(context.Background(), siteKey); ok {
t.Fatal("a failing resolver must not attribute")
}
}
// TestAttributionRequiresAnOrg: a resolver that answers found with no org is refused.
// An empty org would be a write with no tenant at all.
func TestAttributionRequiresAnOrg(t *testing.T) {
stubKeys(t, map[string]Attribution{siteKey: {Org: "", Project: "shop"}})
if _, ok := resolveAttribution(context.Background(), siteKey); ok {
t.Fatal("an attribution with no org must be refused")
}
}
// TestAttributeProjectIsPureAndTotal: the stamp reaches every event in a batch, and
// an empty project leaves the caller's value alone (a bearer names no site).
func TestAttributeProjectIsPureAndTotal(t *testing.T) {
evs := []CaptureEvent{{Product: "a"}, {Product: "b"}, {}}
out := attributeProject(evs, "shop")
for i, e := range out {
if e.Product != "shop" {
t.Fatalf("event %d product = %q, want shop", i, e.Product)
}
}
back := attributeProject([]CaptureEvent{{Product: "console"}}, "")
if back[0].Product != "console" {
t.Fatalf("empty project overwrote %q", back[0].Product)
}
}
// TestMountWiresTheKeyDoor: an unwired seam refuses every beacon on the fleet, and no
// behavioural test inside this package can see it because the package is correct
// either way. So the wiring itself is asserted.
func TestMountWiresTheKeyDoor(t *testing.T) {
keyMu.Lock()
origR, origF := keyResolver, keyFallback
keyMu.Unlock()
SetKeyResolver(nil)
SetFallbackKeyResolver(nil)
t.Cleanup(func() { SetKeyResolver(origR); SetFallbackKeyResolver(origF) })
_ = mountApp(t)
if !HasFallbackKeyResolver() {
t.Fatal("Mount left the key resolver unwired; every beacon on the fleet would refuse")
}
}
+99 -90
View File
@@ -534,30 +534,67 @@ func closeBus() { conn.close() }
// can read the plane.
//
// The field names are the COLUMN names, so a reader never has to hold two vocabularies
// at once: what you see on the subject is what you query in the table.
// at once: what you see on the subject is what you query in the table. That promise is
// why this is FLAT. It carried four optional sub-bodies while there were four tables to
// carry them into; with one table a body is a subset of columns, and nesting it would
// have been a second shape of the row that every consumer then had to translate.
//
// `sample` stays a pointer, alone, because a measurement is genuinely a different
// value: it has a float and no identity, and it lands in the other table.
type message struct {
Signal string `json:"signal"`
// ── spine ────────────────────────────────────────────────────────────────
Signal string `json:"signal"`
Org string `json:"org"`
Time time.Time `json:"time"`
ID string `json:"id"`
// The envelope — identical for every signal.
Org string `json:"org"`
Time time.Time `json:"time"`
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Product string `json:"product,omitempty"`
Session string `json:"session_id,omitempty"`
Distinct string `json:"distinct_id,omitempty"`
Anonymous string `json:"anonymous_id,omitempty"`
Person string `json:"person_id,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
// ── what ─────────────────────────────────────────────────────────────────
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Message string `json:"message,omitempty"`
Severity uint8 `json:"severity,omitempty"`
Duration uint64 `json:"duration,omitempty"`
// ── where ────────────────────────────────────────────────────────────────
Product string `json:"product,omitempty"`
Env string `json:"env,omitempty"`
Service string `json:"service,omitempty"`
Release string `json:"release,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
// ── who ──────────────────────────────────────────────────────────────────
Person string `json:"person_id,omitempty"`
Distinct string `json:"distinct_id,omitempty"`
Anonymous string `json:"anonymous_id,omitempty"`
Groups map[string]string `json:"groups,omitempty"`
// ── correlation ──────────────────────────────────────────────────────────
Session string `json:"session_id,omitempty"`
Trace string `json:"trace_id,omitempty"`
Span string `json:"span_id,omitempty"`
Parent string `json:"parent,omitempty"`
Resource string `json:"resource,omitempty"`
// ── open ─────────────────────────────────────────────────────────────────
Attributes map[string]string `json:"attributes,omitempty"`
El *messageEl `json:"el,omitempty"`
// Exactly one of these is set, chosen by Signal.
Fault *messageFault `json:"fault,omitempty"`
Record *messageRecord `json:"record,omitempty"`
Span *messageSpan `json:"span,omitempty"`
// ── error ────────────────────────────────────────────────────────────────
Issue string `json:"issue,omitempty"`
Class string `json:"class,omitempty"`
Origin string `json:"origin,omitempty"`
Handled bool `json:"handled,omitempty"`
Frames []messageFrame `json:"frames,omitempty"`
// ── span ─────────────────────────────────────────────────────────────────
Status string `json:"status,omitempty"`
// ── clip ─────────────────────────────────────────────────────────────────
Object string `json:"object,omitempty"`
Bytes uint64 `json:"bytes,omitempty"`
// ── the other grain ──────────────────────────────────────────────────────
Sample *messageSample `json:"sample,omitempty"`
}
@@ -578,40 +615,6 @@ type messageFrame struct {
Own bool `json:"own"`
}
type messageFault struct {
Group string `json:"group"`
Message string `json:"message,omitempty"`
Class string `json:"class,omitempty"`
Site string `json:"site,omitempty"`
Handled bool `json:"handled"`
Level string `json:"level,omitempty"`
Release string `json:"release,omitempty"`
Environment string `json:"environment,omitempty"`
Service string `json:"service,omitempty"`
Trace string `json:"trace_id,omitempty"`
Span string `json:"span_id,omitempty"`
Frames []messageFrame `json:"frames,omitempty"`
}
type messageRecord struct {
Service string `json:"service,omitempty"`
Severity string `json:"severity_text,omitempty"`
Number uint8 `json:"severity_number,omitempty"`
Body string `json:"body,omitempty"`
Trace string `json:"trace_id,omitempty"`
Span string `json:"span_id,omitempty"`
Resource uint64 `json:"resource,omitempty"`
}
type messageSpan struct {
Service string `json:"service,omitempty"`
Trace string `json:"trace_id,omitempty"`
ID string `json:"span_id,omitempty"`
Parent string `json:"parent,omitempty"`
Duration uint64 `json:"duration,omitempty"`
Status string `json:"status,omitempty"`
}
type messageSample struct {
Metric string `json:"metric"`
Value float64 `json:"value"`
@@ -645,20 +648,46 @@ func decodeMessage(data []byte) (message, error) {
// contract, and letting one drift is a schema change rather than a rename.
func wire(f fact) message {
m := message{
Signal: string(f.signal),
Org: f.org,
Time: f.time,
ID: f.id,
Name: f.name,
Kind: f.kind,
Product: f.product,
Session: f.session,
Distinct: f.distinct,
Anonymous: f.anonymous,
Person: f.person,
URL: f.url,
Path: f.path,
Signal: string(f.signal),
Org: f.org,
Time: f.time,
ID: f.id,
Name: f.name,
Kind: f.kind,
Message: f.message,
Severity: f.severity,
Duration: f.duration,
Product: f.product,
Env: f.env,
Service: f.service,
Release: f.release,
URL: f.url,
Path: f.path,
Person: f.person,
Distinct: f.distinct,
Anonymous: f.anonymous,
Groups: f.groups,
Session: f.session,
Trace: f.trace,
Span: f.span,
Parent: f.parent,
Resource: f.resource,
Attributes: f.attributes,
Issue: f.issue,
Class: f.class,
Origin: f.origin,
Handled: f.handled,
Status: f.status,
Object: f.object,
Bytes: f.bytes,
}
if !f.el.empty() {
m.El = &messageEl{
@@ -666,33 +695,13 @@ func wire(f fact) message {
Name: f.el.name, Component: f.el.component, Path: f.el.path,
}
}
if f.fault != nil {
frames := make([]messageFrame, 0, len(f.fault.frames))
for _, fr := range f.fault.frames {
frames = append(frames, messageFrame{
if len(f.frames) > 0 {
m.Frames = make([]messageFrame, 0, len(f.frames))
for _, fr := range f.frames {
m.Frames = append(m.Frames, messageFrame{
Function: fr.function, File: fr.file, Line: fr.line, Column: fr.column, Own: fr.own,
})
}
m.Fault = &messageFault{
Group: f.fault.group, Message: f.fault.message, Class: f.fault.class,
Site: f.fault.site, Handled: f.fault.handled, Level: f.fault.level,
Release: f.fault.release, Environment: f.fault.environment,
Service: f.fault.service, Trace: f.fault.trace, Span: f.fault.span,
Frames: frames,
}
}
if f.record != nil {
m.Record = &messageRecord{
Service: f.record.service, Severity: f.record.severity, Number: f.record.number,
Body: f.record.body, Trace: f.record.trace, Span: f.record.span,
Resource: f.record.resource,
}
}
if f.span != nil {
m.Span = &messageSpan{
Service: f.span.service, Trace: f.span.trace, ID: f.span.id,
Parent: f.span.parent, Duration: f.span.duration, Status: f.span.status,
}
}
if f.sample != nil {
m.Sample = &messageSample{Metric: f.sample.metric, Value: f.sample.value, Labels: f.sample.labels}
+26 -19
View File
@@ -15,16 +15,16 @@
// campaign.go is the in-process CAMPAIGN-METRICS seam over the ONE analytics
// warehouse: the /v1/campaign plane (apps/campaign) reads a campaign's funnel
// from HERE rather than opening a second store. A campaign's results ARE an
// analytics query scoped to the campaign — the utm_campaign-tagged events in
// event.event (the attributes['utm_campaign'] entry the plane normalizer stamps)
// analytics query scoped to the campaign — the utm_campaign-tagged acts on the
// event plane (the attributes['utm_campaign'] entry the plane normalizer stamps)
// — so there is one metrics plane, not a parallel one.
//
// TENANCY: identical to every other query this package builds. campaignWhere
// binds the org AND the campaign id (attributes['utm_campaign']) AND the optional
// variant (attributes['utm_content']) POSITIONALLY — nothing user-derived is ever
// interpolated, so a caller can only ever read its OWN org's campaign, and the
// utm_campaign filter can never escape into SQL. The variant arg powers the
// creative-A/B evidence read the experiment primitive composes.
// TENANCY AND SIGNAL: identical to every other query this package builds, because
// campaignWhere composes eventsWhere. The org, the signal, the time bounds, the
// campaign id and the optional variant are all bound POSITIONALLY — nothing
// user-derived is ever interpolated, so a caller can only ever read its OWN org's
// campaign, and the utm_campaign filter can never escape into SQL. The variant arg
// powers the creative-A/B evidence read the experiment primitive composes.
package analytics
@@ -51,15 +51,22 @@ type CampaignEvents struct {
Source string `json:"source"`
}
// campaignWhere is the org + campaign (+ optional variant) predicate over
// event.event. org and campaignID (attributes['utm_campaign']) are ALWAYS bound;
// variant (attributes['utm_content']) is appended only when non-empty. Time bounds
// are bound as datastore DateTime literals (the proven cloud_usage transport). Same
// isolation boundary as eventsWhere — the org is a bound parameter, never
// interpolated; the map ACCESSOR is a server constant and only the VALUE binds.
// campaignWhere is the campaign NARROWING of the behavior lens: eventsWhere,
// plus the campaign (+ optional variant) the caller asked about. It composes
// eventsWhere rather than writing its own FROM-clause predicate, because the
// leading pair — whose rows, and which sort — is one decision and belongs in one
// place: this read named its own `org = ?` and time bounds and had no `signal`
// at all, so on the ONE fact table it counted every log line, span and error as
// a campaign impression the moment a campaign shipped.
//
// campaignID (attributes['utm_campaign']) and variant (attributes['utm_content'])
// are bound like everything else; the map ACCESSOR is a server constant and only
// the VALUE binds. Arg order is eventsWhere's [org, signal, start, end], then
// campaign, then the variant when there is one.
func campaignWhere(org, campaignID, variant string, start, end time.Time) (string, []any) {
where := "time >= ? AND time < ? AND org = ? AND attributes['utm_campaign'] = ?"
args := []any{tsLiteral(start), tsLiteral(end), org, campaignID}
where, args := eventsWhere(org, start, end)
where += " AND attributes['utm_campaign'] = ?"
args = append(args, campaignID)
if variant != "" {
where += " AND attributes['utm_content'] = ?"
args = append(args, variant)
@@ -75,7 +82,7 @@ func campaignWhere(org, campaignID, variant string, start, end time.Time) (strin
// channels. A genuine query failure against a connected warehouse returns the
// error (the caller logs it and shows honest-empty) — never a fabricated funnel.
func CampaignMetrics(ctx context.Context, org, campaignID, variant string, start, end time.Time) (CampaignEvents, error) {
out := CampaignEvents{Available: false, Source: eventsTable}
out := CampaignEvents{Available: false, Source: factTable}
if org == "" || campaignID == "" {
return out, nil
}
@@ -96,7 +103,7 @@ func CampaignMetrics(ctx context.Context, org, campaignID, variant string, start
"countIf(name = 'order_completed' OR name = 'signup_completed' OR name = 'conversion') AS conversions, " +
"sum(toFloat64OrZero(attributes['revenue'])) AS revenue, " +
"uniqExact(distinct_id) AS visitors " +
"FROM " + eventsTable + " WHERE " + where
"FROM " + factTable + " WHERE " + where
rows, err := datastore.Query(ctx, sql, args...)
if err != nil {
// Connected warehouse rejected/failed the query (or the events table is
@@ -111,6 +118,6 @@ func CampaignMetrics(ctx context.Context, org, campaignID, variant string, start
Conversions: aInt64(row["conversions"]),
Revenue: aFloat64(row["revenue"]),
Visitors: aInt64(row["visitors"]),
Source: eventsTable,
Source: factTable,
}, nil
}
+36 -5
View File
@@ -22,9 +22,9 @@ func TestCampaignWhere_BindsOrgAndCampaignPositionally(t *testing.T) {
if strings.Contains(where, "utm_content") {
t.Fatalf("no variant clause expected for whole-campaign read, got %q", where)
}
// args order: start, end, org, campaign.
if len(args) != 4 || args[2] != "acme" || args[3] != "cmp_1" {
t.Fatalf("args must bind [ts, ts, org, campaign], got %v", args)
// args order: org, signal, start, end, campaign — eventsWhere's leading pair first.
if len(args) != 5 || args[0] != "acme" || args[4] != "cmp_1" {
t.Fatalf("args must bind [org, signal, ts, ts, campaign], got %v", args)
}
// The hostile-slug proof: the org value is a bound arg, never text in the SQL.
if strings.Contains(where, "acme") || strings.Contains(where, "cmp_1") {
@@ -41,11 +41,42 @@ func TestCampaignWhere_VariantAppended(t *testing.T) {
if !strings.Contains(where, "attributes['utm_content'] = ?") {
t.Fatalf("variant must add a bound attributes['utm_content'] clause, got %q", where)
}
if len(args) != 5 || args[4] != "hero-b" {
if len(args) != 6 || args[5] != "hero-b" {
t.Fatalf("variant must be the trailing bound arg, got %v", args)
}
}
// TestCampaignWhere_ScopesTheSignal is the regression gate for the read that
// counted the whole plane. On ONE fact table the signal is a PREDICATE, and a
// predicate can be forgotten: campaignWhere named org + time + utm_campaign and
// no signal, so uniqExact(distinct_id) and sum(revenue) ranged over logs, spans
// and errors as well as acts. It was latent only because nothing carries a
// utm_campaign yet. campaignWhere composes eventsWhere so it cannot be forgotten
// again — this pins the composition, not merely the string.
func TestCampaignWhere_ScopesTheSignal(t *testing.T) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
base, baseArgs := eventsWhere("acme", start, end)
for _, variant := range []string{"", "hero-b"} {
where, args := campaignWhere("acme", "cmp_1", variant, start, end)
if !strings.HasPrefix(where, base) {
t.Fatalf("campaign read must NARROW eventsWhere, not restate it:\n got %q\nwant prefix %q", where, base)
}
if !strings.Contains(where, "signal = ?") {
t.Fatalf("campaign read must bind the signal, got %q", where)
}
if got, ok := args[1].(string); !ok || got != string(signalAct) {
t.Fatalf("campaign read must scope to acts, got %v", args[1])
}
for i, want := range baseArgs {
if args[i] != want {
t.Fatalf("leading args must be eventsWhere's, got %v want prefix %v", args, baseArgs)
}
}
}
}
// TestCampaignMetrics_HonestEmptyWhenDatastoreDisabled: with no warehouse
// connected (unit-test default), the seam returns honest-empty (Available=false)
// and NO error — the campaign metrics view still renders spend + channels.
@@ -57,7 +88,7 @@ func TestCampaignMetrics_HonestEmptyWhenDatastoreDisabled(t *testing.T) {
if ev.Available {
t.Fatalf("no warehouse connected ⇒ Available must be false, got %+v", ev)
}
if ev.Source != eventsTable {
if ev.Source != factTable {
t.Fatalf("source should name the events table even when empty, got %q", ev.Source)
}
}
+68 -51
View File
@@ -13,7 +13,7 @@
// limitations under the License.
// Capture (WRITE) side of the analytics plane. analytics.go serves the read
// lenses over the event plane (event.event and its sibling signal tables); this
// lenses over the event plane (event.fact, discriminated by signal); this
// file is the symmetric ingest that FILLS that plane. Products emit here (the ONE
// native front door) instead of talking to the insights capture service directly —
// cloud owns the tenant boundary; the PLANE's schema is owned by hanzoai/o11y.
@@ -45,7 +45,6 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"regexp"
"strings"
"time"
@@ -58,12 +57,6 @@ import (
// Larger batches are rejected (400) rather than silently truncated.
const maxBatch = 500
// publicCaptureEnv gates anonymous (no-principal) capture. Default ON: the
// marketing sites emit anonymous pageviews, and cloud is REPLACING the already-
// public insights-capture ingest, so refusing anonymous events would drop that
// traffic. Set to a falsey value to require a validated principal on every event.
const publicCaptureEnv = "CLOUD_ANALYTICS_PUBLIC_CAPTURE"
// maxClockSkew and maxBackdate are the TWO bounds on the one caller-chosen value
// that reaches a key column, and they exist for different reasons.
//
@@ -71,7 +64,7 @@ const publicCaptureEnv = "CLOUD_ANALYTICS_PUBLIC_CAPTURE"
// the queryable window.
//
// maxBackdate (PAST) bounds the DOMAIN of `time`, which sits second in every plane
// table's ORDER BY ((org, time, id) on event.event). A MergeTree part is skippable
// table's ORDER BY ((org, time, id) on event.fact). A MergeTree part is skippable
// only when its key range misses the query's, so ONE small batch spanning 2019..now
// yields a part whose range intersects every window any tenant will ever ask for —
// O(1) to write, O(table) to read, for everyone. Bounding the domain is what makes
@@ -94,7 +87,7 @@ const (
// THERE IS NO EVENTS-TABLE DDL HERE ANY MORE, AND THAT IS THE POINT. This package
// used to own eventsTableDDL/EnsureEventsTable for the legacy wide table
// (hanzo.events) — a second, per-tenant-partitioned copy of every product event.
// The plane's schema (event.event and its sibling signal tables) has ONE owner,
// The plane's schema (event.fact, event.sample and their rollups) has ONE owner,
// hanzoai/o11y (schema.sql there), and cloud is a WRITER and READER of it, never a
// creator: the write core commits facts onto the plane and the sink lands them
// (warehouse.go); every read lens answers honest-empty when the plane is absent,
@@ -108,28 +101,34 @@ const (
// these; the server owns the tenant (tenant_id is NOT a field here — it can never
// be set by the client).
type CaptureEvent struct {
MessageID string `json:"messageId"` // client idempotency id; server mints one if empty
Type string `json:"type"` // pageview | event | identify | group
Event string `json:"event"` // event name (type=event); pageview→$pageview
Timestamp string `json:"timestamp"` // RFC3339; clamped to server-now on skew/absent
DistinctID string `json:"distinctId"` // resolved person/visitor id
AnonymousID string `json:"anonymousId"`
PersonID string `json:"personId"`
SessionID string `json:"sessionId"`
Product string `json:"product"` // emitting surface: console|chat|app|site|admin
URL string `json:"url"`
Path string `json:"path"`
Referrer string `json:"referrer"`
UTM UTM `json:"utm"`
RefCode string `json:"refCode"`
Channel string `json:"channel"`
GroupID string `json:"groupId"`
SignupWeek string `json:"signupWeek"`
ProductID string `json:"productId"`
Quantity uint32 `json:"quantity"`
Revenue float64 `json:"revenue"`
Currency string `json:"currency"`
Error *Exception `json:"error"` // set on type:'error' events (folded into properties.$exception)
MessageID string `json:"messageId"` // client idempotency id; server mints one if empty
Type string `json:"type"` // pageview | event | identify | group
Event string `json:"event"` // event name (type=event); pageview→$pageview
Timestamp string `json:"timestamp"` // RFC3339; clamped to server-now on skew/absent
DistinctID string `json:"distinctId"` // resolved person/visitor id
AnonymousID string `json:"anonymousId"`
PersonID string `json:"personId"`
SessionID string `json:"sessionId"`
Product string `json:"product"` // emitting surface: console|chat|app|site|admin
URL string `json:"url"`
Path string `json:"path"`
Referrer string `json:"referrer"`
UTM UTM `json:"utm"`
RefCode string `json:"refCode"`
Channel string `json:"channel"`
GroupID string `json:"groupId"`
// GroupType names WHICH grouping the id belongs to (organization, workspace,
// account). It is a map key rather than a column so the second grouping is a data
// change: the plane stores `groups[type] = id`, never group0..group4, because a
// fifth positional slot is a sixth one waiting to become a version suffix.
// Absent means the only grouping anything sends today, `organization`.
GroupType string `json:"groupType"`
SignupWeek string `json:"signupWeek"`
ProductID string `json:"productId"`
Quantity uint32 `json:"quantity"`
Revenue float64 `json:"revenue"`
Currency string `json:"currency"`
Error *Exception `json:"error"` // set on type:'error' events (folded into properties.$exception)
// THE OTEL SIGNALS. One envelope carries all four — event, log, span, metric —
// because they differ in their BODY, not in who sent them or when. Splitting the
@@ -141,6 +140,7 @@ type CaptureEvent struct {
Log *LogBody `json:"log"`
Span *SpanBody `json:"span"`
Metric *MetricBody `json:"metric"`
Clip *ClipBody `json:"clip"`
// Kind narrows Type when the surface knows more than the wire word does — a
// span's client/server role, a page's navigation kind. Empty means the route's
@@ -166,8 +166,16 @@ type CaptureEvent struct {
// TraceID and SpanID correlate a signal to a trace whatever its body is, so a log
// and the span it was emitted inside join without either owning the other.
TraceID string `json:"traceId"`
SpanID string `json:"spanId"`
TraceID string `json:"traceId"`
SpanID string `json:"spanId"`
// Resource is the fingerprint of the emitting resource — the host, pod and
// service attributes a collector already deduplicates into its own dimension
// table. It joins event.log_resource / event.span_resource on `fingerprint`.
// ONE name for it: the plane previously carried this fact as `resource UInt64`
// AND `resource_fingerprint String` on the same row, one of them always zero.
Resource string `json:"resource"`
Properties map[string]any `json:"properties"`
Library string `json:"library"`
LibraryVer string `json:"libraryVersion"`
@@ -205,6 +213,26 @@ type SpanBody struct {
Duration uint64 `json:"duration"`
}
// ClipBody is the body of a session-replay clip: WHERE THE BLOB IS, not the blob.
//
// A clip's recording is a multi-megabyte time-ordered binary. It is wrong for a bus
// message (which is a hand-off, sized for many small facts) and wrong for a warehouse
// row (which is a column store optimized for scanning narrow values), so it goes
// neither place. It is written to object storage by whoever recorded it, and the fact
// plane carries the INDEX: the address, the size and the span of time it covers.
//
// That is what makes a whole product cost two columns. Everything else a replay needs
// — which session, which org, which page, when — is already the envelope.
type ClipBody struct {
// Object is the blob's address in object storage.
Object string `json:"object"`
// Bytes is its size, so a session list can show weight without opening it.
Bytes uint64 `json:"bytes"`
// Duration is the wall time the clip covers, in nanoseconds — the same column a
// span's elapsed time uses, because it is the same fact.
Duration uint64 `json:"duration"`
}
// MetricBody is the body of a metric sample: what was measured, its value, and the
// labels it is sliced by.
type MetricBody struct {
@@ -331,7 +359,7 @@ var emailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
// secretRe redacts credential shapes that leak in free-text — chiefly error
// stacks/messages (which bypass the key-based denylist): bearer tokens, the key
// families (pk-/sk-/hk-), and ?token=/api_key=/access_token=/password=/secret=
// families (pk-/sk-), and ?token=/api_key=/access_token=/password=/secret=
// query params. Applied to every scrubbed string so a token in a URL property or
// an exception frame is redacted before storage AND before the destinations
// fan-out.
@@ -339,7 +367,7 @@ var emailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
// A published key is not a secret — it ships in public bundles by design — but it
// is redacted anyway: a key in an error frame is noise, and telling the two apart
// here would be a second place that has to know the families.
var secretRe = regexp.MustCompile(`(?i)(bearer\s+[a-z0-9._~+/\-]{8,}={0,2}|(?:pk|sk|hk)-[a-z0-9._\-]{8,}|[?&](?:access_token|refresh_token|id_token|api[_-]?key|token|password|secret|auth)=[^&\s"']+)`)
var secretRe = regexp.MustCompile(`(?i)(bearer\s+[a-z0-9._~+/\-]{8,}={0,2}|(?:pk|sk)-[a-z0-9._\-]{8,}|[?&](?:access_token|refresh_token|id_token|api[_-]?key|token|password|secret|auth)=[^&\s"']+)`)
// scrubText redacts email- and credential-shaped substrings from a free-text
// string. This is the ONE string scrubber; scrubValue and scrubException both
@@ -504,16 +532,6 @@ func strconv64(n int64) string {
// ── handler ──────────────────────────────────────────────────────────────────
// publicCaptureEnabled reports whether anonymous capture is allowed (default ON).
func publicCaptureEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(publicCaptureEnv))) {
case "0", "false", "no", "off":
return false
default:
return true
}
}
// resolveKeyOrg maps a presented project/API key to its org through the ONE IAM
// key seam (cloud.OrgForKey). It is a package var ONLY so a test can substitute a
// resolver without standing up IAM; production is always cloud.OrgForKey.
@@ -576,14 +594,14 @@ func projectKey(c *zip.Ctx) string {
//
// That was a per-DOOR copy of a decision that belongs to the TRUST LEVEL. Both alias
// handlers now call handle (event.go) like every other door: a credential resolves to
// its own org at full capability, and a credential-less caller gets the anonymous
// projection under publicTenant. A Host header no longer names a tenant anywhere.
// its own org, at full capability or through the projection, and a credential-less
// caller is refused. A Host header no longer names a tenant anywhere.
// ── ONE write core ───────────────────────────────────────────────────────────
// event source tags — the WIRE each row arrived on. Stamped into properties.$source
// by ingestEvents, which the plane normalizer carries into attributes['$source'], so
// the ONE event.event table stays honest about origin WITHOUT a second table or a
// the ONE event.fact table stays honest about origin WITHOUT a second table or a
// schema migration: $source is queryable in the attributes map. One tag per door,
// and doors (event.go) is the only list that binds them.
//
@@ -620,8 +638,7 @@ func withSource(p map[string]any, source string) map[string]any {
// already HTTP-shaped (zip) for the handler to pass straight up.
//
// ONE ADMISSION, ONE STORAGE PROJECTION. The fact is the ONLY durable copy: the sink
// (warehouse.go) lands it in its signal's own table — event.event for product events,
// event.error / event.log / event.span for the others — so "stored" and "queryable by
// (warehouse.go) lands it in event.fact under its own signal — so "stored" and "queryable by
// signal" are the same claim. The legacy second projection (a wide hanzo.events row
// per event, inserted here beside the publish) is GONE: it was the measured
// 59.7-byte/row double-write the o11y MV then copied BACK onto the plane minus its
+11 -10
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)
}
@@ -76,9 +76,9 @@ func TestKeyOrg_KeyExtractionReachesResolver(t *testing.T) {
hdr map[string]string
wantKey string
}{
{"body", "/v1/event", `{"api_key":"hk-body","event":"e","distinct_id":"d"}`, nil, "hk-body"},
{"query", "/v1/event?api_key=hk-query", `{"event":"e","distinct_id":"d"}`, nil, "hk-query"},
{"x-api-key", "/v1/event", `{"event":"e","distinct_id":"d"}`, map[string]string{"x-api-key": "hk-hdr"}, "hk-hdr"},
{"body", "/v1/event", `{"api_key":"sk-body","event":"e","distinct_id":"d"}`, nil, "sk-body"},
{"query", "/v1/event?api_key=sk-query", `{"event":"e","distinct_id":"d"}`, nil, "sk-query"},
{"x-api-key", "/v1/event", `{"event":"e","distinct_id":"d"}`, map[string]string{"x-api-key": "sk-hdr"}, "sk-hdr"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -101,7 +101,7 @@ func TestKeyOrg_UnresolvableKeyFailsClosed(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false }) // nothing resolves
code := postKeyed(t, app, "/v1/event", "hanzo.ai",
`{"api_key":"hk-bad","event":"e","distinct_id":"d"}`, nil)
`{"api_key":"sk-bad","event":"e","distinct_id":"d"}`, nil)
if code != http.StatusForbidden {
t.Fatalf("presented-but-unresolvable key on a brand host must 403 (fail closed), got %d", code)
}
@@ -109,9 +109,10 @@ func TestKeyOrg_UnresolvableKeyFailsClosed(t *testing.T) {
// TestKeyOrg_KeylessRequestNeverConsultsResolver: with NO key presented the key
// resolver is never consulted — the key path triggers only on a real key — and the
// request is not refused either: it takes the anonymous lane, where its custom event
// kind is dropped and reported honestly (200), never stored. It used to be admitted
// here at FULL capability into the brand org named by the Host.
// request is not refused at the GATE either: it takes the anonymous lane, where its
// custom event kind is dropped, and the door says so (401 ingest_key_required, which is
// the projection's refusal; the gate's is 403). It used to be admitted here at FULL
// capability into the brand org named by the Host.
func TestKeyOrg_KeylessRequestNeverConsultsResolver(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
@@ -121,7 +122,7 @@ func TestKeyOrg_KeylessRequestNeverConsultsResolver(t *testing.T) {
})
code := postKeyed(t, app, "/v1/event", "hanzo.ai",
`{"event":"e","distinct_id":"d"}`, nil)
if code != http.StatusOK {
t.Fatalf("keyless PostHog event want 200 (anonymous lane, kind dropped), got %d", code)
if code != http.StatusUnauthorized {
t.Fatalf("keyless PostHog event want 401 (anonymous lane, kind dropped, nothing stored), got %d", code)
}
}
+31 -28
View File
@@ -44,6 +44,7 @@ import (
func liveApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("live")})
compose(app)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("live")}); err != nil {
t.Fatalf("Mount: %v", err)
}
@@ -80,7 +81,7 @@ func landDirect(t *testing.T) {
// datastore under test — cloud never creates it, so absence is a skip, not a failure.
func requirePlane(t *testing.T, ctx context.Context) {
t.Helper()
for _, tbl := range []string{eventsTable, errorsTable} {
for _, tbl := range []string{factTable, factTable} {
if !tableExists(ctx, tbl) {
t.Skipf("%s not provisioned (the plane's DDL owner is hanzoai/o11y); skipping live round trip", tbl)
}
@@ -136,11 +137,11 @@ func TestLiveCaptureRoundTrip(t *testing.T) {
// Datastore MergeTree inserts are visible immediately to a direct SELECT.
// 1) Raw landing proof: per-name counts for THIS org on event.event.
rows, err := datastore.Query(ctx,
"SELECT name, count() AS n FROM "+eventsTable+" WHERE org = ? GROUP BY name ORDER BY name", org)
"SELECT name, count() AS n FROM "+factTable+" WHERE org = ? GROUP BY name ORDER BY name", org)
if err != nil {
t.Fatalf("readback query: %v", err)
}
t.Logf("── %s landed rows (org=%s) ──", eventsTable, org)
t.Logf("── %s landed rows (org=%s) ──", factTable, org)
total := 0
for _, r := range rows {
n := aInt64(r["n"])
@@ -154,7 +155,7 @@ func TestLiveCaptureRoundTrip(t *testing.T) {
// 2) Privacy proof: the scrubbed signup_submitted row must NOT contain the
// password or the raw email anywhere in its stored attributes.
pr, err := datastore.Query(ctx,
"SELECT attributes FROM "+eventsTable+" WHERE org = ? AND name = 'signup_submitted'", org)
"SELECT attributes FROM "+factTable+" WHERE org = ? AND name = 'signup_submitted'", org)
if err != nil || len(pr) == 0 {
t.Fatalf("attrs readback: %v", err)
}
@@ -177,7 +178,7 @@ func TestLiveCaptureRoundTrip(t *testing.T) {
where, args := eventsWhere(org, start, end)
overSQL := "SELECT countIf(kind = 'page') AS pageviews, uniqExact(distinct_id) AS visitors, " +
"uniqExact(session_id) AS sessions, countIf(name = 'order_completed') AS orders, " +
"sum(toFloat64OrZero(attributes['revenue'])) AS revenue FROM " + eventsTable + " WHERE " + where
"sum(toFloat64OrZero(attributes['revenue'])) AS revenue FROM " + factTable + " WHERE " + where
orows, err := datastore.Query(ctx, overSQL, args...)
if err != nil || len(orows) == 0 {
t.Fatalf("overview lens query: %v", err)
@@ -201,7 +202,7 @@ func TestLiveCaptureRoundTrip(t *testing.T) {
pwhere, pargs := eventsWhere(org, start, end)
prodSQL := "SELECT attributes['product_id'] AS productId, countIf(name = 'order_completed') AS orders, " +
"sum(toFloat64OrZero(attributes['revenue'])) AS revenue, sum(toUInt64OrZero(attributes['quantity'])) AS units " +
"FROM " + eventsTable +
"FROM " + factTable +
" WHERE " + pwhere + " AND attributes['product_id'] != '' GROUP BY productId ORDER BY revenue DESC LIMIT 10"
prows, err := datastore.Query(ctx, prodSQL, pargs...)
if err != nil {
@@ -213,7 +214,7 @@ func TestLiveCaptureRoundTrip(t *testing.T) {
t.Fatalf("top-products mismatch: %+v", tp.Items)
}
t.Logf("LIVE E2E OK: 10 events emitted via POST /v1/event landed in %s and read back through the analytics lenses", eventsTable)
t.Logf("LIVE E2E OK: 10 events emitted via POST /v1/event landed in %s and read back through the analytics lenses", factTable)
}
func attrsString(m map[string]string) string {
@@ -233,7 +234,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)
}
@@ -242,10 +243,10 @@ func livePost(t *testing.T, app *zip.App, path, user, org, body string) (int, []
return resp.StatusCode, b
}
// TestLiveAnonymousCapture proves the marketing-site path: an ANONYMOUS pageview
// (no principal) posted with no credential lands under the reserved public tenant,
// resolved server-side — never from a client field.
func TestLiveAnonymousCapture(t *testing.T) {
// TestLiveAnonymousCaptureIsRefused proves it against the real warehouse: a pageview
// posted with no credential is refused 401 and writes NO row. A brand Host buys
// nothing — attribution is the key, and there is no tenant to fall back to.
func TestLiveAnonymousCaptureIsRefused(t *testing.T) {
ready, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := datastore.Wait(ready); err != nil {
@@ -256,35 +257,37 @@ func TestLiveAnonymousCapture(t *testing.T) {
landDirect(t)
app := liveApp(t)
// A unique session id lets us find exactly this run's row (an anonymous row
// carries no caller properties, so the marker rides a projected column).
// A unique session id lets us look for exactly this run's row. Nothing must
// carry it.
marker := "anon-" + time.Now().UTC().Format("150405.000")
body := `{"batch":[{"type":"pageview","distinctId":"visitor-x","sessionId":"` + marker + `","product":"site","path":"/"}]}`
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)
req.Host = "hanzo.ai" // a brand host names no tenant
resp, err := app.Test(req)
if err != nil {
t.Fatalf("anon POST: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("anon capture = %d, want 200", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anon capture = %d (%s), want 401", resp.StatusCode, raw)
}
var e struct {
Code string `json:"code"`
}
if err := json.Unmarshal(raw, &e); err != nil || e.Code != "ingest_key_required" {
t.Fatalf("anon capture code = %q (%s), want ingest_key_required", e.Code, raw)
}
rows, err := datastore.Query(ctx,
"SELECT org, kind, product FROM "+eventsTable+" WHERE session_id = ?", marker)
"SELECT org, kind, product FROM "+factTable+" WHERE session_id = ?", marker)
if err != nil {
t.Fatalf("readback: %v", err)
}
if len(rows) != 1 {
t.Fatalf("anon rows = %d, want 1", len(rows))
}
tenant := aString(rows[0]["org"])
t.Logf("anonymous pageview landed: org=%q kind=%q product=%q",
tenant, aString(rows[0]["kind"]), aString(rows[0]["product"]))
if tenant != publicTenant {
t.Fatalf("anon tenant = %q, want %s (no Host names a tenant)", tenant, publicTenant)
if len(rows) != 0 {
t.Fatalf("anon rows = %d, want 0 — a refused beacon must reach no partition, got org=%q",
len(rows), aString(rows[0]["org"]))
}
}
+41 -59
View File
@@ -38,7 +38,7 @@ func TestNormalize_TenantAlwaysServerOrg(t *testing.T) {
if f.org != "acme" {
t.Fatalf("org = %q, want acme", f.org)
}
if f.name != "signup_completed" || f.signal != signalEvent || f.kind != kindTrack {
if f.name != "signup_completed" || f.signal != signalAct || f.kind != kindTrack {
t.Fatalf("name/signal/kind = %q/%q/%q", f.name, f.signal, f.kind)
}
}
@@ -132,17 +132,12 @@ func TestBackdatedTimestampIsClamped(t *testing.T) {
// is hanzoai/o11y; the pin over the DDL string moved there with the DDL.
func TestRetentionIsNotARequestParameter(t *testing.T) {
for _, w := range writers {
for _, c := range w.columns {
for _, c := range w.table.columns {
if strings.Contains(c, "ingested_at") {
t.Errorf("%s writer binds ingested_at — the caller can set the column retention is measured from", w.signal)
}
}
}
for _, c := range envelopeColumns {
if c == "ingested_at" {
t.Error("ingested_at is in the envelope column list, so every writer binds the TTL anchor")
}
}
}
// TestCloudDoesNotPartitionByTenant — THE INVERTED PIN. Its predecessor
@@ -332,14 +327,14 @@ func decodeProps(t *testing.T, s string) map[string]any {
func TestEventWriterStatementTargetsThePlane(t *testing.T) {
var ew writer
for _, w := range writers {
if w.signal == signalEvent {
if w.signal == signalAct {
ew = w
}
}
stmt := ew.statement()
if !strings.HasPrefix(stmt, "INSERT INTO event.event (") {
t.Fatalf("stmt prefix: %s — the ONE product-event INSERT lands on the plane, "+
"never on the retired wide table", stmt)
stmt := ew.table.statement()
if !strings.HasPrefix(stmt, "INSERT INTO event.fact (") {
t.Fatalf("stmt prefix: %s — the ONE occurrence INSERT lands on the plane's one "+
"fact table, never on a per-signal table and never on the retired wide table", stmt)
}
if strings.Contains(stmt, "hanzo.events") {
t.Fatalf("the event writer still names the retired wide table: %s", stmt)
@@ -351,21 +346,29 @@ func TestEventWriterStatementTargetsThePlane(t *testing.T) {
}
// One placeholder per bound value: the el tuple binds six, every other
// envelope column one.
want := len(envelopeColumns) - 1 + 6
want := len(factColumns) - 1 + 6
if got := strings.Count(stmt, "?"); got != want {
t.Fatalf("placeholder count = %d, want %d", got, want)
}
// EVERY signal renders the IDENTICAL statement, which is the property that
// replaced "one table per signal": five writers cannot drift into five row
// shapes if they are literally the same string.
for _, w := range writers {
if got := w.table.statement(); got != stmt {
t.Fatalf("%s writer renders a different INSERT than act:\n%s\n%s", w.signal, got, stmt)
}
}
}
func TestEnvelopeColumnsMatchArgsWidth(t *testing.T) {
func TestFactColumnsMatchArgsWidth(t *testing.T) {
// The positional args MUST be exactly as wide as the placeholder list, or the
// INSERT binds the wrong column — the one invariant that silently corrupts data.
// (el is one column bound as a six-element tuple, hence the +5.)
if got, want := len(envelopeArgs(message{})), len(envelopeColumns)+5; got != want {
t.Fatalf("envelopeArgs width = %d, want %d (envelopeColumns + el's extra 5)", got, want)
if got, want := len(factArgs(message{})), len(factColumns)+5; got != want {
t.Fatalf("factArgs width = %d, want %d (factColumns + el's extra 5)", got, want)
}
// org leads: the tenant is the first bound value of every fact insert.
args := envelopeArgs(message{Org: "acme"})
args := factArgs(message{Org: "acme"})
if args[0] != "acme" {
t.Fatalf("org arg = %v, want acme (server tenant, positional)", args[0])
}
@@ -392,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)
}
@@ -402,24 +405,20 @@ func doBody(t *testing.T, app *zip.App, method, path, user, org, body string) (i
}
// TestCapture_NoPrincipalGetsAnonymousLane: a credential-less POST is not refused
// outright — it takes the anonymous lane, because admission is decided by trust level
// rather than per door. A pageview is admitted (503, datastore down) under the
// reserved public tenant, and everything beyond the allowlist is dropped, which is
// what the retired alias routes used to get WRONG in the other direction: they
// resolved a REAL brand org from the Host and admitted the lot.
func TestCapture_NoPrincipalGetsAnonymousLane(t *testing.T) {
// outright at the door and admitted nowhere: admission is decided by trust level
// rather than per door, and with no credential there is no trust level to decide on.
// The retired alias routes got this WRONG in the other direction — they resolved a
// REAL brand org from the Host and admitted the lot.
func TestCapture_NoPrincipalIsRefused(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
p := canonDoor
if code, body := doBody(t, app, http.MethodPost, p, "", "", `{"batch":[{"type":"pageview"}]}`); code != http.StatusServiceUnavailable {
t.Fatalf("no-principal POST %s want 503 (anonymous lane, admitted), got %d (%s)", p, code, body)
}
code, body := doBody(t, app, http.MethodPost, p, "", "", `{"batch":[{"type":"event","event":"order_completed","revenue":99}]}`)
if code != http.StatusOK {
t.Fatalf("no-principal commerce POST %s want 200 all-dropped, got %d (%s)", p, code, body)
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Fatalf("no-principal commerce POST %s receipt = %+v, want accepted:0 dropped:1", p, r)
for _, body := range []string{
`{"batch":[{"type":"pageview"}]}`,
`{"batch":[{"type":"event","event":"order_completed","revenue":99}]}`,
} {
code, got := doBody(t, app, http.MethodPost, p, "", "", body)
refusedAnon(t, "no-principal POST "+p+" "+body, code, got)
}
}
@@ -435,12 +434,7 @@ func TestCapture_ForgedOrgWithoutBearerBuysNothing(t *testing.T) {
p := canonDoor
code, body := doBody(t, app, http.MethodPost, p, "", "maxpower",
`{"batch":[{"type":"event","event":"steal","groupId":"maxpower","personId":"victim","revenue":1}]}`)
if code != http.StatusOK {
t.Fatalf("forged-org-no-bearer POST %s want 200 all-dropped, got %d (%s)", p, code, body)
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Fatalf("forged-org-no-bearer POST %s receipt = %+v, want accepted:0 dropped:1", p, r)
}
refusedAnon(t, "forged-org-no-bearer POST "+p, code, body)
}
func TestCapture_EmptyBatchOK(t *testing.T) {
@@ -496,7 +490,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)
}
@@ -505,12 +499,11 @@ func doHost(t *testing.T, app *zip.App, path, user, org, host, body string) (int
return resp.StatusCode, b
}
// TestCapture_HostIsNotATenant: marketing traffic on a recognized brand Host is still
// ACCEPTED (503 — admitted, datastore down), so nothing external breaks; what changed is
// that the Host no longer picks the TENANT. Anonymous traffic lands under the reserved
// public tenant whatever the Host says, and an UNRECOGNIZED Host now behaves exactly
// like a recognized one — the two used to differ (403 vs. a real brand org), which is
// precisely how a caller-settable header ended up selecting a real partition.
// TestCapture_HostIsNotATenant: a Host never picks a tenant, and now never admits one
// either. A recognized brand Host, an unrecognized one and a customer's own all answer
// the SAME 401 — the Host is not evidence of anything. It used to differ (403 vs. a
// real brand org), which is precisely how a caller-settable header ended up selecting
// a real partition.
func TestCapture_HostIsNotATenant(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
@@ -525,19 +518,8 @@ func TestCapture_HostIsNotATenant(t *testing.T) {
{"/v1/event", "hanzo.ai", posthogPage},
{"/v1/event", "evil.example.com", posthogPage},
} {
if code, body := doHost(t, app, tc.path, "", "", tc.host, tc.body); code != http.StatusServiceUnavailable {
t.Fatalf("anonymous pageview %s on host %q want 503 (admitted), got %d (%s)", tc.path, tc.host, code, body)
if code, body := doHost(t, app, tc.path, "", "", tc.host, tc.body); code != http.StatusUnauthorized {
t.Fatalf("anonymous pageview %s on host %q want 401 (no key), got %d (%s)", tc.path, tc.host, code, body)
}
}
}
func TestCapture_PublicCaptureDisabled(t *testing.T) {
t.Setenv(publicCaptureEnv, "off")
app := mountApp(t)
// With public capture disabled, even a recognized brand host is refused
// without a validated principal.
code, _ := doHost(t, app, canonDoor, "", "", "hanzo.ai", `{"batch":[{"type":"pageview"}]}`)
if code != http.StatusForbidden {
t.Fatalf("public-capture-off anonymous want 403, got %d", code)
}
}
+273
View File
@@ -0,0 +1,273 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"context"
"encoding/json"
"net/http"
"sync"
"testing"
"time"
"github.com/hanzoai/cloud/apps/team/token"
"go.opentelemetry.io/otel"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)
// door_honesty_test.go — the door told every client it was fine while storing nothing.
//
// EVERY wire shape /v1/event publishes, posted without a resolvable tenant, answered
// 200 {"accepted":0,"dropped":1}. The projection refuses a kind it cannot name
// (publicKinds admits pageviews and errors; a log, a span and an exception envelope are
// none of those) and said so in a receipt field no client parses and no probe reads. A
// client whose key was absent, revoked or mistyped therefore lost 100% of what it sent
// with a green check beside it — the mechanism that hid an 88% log loss, a span outage
// that ran four and a half months, and a day of missing Sentry traffic.
//
// The rule now is exactly "did anything land" (answer, event.go). These tests pin both
// halves: the loss is loud, and the accepted path — including the PARTIAL batch — is
// exactly as it was.
// refused asserts the observable of a request that stored NOTHING: a 4xx naming a
// machine-readable reason, never a 200 whose receipt nobody reads. It Errorf's and
// returns false rather than failing the run, so a table reports every row.
func refused(t *testing.T, what string, status int, body []byte, wantStatus int, wantCode string) bool {
t.Helper()
if status != wantStatus {
t.Errorf("%s = %d (%s), want %d — a request that stored NOTHING must say so in the "+
"status, which is the only field a client and a probe both read", what, status, body, wantStatus)
return false
}
var e struct {
Code string `json:"code"`
}
if err := json.Unmarshal(body, &e); err != nil || e.Code != wantCode {
t.Errorf("%s code = %q (%s), want %q — the reason has to be machine-readable or an SDK "+
"is back to guessing", what, e.Code, body, wantCode)
return false
}
return true
}
// refusedAnon is the common case: nothing stored, and nobody vouched for the caller.
func refusedAnon(t *testing.T, what string, status int, body []byte) bool {
t.Helper()
return refused(t, what, status, body, http.StatusUnauthorized, "ingest_key_required")
}
// ── 1. the defect, on every shape the door publishes ─────────────────────────
// TestEveryWireShapeRefusesWhenNothingLands is the regression, stated once per SHAPE
// because the door dispatches on shape: a fix that only reached the canonical decoder
// would leave the PostHog and team wires lying exactly as before, and those carry the
// SDK traffic that went missing.
//
// Every row here answered 200 {"accepted":0,"dropped":1} before this file existed.
func TestEveryWireShapeRefusesWhenNothingLands(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, tc := range []struct{ name, body string }{
{"canonical object", `{"event":"app.log","distinctId":"d1","properties":{"msg":"hi"}}`},
{"canonical object with time", `{"event":"app.log","distinctId":"d1","time":"2026-07-31T00:00:00Z"}`},
{"canonical array", `[{"event":"app.log","distinctId":"d1"}]`},
{"batch envelope", `{"batch":[{"type":"event","event":"app.log","distinctId":"d1"}]}`},
{"posthog single", `{"event":"app.log","distinct_id":"d1","properties":{"msg":"hi"}}`},
{"posthog batch", `{"batch":[{"event":"app.log","distinct_id":"d1","properties":{"msg":"hi"}}]}`},
{"team bare array", `[{"event":"app.log","distinct_id":"d1","timestamp":1750000000000}]`},
} {
t.Run(tc.name, func(t *testing.T) {
code, body := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", tc.body)
refusedAnon(t, tc.name, code, body)
})
}
}
// ── 2. what must NOT change ──────────────────────────────────────────────────
// TestAcceptedPathIsUnchanged: an anonymous pageview is still ADMITTED and still
// reaches the write core (503 in this warehouse-less harness). The fix is about the
// request that stored NOTHING; a request that stores something was never the problem.
func TestAcceptedPathIsUnchanged(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, tc := range []struct{ name, body string }{
{"bare object", `{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}`},
{"bare array", `[{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}]`},
{"batch envelope", `{"batch":[{"type":"pageview","event":"$pageview","distinctId":"anon-1"}]}`},
} {
code, body := postAnon(t, app, "/v1/event", tc.body, nil)
if code != http.StatusServiceUnavailable {
t.Errorf("anonymous pageview (%s) = %d (%s), want 503 ADMITTED — the fix must not "+
"narrow what the door accepts", tc.name, code, body)
}
}
}
// TestPartialBatchStillSucceeds is the OTHER half of "only a wholly-unattributable
// request is an error", and the reason the rule is `accepted == 0` and not `dropped > 0`.
// A batch mixing a storable pageview with a kind the projection refuses must still 200,
// with counts that add up — failing it whole would take a client's good events down with
// its bad one, which is a worse outage than the one being fixed.
func TestPartialBatchStillSucceeds(t *testing.T) {
roomyRate(t)
fakeWarehouse(t)
app := mountApp(t)
const mixed = `{"batch":[` +
`{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"},` +
`{"type":"event","event":"order_completed","revenue":99}]}`
code, body := postAnon(t, app, "/v1/event", mixed, nil)
if code != http.StatusOK {
t.Fatalf("partial batch = %d (%s), want 200 — some events landing is a success", code, body)
}
if r := receipt(t, body); r.Accepted != 1 || r.Dropped != 1 {
t.Fatalf("partial batch receipt = %+v, want accepted:1 dropped:1 — the counts still have "+
"to total what was sent", r)
}
}
// TestEmptyBodyStillSucceeds: nothing sent is not something lost. An empty body drops
// zero events, so there is no failure to report and the honest empty receipt stands.
func TestEmptyBodyStillSucceeds(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, body := range []string{``, ` `, `{"batch":[]}`, `[]`} {
code, got := postAnon(t, app, "/v1/event", body, nil)
if code != http.StatusOK {
t.Errorf("empty body %q = %d (%s), want 200 — dropping nothing is not losing anything",
body, code, got)
}
}
}
// TestOptedOutStillSucceeds: DNT is the ONE total drop that is not a failure. The client
// asked not to be tracked and the server obeyed, so there is nothing to fix and nothing
// to page on — turning it into a 4xx would make every privacy-respecting browser look
// like an outage.
func TestOptedOutStillSucceeds(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, h := range []map[string]string{{"DNT": "1"}, {"Sec-GPC": "1"}} {
code, body := postAnon(t, app, "/v1/event",
`{"batch":[{"type":"pageview","event":"$pageview","distinctId":"anon-1"}]}`, h)
if code != http.StatusOK {
t.Errorf("opt-out %v = %d (%s), want 200 — an honored opt-out is not a failed ingest",
h, code, body)
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("opt-out %v receipt = %+v, want accepted:0 dropped:1", h, r)
}
}
}
// ── 3. the reason names what the CALLER can do about it ──────────────────────
// TestFullCapabilityUnroutableBodyIs400: a caller holding a real credential is not
// missing a key, so 401 would send it after a second one to hit the identical wall. Its
// batch landed nothing because nothing in it was routable — a metric has no writer to
// drain it (plane_test.go) — so the BODY is the thing to change, and that is 400.
func TestFullCapabilityUnroutableBodyIs400(t *testing.T) {
fakeWarehouse(t)
app := mountApp(t)
code, body := doBody(t, app, http.MethodPost, "/v1/event", "user-dave", "acme",
`{"batch":[{"type":"metric","metric":{"name":"page_load_ms","value":812}}]}`)
refused(t, "authenticated unroutable batch", code, body, http.StatusBadRequest, "unroutable_events")
}
// TestGuestRefusalIsCapabilityNotCredential: a guest's workspace token RESOLVED. It has
// a credential and the credential is not the problem — it lacks capability into an org
// it was invited into for one channel. 401 "key required" would be a false instruction;
// 403 is the true one, and the code says which.
func TestGuestRefusalIsCapabilityNotCredential(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
app := mountApp(t)
guest := teamToken(t, "acme", "a-real-team-secret",
map[string]any{"role": token.RoleGuest}, time.Now().Add(time.Hour).Unix())
code, body := postAnon(t, app, "/v1/event",
`[{"event":"customEvent","properties":{"revenue":99999},"timestamp":1750000000000,"distinct_id":"u"}]`,
map[string]string{"Authorization": "Bearer " + guest})
refused(t, "guest whose every event was projected away", code, body,
http.StatusForbidden, "insufficient_capability")
}
// ── 4. the drop is READABLE, not merely true ─────────────────────────────────
// TestDropIsVisibleToAnAlert: a drop nobody can see is the defect in another costume.
// The receipt already carried an accurate count and three outages still ran unnoticed,
// so "the number is correct" is not the property that matters — "something watching can
// read it" is. hanzo_ingest_dropped_total is what an ingest-drop alert rule reads, and
// this collects it rather than trusting that an instrument nobody exercises works.
//
// The instrument binds through a sync.Once (production has one composition root that
// installs the provider before serving), so the test installs its own provider and
// resets the Once — the same substitution this package already makes for publicRate and
// the warehouse seam.
func TestDropIsVisibleToAnAlert(t *testing.T) {
reader := sdkmetric.NewManualReader()
prev := otel.GetMeterProvider()
otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))
dropOnce, dropCounter = sync.Once{}, nil
t.Cleanup(func() {
otel.SetMeterProvider(prev)
dropOnce, dropCounter = sync.Once{}, nil
})
roomyRate(t)
app := mountApp(t)
postAnon(t, app, "/v1/event", `{"event":"app.log","distinctId":"d1"}`, nil)
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
for _, s := range rm.ScopeMetrics {
for _, m := range s.Metrics {
if m.Name != "hanzo_ingest_dropped_total" {
continue
}
sum, ok := m.Data.(metricdata.Sum[int64])
if !ok || len(sum.DataPoints) != 1 {
t.Fatalf("hanzo_ingest_dropped_total = %#v, want one int64 data point", m.Data)
}
dp := sum.DataPoints[0]
reason, _ := dp.Attributes.Value("reason")
if dp.Value != 1 || reason.AsString() != "unattributable" {
t.Fatalf("drop point = %d reason=%q, want 1 unattributable", dp.Value, reason.AsString())
}
return
}
}
t.Fatal("hanzo_ingest_dropped_total was never recorded — an ingest-drop alert would have " +
"nothing to read, which is how a silent drop stays silent")
}
// ── 5. shape still wins over key ─────────────────────────────────────────────
// TestShapeDispatchSurvivesTheFix: isInsightsWire MUST keep running BEFORE the canonical
// object branch. Both wires spell a batch envelope `batch`, so routing on the key alone
// hands a PostHog batch to the CaptureBatch decoder — which cannot see `distinct_id` and
// yields events with no person and no kind, i.e. the very silent drop this file exists to
// end, reintroduced by the fix for it. The proof is at the decoder, where the dispatch
// lives: a PostHog body must come back carrying its person.
func TestShapeDispatchSurvivesTheFix(t *testing.T) {
for _, tc := range []struct{ name, body string }{
{"posthog single", `{"event":"$pageview","distinct_id":"ph-1"}`},
{"posthog batch", `{"batch":[{"event":"$pageview","distinct_id":"ph-1"}]}`},
} {
evs, err := decodeIngest([]byte(tc.body))
if err != nil || len(evs) != 1 {
t.Fatalf("decodeIngest(%s) = %d events, %v; want 1, nil", tc.name, len(evs), err)
}
if evs[0].DistinctID != "ph-1" {
t.Errorf("decodeIngest(%s) person = %q, want %q — the canonical decoder ate a PostHog "+
"body, which is exactly how a batch becomes personless and gets dropped whole",
tc.name, evs[0].DistinctID, "ph-1")
}
}
}
+57 -212
View File
@@ -22,12 +22,8 @@ import (
// doors_test.go — the ingest SURFACE is one set, and these are its proofs.
//
// Three things used to answer "what is an ingest door" independently: the route
// table, sites' analyticsPaths literal, and a path switch inside the carve. They
// disagreed — /v1/tracker and /v1/ingest were routed doors sites did not name, so the
// same beacon was admitted on an API host and 405'd on a site host. doors (event.go)
// is now the only answer and both surfaces derive from it; the tests below hold that
// shut from both ends.
// doors (event.go) is the only answer to "what is an ingest door"; the router derives
// from it, and the tests below hold that shut.
//
// Every gate assertion here is QUANTIFIED OVER doors rather than written against a
// path list, so a door added tomorrow inherits the whole contract instead of needing
@@ -84,8 +80,7 @@ func sameWire(a, b decode) bool { return samePtr(a, b) }
// named them, has no importer left in the fleet.
//
// /v1/tracker is retired FROM THIS PACKAGE only, and this list is scoped to this
// package's two surfaces (its own router and the carve it hands sites). The path
// itself belongs to the tracker product, which owns the prefix in the app manifest
// package's own router. The path itself belongs to the tracker product, which owns the prefix in the app manifest
// and keeps serving /v1/tracker/projects/… — analytics squatting the bare path is
// precisely what ends here. mountApp mounts analytics alone, so a 404 in this
// harness is the honest statement that ANALYTICS no longer answers there.
@@ -98,28 +93,6 @@ var retiredDoors = []string{
"/v1/analytics", "/v1/analytics/batch", "/v1/tracker",
}
// notDoors are paths that must never ingest: the read lenses, near-miss spellings, and
// the neighbouring subsystem's route. They are the paired negative for every positive
// below — widen the door lookup to a prefix, or give it a default case, and these go
// red.
//
// The last row is the deliberate strictness. c.Path() is the RAW request target —
// zip returns Fiber's path verbatim and nothing upstream unescapes or normalizes it
// (see resolveKey in clients/sites) — and the carve matches it BYTE-EXACTLY. So an
// encoded or denormalized spelling of a real door misses the carve and is served as
// static, even where Fiber's own router would still reach the door (POST /v1/event/
// routes on an API host and does not carve on a site host). That asymmetry is chosen,
// not overlooked: the carve hands a request a tenant derived from a Host, so it admits
// only the exact strings it was given, and every near-miss fails to the static serve.
// Normalizing here to match the router would widen a security-relevant exact set to
// chase a routing convenience — the same mistake as the prefix match this set replaced.
var notDoors = []string{
"/v1/analytics/overview", "/v1/analytics/timeseries", "/v1/analytics/top",
"/v1/analytics/health", "/v1/analytics/anything", "/v1/analytics/batch/extra",
"/v1/eventx", "/v1/insights/e/extra", "/v1/insights/events", "/v1/tracker/projects",
"/v1/%65vent", "/v1/event/", "//v1/event", "/v1/./event", "/v1/x/../event",
}
func doorPaths() []string {
p := make([]string, len(doors))
for i, d := range doors {
@@ -216,10 +189,9 @@ func TestWritePathSeamsDefaultToTheRealThing(t *testing.T) {
}
}
// tenants returns the org of every fact committed — the fact the site-host lane
// and the anonymous lane must disagree about, and the only place that disagreement
// is visible. The wide row died with hanzo.events, so the fact's own envelope is
// where the tenant stamp is read now.
// tenants returns the org of every fact committed — the only place the tenant a lane
// actually wrote is visible. The wide row died with hanzo.events, so the fact's own
// envelope is where the tenant stamp is read now.
func (w *warehouse) tenants(t *testing.T) []string {
t.Helper()
out := make([]string, 0, len(w.facts))
@@ -261,7 +233,7 @@ func sameSet(a, b []string) bool {
}
// admittedWire returns the body, from cands, that THIS door's own wire decodes into
// exactly one event the anonymous lane ADMITS. Picking the body through the door's
// exactly one event the PROJECTION admits. Picking the body through the door's
// real decoder + the real projection is what lets every test below quantify over
// doors without a per-wire lookup table beside it — the thing whose duplication
// caused the drift in the first place.
@@ -276,12 +248,12 @@ func admittedWire(t *testing.T, d door, cands ...string) string {
return b
}
}
t.Fatalf("no candidate body is admitted by the anonymous lane on door %s", d.path)
t.Fatalf("no candidate body is admitted by the projection on door %s", d.path)
return ""
}
// droppedWire is the twin: exactly one decoded event that the anonymous lane REFUSES
// (a commerce/custom kind), which is what proves capability rather than reachability.
// droppedWire is the twin: exactly one decoded event the PROJECTION refuses (a
// commerce/custom kind), which is what proves capability rather than reachability.
func droppedWire(t *testing.T, d door, cands ...string) string {
t.Helper()
for _, b := range cands {
@@ -293,7 +265,7 @@ func droppedWire(t *testing.T, d door, cands ...string) string {
return b
}
}
t.Fatalf("no candidate body is dropped by the anonymous lane on door %s", d.path)
t.Fatalf("no candidate body is dropped by the projection on door %s", d.path)
return ""
}
@@ -383,15 +355,9 @@ func TestIngestSurfaceIsExactlyTheContract(t *testing.T) {
// that reaches the ROW. Without it, source could be pinned in the table and dropped on
// the way to the warehouse and both halves would still look right.
//
// It quantifies over doors × HANDLERS, because a door has two of them and they stamp
// $source independently: ingest (the API host, via handle) and anon (the site host,
// which calls publicIngest directly). Driving only the ingest half left the anon half
// free to stamp a CONSTANT, and $source is precisely the signal the alias sunset is
// decided on — the documented rule is that a door may be retired when its $source
// volume reaches zero, so an anon lane that stamped 'event' for every door would read
// as "/v1/tracker is dead" while site-host callers were still beaconing it. The
// sunset is a delete-the-route decision made on this column; it has to be true on
// EVERY lane that writes it, not just the one a test happened to drive.
// $source is the signal the alias sunset is decided on — a door may be retired when
// its volume reaches zero — so the value declared in the table has to be the value
// that reaches the column.
func TestEveryDoorStampsItsOwnSource(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
@@ -401,103 +367,28 @@ func TestEveryDoorStampsItsOwnSource(t *testing.T) {
t.Fatalf("door %s = %d (%s), want 200 (written to the fake warehouse)", d.path, code, body)
}
if got := w.sources(t); len(got) != 1 || got[0] != d.source {
t.Errorf("door %s ingest lane wrote $source %v, want [%s]", d.path, got, d.source)
}
w = fakeWarehouse(t)
site := carveApp(t, "hanzo")
if code := postHost(t, site, "yadota.hanzo.app", d.path, pageviewFor(t, d), nil); code != http.StatusOK {
t.Fatalf("site-host door %s = %d, want 200 (admitted and written)", d.path, code)
}
if got := w.sources(t); len(got) != 1 || got[0] != d.source {
t.Errorf("door %s anon lane wrote $source %v, want [%s] — the sunset metric must name "+
"the door the beacon actually arrived through, on this lane too", d.path, got, d.source)
t.Errorf("door %s wrote $source %v, want [%s]", d.path, got, d.source)
}
}
}
// ── the site-host lane, which is the one that derives a tenant from a Host ───
// ── the tenant is the credential's, and a beacon without one writes nothing ──
// TestSiteHostLaneWritesTheResolvedSiteOrg is the tenant proof for the carve, and the
// reason the warehouse seam exists. Every declared door, POSTed to a LIVE site host,
// must write rows under the RESOLVED Site.Org — not the reserved public tenant, and
// not the org the request claims in a header or body.
//
// Paired failures, all of which used to pass unnoticed because the pipeline stopped at
// the readiness gate and every case answered 503: pass publicTenant instead of org and
// a customer's own site analytics land in a partition they cannot read; honour the
// caller's X-Org-Id and a stranger writes into any org they can name.
func TestSiteHostLaneWritesTheResolvedSiteOrg(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
w := fakeWarehouse(t)
app := carveApp(t, "hanzo")
if code := postHost(t, app, "yadota.hanzo.app", d.path, pageviewFor(t, d),
map[string]string{"X-Org-Id": "attacker", "X-User-Id": "attacker-user"}); code != http.StatusOK {
t.Fatalf("site-host door %s = %d, want 200 (admitted and written)", d.path, code)
}
got := w.tenants(t)
if len(got) != 1 || got[0] != "hanzo" {
t.Errorf("site-host door %s wrote tenants %v, want [hanzo] — the carve must file a "+
"beacon under the RESOLVED Site.Org", d.path, got)
}
for _, g := range got {
if g == publicTenant {
t.Errorf("site-host door %s filed the site's own beacon under %q, where its owner "+
"cannot read it", d.path, publicTenant)
}
if g == "attacker" {
t.Errorf("site-host door %s took the tenant from the caller's header", d.path)
}
}
}
}
// TestSiteHostLaneNeverConsultsHandle: on a site host the anonymous lane is reached
// DIRECTLY, and it has to be. sites.Middleware runs before the identity boundary, so
// X-User-Id / X-Org-Id there are still raw client headers that nothing has validated —
// exactly the shape SanitizeIdentity would have minted for a real bearer.
//
// So a request carrying them must still be PROJECTED. If door.anon consulted handle,
// those headers would resolve a principal and buy full capability, and the commerce
// payload would become a row under whatever org the caller named. The assertion is on
// the ROW, not the status: with a warehouse in place "admitted" is a 200 too, so a
// status check alone cannot tell the two lanes apart.
func TestSiteHostLaneNeverConsultsHandle(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
w := fakeWarehouse(t)
app := carveApp(t, "hanzo")
code := postHost(t, app, "yadota.hanzo.app", d.path, commerceFor(t, d),
map[string]string{"X-User-Id": "user-dave", "X-Org-Id": "acme"})
if code != http.StatusOK {
t.Fatalf("site-host door %s with raw identity headers = %d, want 200", d.path, code)
}
if got := w.tenants(t); len(got) != 0 {
t.Errorf("site-host door %s STORED a commerce payload under %v — the site-host lane "+
"consulted handle, so unvalidated headers bought full capability", d.path, got)
}
}
}
// TestApiHostAnonymousLaneWritesThePublicTenant is the other half of the tenant pair:
// on an API host a credential-less caller is the RESERVED public tenant, whatever Host
// it used. Together with the site-host test above, this is what makes each lane's
// tenant a checked fact rather than a comment — one must be $public and the other must
// not, so a change that collapses them fails on one side or the other.
func TestApiHostAnonymousLaneWritesThePublicTenant(t *testing.T) {
// TestApiHostAnonymousWritesNothing: a credential-less caller is REFUSED on every
// door, whatever Host it used, and reaches the warehouse not at all. There is no
// reserved tenant to fall back to — a row lands in the org a credential named or it
// does not land.
func TestApiHostAnonymousWritesNothing(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
for _, host := range []string{"api.hanzo.ai", "hanzo.ai"} {
w := fakeWarehouse(t)
app := mountApp(t)
if code, body := doHost(t, app, d.path, "", "", host, pageviewFor(t, d)); code != http.StatusOK {
t.Fatalf("anonymous door %s on %q = %d (%s), want 200", d.path, host, code, body)
}
got := w.tenants(t)
if len(got) != 1 || got[0] != publicTenant {
t.Errorf("anonymous door %s on host %q wrote tenants %v, want [%s] — no Host names a tenant",
d.path, host, got, publicTenant)
code, body := doHost(t, app, d.path, "", "", host, pageviewFor(t, d))
refusedAnon(t, "anonymous door "+d.path+" on host "+host, code, body)
if got := w.tenants(t); len(got) != 0 {
t.Errorf("anonymous door %s on host %q wrote tenants %v, want none — a beacon "+
"nobody can attribute must not reach the warehouse", d.path, host, got)
}
}
}
@@ -512,7 +403,7 @@ func TestRoutedPostSetIsExactlyTheDoors(t *testing.T) {
var posts []string
// GetRoutes(true) drops the `use` entries — middleware, which fiber keeps in the
// same stack as routes and reports under every method at the prefix it gates.
// cloud.Bridge is one of those (routes installs it so a typed op can read the
// cloud.Bridge is one of those (compose installs it so a typed op can read the
// validated org), and so is every middleware Serve installs app-wide, so an
// unfiltered read has never been "the POST surface" in the real binary either. A
// middleware is a passthrough, not a door: it dispatches nothing.
@@ -533,13 +424,13 @@ func TestRoutedPostSetIsExactlyTheDoors(t *testing.T) {
}
// TestEveryDoorIsRoutedAndAdmits is the positive half on the API host: each declared
// door actually exists (never 404) and reaches the write core for an admissible
// anonymous event (503, no datastore in the harness).
// door actually exists (never 404) and, for a credential that resolves, reaches the
// write core (503, no datastore in the harness).
func TestEveryDoorIsRoutedAndAdmits(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
for _, d := range doors {
code, body := doHost(t, app, d.path, "", "", "api.hanzo.ai", pageviewFor(t, d))
code, body := doBody(t, app, http.MethodPost, d.path, "user-dave", "acme", pageviewFor(t, d))
if code == http.StatusNotFound {
t.Errorf("door %s is declared but not routed (404)", d.path)
continue
@@ -550,20 +441,14 @@ func TestEveryDoorIsRoutedAndAdmits(t *testing.T) {
}
}
// TestRetiredDoorIsGoneFromBothSurfaces is the deletion proof, and it checks BOTH
// surfaces because deleting a route while leaving the carve entry (or the reverse) is
// the exact failure mode this whole change removes. A retired door must 404 on the API
// host and fall to the static serve (405) on a site host.
func TestRetiredDoorIsGoneFromBothSurfaces(t *testing.T) {
// TestRetiredDoorIsGone is the deletion proof: a retired door must 404 on the API host
// and be absent from the door table.
func TestRetiredDoorIsGone(t *testing.T) {
api := mountApp(t)
site := carveApp(t, "hanzo")
for _, p := range retiredDoors {
if code, body := doHost(t, api, p, "", "", "api.hanzo.ai", canonPageview); code != http.StatusNotFound {
t.Errorf("retired door %s is still routed on the API host: %d (%s)", p, code, body)
}
if code := postHost(t, site, "yadota.hanzo.app", p, canonPageview, nil); code != http.StatusMethodNotAllowed {
t.Errorf("retired door %s is still carved on a site host: %d (want 405, static serve)", p, code)
}
for _, d := range doors {
if d.path == p {
t.Errorf("retired door %s is still declared in doors", p)
@@ -572,60 +457,18 @@ func TestRetiredDoorIsGoneFromBothSurfaces(t *testing.T) {
}
}
// ── the carve set IS the door set ───────────────────────────────────────────
// TestSiteHostCarvesExactlyTheDoors is the reconciliation proof. On a live site host
// every declared door is carved to the anonymous lane under the SITE's org, and no
// non-door is — so the routed set (pinned exactly above) and the carved set are the
// same set. Before, they were not: /v1/tracker routed here and 405'd there.
//
// The negative half is the paired failure: hand sites anything other than the doors,
// or let its lookup fall back to a default, and a notDoors path starts carving.
func TestSiteHostCarvesExactlyTheDoors(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
app := carveApp(t, "hanzo")
// A forged org on the wire must not win — the tenant is the resolved Site's.
if code := postHost(t, app, "yadota.hanzo.app", d.path, pageviewFor(t, d),
map[string]string{"X-Org-Id": "attacker"}); code != http.StatusServiceUnavailable {
t.Errorf("door %s on a site host = %d, want 503 (carved, ingested for the site org)", d.path, code)
}
}
app := carveApp(t, "hanzo")
for _, p := range notDoors {
if code := postHost(t, app, "yadota.hanzo.app", p, canonPageview, nil); code != http.StatusMethodNotAllowed {
t.Errorf("non-door %s carved on a site host: %d (want 405, static serve)", p, code)
}
}
}
// TestSiteHostCarveNeedsAResolvedSite: the carve is gated on a Site actually
// resolving, not merely on the host looking like one. An unresolvable slug host falls
// to the static serve on EVERY door — no door turns an unbacked Host into a tenant.
func TestSiteHostCarveNeedsAResolvedSite(t *testing.T) {
app := carveApp(t, "hanzo") // the resolver knows only "yadota"
for _, d := range doors {
if code := postHost(t, app, "nosuchsite.hanzo.app", d.path, pageviewFor(t, d), nil); code == http.StatusServiceUnavailable {
t.Errorf("door %s ingested on an UNRESOLVED site host — the carve must require a resolved Site", d.path)
}
}
}
// ── the gate, quantified over every door ────────────────────────────────────
// TestEveryDoorFailsClosedOnUnresolvableCredential is THE admission gate. A caller that
// PRESENTED a credential which does not resolve is refused on every door — never
// silently downgraded into the anonymous lane, where its events would land in a
// partition its owner cannot read.
//
// Paired failure: delete handle's `if presented(c)` branch and every door answers 200
// or 503 instead of 403, and this fails on all of them at once.
// PRESENTED a credential which does not resolve is refused 403 on every door — never
// downgraded, because a downgrade files a misconfigured key's events where its owner
// cannot read them.
func TestEveryDoorFailsClosedOnUnresolvableCredential(t *testing.T) {
for _, d := range doors {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false })
for _, hdr := range []map[string]string{
{"x-api-key": "hk-nosuch"},
{"x-api-key": "sk-nosuch"},
{"Authorization": "Bearer pk-nosuch"},
{"x-hanzo-ingest-key": "pk-nosuch"},
} {
@@ -636,14 +479,13 @@ func TestEveryDoorFailsClosedOnUnresolvableCredential(t *testing.T) {
}
}
// TestEveryDoorProjectsTheAnonymousCaller is the capability gate. With no credential
// of any kind, on a RECOGNIZED BRAND HOST, a commerce payload must be dropped — never
// stored, and never at full capability into a real org.
// TestEveryDoorRefusesTheAnonymousCaller is the capability gate. With no credential of
// any kind, on a RECOGNIZED BRAND HOST, a commerce payload is refused — never stored,
// and never at full capability into a real org.
//
// 503 is the failure signal here, not the success one: it would mean the request
// reached the write core unprojected. Paired failure: give handle a host fallback, or
// let admitPublic see the org, and these turn 503.
func TestEveryDoorProjectsTheAnonymousCaller(t *testing.T) {
// reached the write core. Paired failure: give handle a host fallback and these turn 503.
func TestEveryDoorRefusesTheAnonymousCaller(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
for _, d := range doors {
@@ -654,20 +496,14 @@ func TestEveryDoorProjectsTheAnonymousCaller(t *testing.T) {
"credential-less caller must never write revenue/groupId/personId into a real org", d.path, host)
continue
}
if code != http.StatusOK {
t.Errorf("door %s on host %q = %d (%s), want 200 all-dropped", d.path, host, code, body)
continue
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("door %s on host %q receipt = %+v, want accepted:0 dropped:1", d.path, host, r)
}
refusedAnon(t, "door "+d.path+" on host "+host, code, body)
}
}
}
// TestEveryDoorAdmitsAValidatedPrincipal is the "the gate is not just a wall" half: a
// validated bearer keeps FULL capability on every door, so the commerce payload the
// anonymous lane drops is admitted here (503 = reached the write core).
// validated bearer keeps FULL capability on every door, so the commerce payload a
// credential-less caller is refused for is admitted here (503 = reached the write core).
func TestEveryDoorAdmitsAValidatedPrincipal(t *testing.T) {
app := mountApp(t)
for _, d := range doors {
@@ -677,14 +513,14 @@ func TestEveryDoorAdmitsAValidatedPrincipal(t *testing.T) {
}
}
// TestEveryDoorAdmitsAResolvedKey: the same for out-of-band keys — a resolvable hk-
// TestEveryDoorAdmitsAResolvedKey: the same for out-of-band keys — a resolvable sk-
// and a resolvable pk- both reach the write core at full capability on every door.
func TestEveryDoorAdmitsAResolvedKey(t *testing.T) {
for _, d := range doors {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "acme", true })
for _, hdr := range []map[string]string{
{"x-api-key": "hk-good"},
{"x-api-key": "sk-good"},
{"Authorization": "Bearer pk-good"},
} {
if code := postKeyed(t, app, d.path, "", commerceFor(t, d), hdr); code != http.StatusServiceUnavailable {
@@ -725,9 +561,18 @@ func TestEveryUntypedRouteDeclaresItsBodies(t *testing.T) {
}
continue
}
// Any declared media type counts: an asset route answers JavaScript, not
// JSON (openapi.Bytes), and requiring application/json here would force a
// document that lies about what the handler sets.
resp, ok := op.Responses["2XX"]
if !ok || len(resp.Content["application/json"].Schema) == 0 {
if !ok || len(resp.Content) == 0 {
t.Errorf("%s publishes no 2XX body schema", key)
continue
}
for media, m := range resp.Content {
if len(m.Schema) == 0 {
t.Errorf("%s publishes a 2XX %s with no schema", key, media)
}
}
_ = path
}
+296 -129
View File
@@ -34,7 +34,7 @@
// 2. a publishable key (pk-…) — IAM resolves it to its org; it can write but not read
// (the SAME key publishable.go mints; folded in here so a pk- caller uses
// /v1/event directly);
// 3. an out-of-band IAM access key (hk-/sk-…) — resolved through the ONE key seam
// 3. an out-of-band IAM access key (sk-…) — resolved through the ONE key seam
// (cloud.OrgForKey).
//
// One of those resolves ⇒ FULL capability into that credential's org, and that branch of
@@ -68,12 +68,16 @@ import (
"encoding/json"
"net/http"
"strings"
"time"
"sync"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/openapi"
planeops "github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
"go.opentelemetry.io/otel"
// attr, not attribute: this package already has an attribute() — the function that
// stamps a signed identity onto a reduced principal's rows (public.go).
attr "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Event is the canonical analytics event — the entire ingest contract in five
@@ -132,6 +136,13 @@ func (e Event) toCapture() CaptureEvent {
type admission struct {
org string
full bool
// project is the site the credential named, when it named one. Only a project
// key can: it is minted with a project and resolves to nothing else, so this is
// the one attribution the server can state rather than accept. It REPLACES the
// caller's `product` on every admitted row (attributeProject). Empty for the
// org-level credentials — a bearer and an IAM key name an org and no site, and
// an empty project honestly says "this write names no site".
project string
// subject is the credential's OWN signed identity. It is only consulted on the
// reduced lane, where it REPLACES the caller-supplied distinctId — see handle. It
// is empty for the full-capability credentials, which are trusted to attribute
@@ -143,12 +154,10 @@ type admission struct {
// in strict trust order:
//
// 1. a validated IAM bearer principal wins (its owner org), at FULL capability;
// 2. else a presented write-only publishable key (pk_…) is HMAC-verified to its org
// with no IAM/DB hop (the SAME verifier publishable.go's /v1/ingest used — folded
// in here so a pk_ caller uses /v1/event directly), at FULL capability;
// 3. else a presented out-of-band IAM access key (hk-/sk-…) is resolved to its org
// through the ONE key seam (resolveKeyOrg → cloud.OrgForKey), at FULL capability;
// 4. else a verified Hanzo Team workspace token — at FULL capability for a member,
// 2. else a presented key on either carrier resolves through keyAdmission — the
// project that minted it (org AND site), else the org IAM issued it to — at FULL
// capability;
// 3. else a verified Hanzo Team workspace token — at FULL capability for a member,
// and at REDUCED capability for a guest (teamTenant, team.go).
//
// None matches ⇒ (admission{}, false), which handle answers by refusing a presented-
@@ -169,13 +178,13 @@ func eventTenant(c *zip.Ctx) (admission, bool) {
// Safe only because a pk- no longer authenticates: IdentityFromRequest
// refuses it, so it attributes a write and never mints a reading principal.
if key := ingestKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return admission{org: org, full: true}, true
if a, ok := keyAdmission(c, key); ok {
return a, true
}
}
if key := projectKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return admission{org: org, full: true}, true
if a, ok := keyAdmission(c, key); ok {
return a, true
}
}
// A Hanzo Team workspace token (HS256 over SERVER_SECRET, org and role in the
@@ -195,6 +204,29 @@ func eventTenant(c *zip.Ctx) (admission, bool) {
return admission{}, false
}
// keyAdmission resolves ONE presented key, on either carrier, to what it names.
// Both carriers call it so they cannot drift into meaning different things by the
// same string.
//
// Two issuers, and they are DISJOINT rather than a fallback chain: a project key
// exists only in the project store and an IAM key only in IAM, so a lookup in one
// can never shadow the other and the order costs nothing but a miss. Projects are
// asked first because they answer a strictly narrower question — org AND site,
// where IAM can only ever say org, having no project to scope to.
//
// A project key is the credential a site's own beacon carries, so it also carries
// the property the whole change is for: it stops resolving the moment the project
// stops existing.
func keyAdmission(c *zip.Ctx, key string) (admission, bool) {
if sc, ok := resolveAttribution(c.Context(), key); ok {
return admission{org: sc.Org, project: sc.Project, full: true}, true
}
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return admission{org: org, full: true}, true
}
return admission{}, false
}
// firstNonWS returns the index of the first non-JSON-whitespace byte, or len(body)
// when the body is empty or all whitespace. The four bytes are JSON's insignificant
// whitespace (RFC 8259 §2). The ONE place the ingest decoders skip leading space.
@@ -348,13 +380,71 @@ func isTeamArray(body []byte, i int) bool {
// of one copy per door (which is exactly how the credential-less doors drifted).
type decode func([]byte) ([]CaptureEvent, error)
// refusal is what ADMISSION already refused before the write core ever saw it, and what
// that refusal MEANS — two halves of ONE fact, kept together so the receipt can never
// report a count with the wrong reason. Only the PROJECTED lanes produce one; the
// full-capability lane refuses nothing and passes the zero value, which is why its
// behavior is untouched.
//
// why is built ONLY when something was actually refused (publicIngest), so the accepted
// path allocates nothing it did not allocate before.
type refusal struct {
n int
why *zip.HTTPError // answered ONLY when nothing else landed
}
// cannotWrite names why a PROJECTED lane refused an event. The two answers are different
// facts about the caller, not two spellings of one, and getting it wrong sends a caller
// after the wrong fix:
//
// unsigned ⇒ 401. Nobody vouched for this request. These same events land with a key,
// so the missing key is the whole of it.
// signed ⇒ 403. A guest's workspace token RESOLVED — it has a credential and it is
// not the problem. What it lacks is capability into an org it was invited
// into for one channel. Telling it "key required" would send it to mint a
// second key and hit the identical wall.
func cannotWrite(signed bool) *zip.HTTPError {
if signed {
return &zip.HTTPError{
Status: http.StatusForbidden, Code: "insufficient_capability",
Msg: "no event could be stored: this credential may not write these events",
}
}
return &zip.HTTPError{
Status: http.StatusUnauthorized, Code: "ingest_key_required",
Msg: "no event could be attributed: an ingest key is required to write these events",
}
}
// cannotAttribute names why ADMISSION refused — the wall before cannotWrite's. Same
// two-answer shape and the same reason: the caller's next move differs.
//
// nothing presented ⇒ 401 ingest_key_required. The one code every client already
// branches on, so a beacon that lost its key reads the same
// whether it never had one or the projection dropped it.
// presented, unresolved ⇒ 403. It HAS a key; the key names no project. Minting
// another would hit the identical wall, so the fix named is the
// project, not the key.
func cannotAttribute(presented bool) *zip.HTTPError {
if presented {
return &zip.HTTPError{
Status: http.StatusForbidden, Code: "ingest_key_unknown",
Msg: "this ingest key names no project: create one (POST /v1/projects) and send the key it mints",
}
}
return &zip.HTTPError{
Status: http.StatusUnauthorized, Code: "ingest_key_required",
Msg: "no event could be attributed: create a project (POST /v1/projects) and send its key as ?ingest_key= or Authorization: Bearer",
}
}
// ingestDecoded is the TAIL of the ingest pipeline, and the ONE place it lives: fold
// type:'error' events (foldException) → the ONE write core (ingestEvents) → the honest
// receipt. Every lane ends here, so "what happens to an admitted event" is written
// once. org is the SERVER-resolved tenant; dropped is what admission already refused
// upstream (0 on the vouched-for lane, so its behavior is unchanged), added to the
// receipt so {accepted,dropped} always totals what the caller sent.
func ingestDecoded(c *zip.Ctx, org, source string, evs []CaptureEvent, dropped int) error {
// once. org is the SERVER-resolved tenant; refused is what admission already refused
// upstream (the zero value on the vouched-for lane, so its behavior is unchanged),
// added to the receipt so {accepted,dropped} always totals what the caller sent.
func ingestDecoded(c *zip.Ctx, org, source string, evs []CaptureEvent, refused refusal) error {
for i := range evs {
evs[i] = foldException(evs[i])
}
@@ -362,52 +452,130 @@ func ingestDecoded(c *zip.Ctx, org, source string, evs []CaptureEvent, dropped i
if err != nil {
return err
}
res.Dropped += dropped
// The admission receipt, counted. Every lane funnels through here, and the
// pair is reported together on purpose: the answerable question is a RATIO.
// "8,000 items were dropped" needs a second series before it means anything;
// "88% of what was offered was dropped" is an outage on its own — and that
// exact loss ran unnoticed because neither number left the response body.
cloud.ObserveIngest(source, res.Accepted, res.Dropped)
return c.JSON(http.StatusOK, res)
res.Dropped += refused.n
return answer(c, org, source, res, refused)
}
// presented reports whether the request PRESENTED an IDENTIFIABLE credential at all,
// independent of whether it resolved. It is the discriminator between "misconfigured"
// (refuse) and "anonymous" (project), and it names exactly the carriers eventTenant
// consults, so the two can never disagree about what "presented" means. When
// eventTenant learned about team tokens and this did not, they DID disagree, and the
// result was the precise failure the team door exists to prevent: an expired team
// token answered 200 with its rows filed under $public, a partition its org cannot
// read.
// answer is THE receipt, and the ONE place a door's ingest STATUS is decided. Every
// lane reaches it — the anonymous projection, the reduced team principal, the full
// credential, and the o11y plane's claim — so "what the caller is told happened" is
// written once, beside the counts it is derived from.
//
// WHY A KEY AND A TEAM TOKEN REFUSE, AND A STALE IAM BEARER DOES NOT. The asymmetry is
// a fact about what is DECIDABLE, not a preference:
// A 200 MEANT NOTHING, AND THAT IS WHAT MADE IT DANGEROUS. Every wire shape this door
// accepts, posted with no resolvable tenant, answered 200 {"accepted":0,"dropped":1}:
// the projection refuses a kind it cannot name (publicKinds — the anonymous lane stores
// pageviews and errors, and a log, a span and an exception envelope are none of those),
// and the receipt said so in a field nobody parses. A client whose key was absent,
// revoked or mistyped therefore lost 100% of what it sent while every status check it
// had stayed green — which is how an 88% log loss, a span outage that ran for four and a
// half months, and a day of missing Sentry traffic all went unnoticed. The counts were
// never wrong. The STATUS was, and the status is what clients and probes actually read.
//
// - an ingest key is self-identifying by PREFIX (pk-/hk-/sk-), and a team token is
// self-identifying by STRUCTURE (it carries an `account` claim, which an IAM token
// does not). For both, "the caller presented THIS kind of credential" is answerable
// without trusting anything, so a failure to resolve is unambiguously a
// misconfiguration and 403 is the honest answer.
// - an arbitrary `Authorization: Bearer <jwt>` is not distinguishable from a bearer
// minted for some other audience entirely. IdentityMiddleware already declines to
// 401 it (validatedPrincipal returns nil rather than refusing), so treating its
// mere presence as "presented" here would turn every stale or foreign bearer that
// reaches an ingest door into a 403 — a refusal on evidence we do not have.
// So the receipt now says what happened in the one field every HTTP client already
// understands, and the rule is exactly "did anything land":
//
// So: identifiable credential that fails ⇒ 403. Unidentifiable bearer ⇒ the anonymous
// lane, exactly as before this file learned about team tokens.
// WHY bearerAPIKey IS HERE AND ingestKey IS NOT WIDENED. ingestKey returns only a
// pk- so this door never SHADOWS the identity path: an hk-/sk- bearer is IAM's to
// validate, and it arrives here already resolved (tenant ⇒ full capability) or not
// at all. That is right, and it is not the question presented() asks. presented()
// asks whether the caller PRESENTED an identifiable credential, and an hk-/sk-
// bearer is identifiable by the SAME prefix authority every other carrier is judged
// by — so a FAILED one is a misconfiguration and must refuse, exactly as the same
// key refuses today on x-api-key. Without this it took the anonymous lane instead:
// 200, with the caller's rows filed under $public, a partition its owner cannot
// read. That is the precise silent-misfiling failure this function exists to
// prevent, reached through the one carrier every Hanzo caller reaches for first.
// accepted > 0 ⇒ 200. THE ACCEPTED PATH IS UNCHANGED, including the partial batch:
// some events landing is a success with an honest drop count beside
// it, and a batch is never failed whole for its worst element.
// nothing sent ⇒ 200. An empty body drops nothing, so nothing was lost.
// nothing landed ⇒ 4xx, naming the ONE thing the caller can do about it. Admission's
// refusal wins when there was one (cannotWrite: 401 with no
// credential, 403 for a guest that has one and lacks capability),
// because that is the caller's first wall. Otherwise the caller HAD
// capability and nothing was routable anyway — no name, or a signal
// no writer drains — so the BODY is what has to change: 400.
//
// The reason travels in HTTPError.Code, which is machine-readable and already on the
// wire for every other refusal on this API — an SDK branches on `ingest_key_required`
// without parsing prose.
//
// DNT IS NOT HERE, and must not move here: an opted-out request drops everything and
// still answers 200 (publicIngest). It is the one total drop that is not a failure —
// the client asked to be forgotten and the server obeyed, so there is nothing for the
// caller to fix and nothing for an alert to page on.
func answer(c *zip.Ctx, org, source string, res CaptureResult, refused refusal) error {
// The admission receipt, counted. Reported HERE rather than at each lane's tail
// for the same reason the status is decided here: every lane reaches this point,
// so the pair is emitted once. The pair travels TOGETHER on purpose — the
// answerable question is a RATIO. "8,000 items were dropped" needs a second
// series before it means anything; "88% of what was offered was dropped" is an
// outage on its own, and that exact loss ran unnoticed for months.
cloud.ObserveIngest(source, res.Accepted, res.Dropped)
if res.Dropped > 0 {
observeDropped(c, org, source, refused.n, res.Dropped-refused.n)
}
if res.Accepted > 0 || res.Dropped == 0 {
return c.JSON(http.StatusOK, res)
}
if refused.why != nil {
return refused.why
}
return &zip.HTTPError{
Status: http.StatusBadRequest, Code: "unroutable_events",
Msg: "no event could be stored: nothing in this body names a landable event",
}
}
// The ingest-drop instrument. It is resolved LAZILY for the reason metrics_http.go
// documents: the meter provider is installed by the composition root, so binding at
// init would attach every measurement to the no-op provider that exists before it runs
// and discard them while the code looks perfectly instrumented — the exact failure mode
// this counter exists to catch.
//
// CARDINALITY is bounded on all three labels: org is a SERVER-resolved tenant (an IAM
// owner, a resolved key's org, or the $public constant) and never a caller-chosen
// string; source is the door's own origin tag, from the finite doors table; reason is
// two values. Bounded by real orgs × doors × 2 — the same envelope hanzo_http_requests_total
// already lives in.
var (
dropOnce sync.Once
dropCounter metric.Int64Counter
)
// observeDropped makes a nonzero drop VISIBLE — the whole defect was that it was not.
// It emits per REASON rather than one total, because the two are different incidents:
// `unattributable` is a fleet of clients writing with no usable credential, and
// `unroutable` is one client sending bodies nothing can store. An alert that cannot
// tell them apart pages the wrong team.
//
// Both a counter and a log line, deliberately: the counter is what an alert rule reads
// (it reaches the telemetry store in-process, via apps/o11y's metrics push), and the
// log line is what names the tenant and door to whoever the alert wakes.
func observeDropped(c *zip.Ctx, org, source string, unattributable, unroutable int) {
dropOnce.Do(func() {
dropCounter, _ = otel.Meter("github.com/hanzoai/cloud/apps/analytics").Int64Counter("hanzo_ingest_dropped_total",
metric.WithDescription("Events an ingest door received and did not land, by tenant, door origin and reason."))
})
for _, d := range []struct {
n int
reason string
}{{unattributable, "unattributable"}, {unroutable, "unroutable"}} {
if d.n == 0 {
continue
}
if dropCounter != nil {
dropCounter.Add(context.Background(), int64(d.n), metric.WithAttributes(
attr.String("org", org),
attr.String("source", source),
attr.String("reason", d.reason),
))
}
c.Log().Warn("ingest dropped events", "org", org, "source", source, "reason", d.reason, "count", d.n)
}
}
// presented reports whether the request PRESENTED an IDENTIFIABLE credential at
// all, independent of whether it resolved. It picks which refusal handle answers:
// 403 (you sent one and it is broken) or 401 (you sent none — here is what to get).
// It names exactly the carriers eventTenant consults, so the two cannot disagree
// about what "presented" means.
//
// A key is identifiable by PREFIX (pk-/sk-) and a team token by STRUCTURE (an
// `account` claim an IAM token lacks), so a failure to resolve is decidably a
// misconfiguration. An arbitrary Bearer JWT is not distinguishable from one minted
// for another audience — IdentityMiddleware itself declines to 401 it — so it
// reads as "presented nothing", and its caller is told to get a key rather than
// that its key is broken.
func presented(c *zip.Ctx) bool {
return ingestKey(c) != "" || projectKey(c) != "" || bearerAPIKey(c) || teamPresented(c)
}
@@ -435,14 +603,23 @@ func bearerAPIKey(c *zip.Ctx) bool {
// itself full capability, and a door added tomorrow inherits this decision by
// construction rather than by remembering to copy it.
//
// credential resolves ⇒ FULL capability into THAT credential's org.
// credential resolves ⇒ FULL capability into THAT credential's org, and into
// the site it named when it named one.
// credential presented,
// does not resolve ⇒ 403. Never downgraded: filing a misconfigured key's
// events under the public tenant would hide them in a
// events under a reserved tenant would hide them in a
// partition its owner cannot read — a silent failure worse
// than the refusal.
// nothing presented ⇒ the ANONYMOUS lane (publicIngest): the projection, the
// kind allowlist, the size/rate bounds, the DNT gate.
// nothing presented ⇒ 401, naming the key to get and where to put it.
//
// THERE IS NO ANONYMOUS LANE. A keyless beacon used to be ACCEPTED into a reserved
// `$public` tenant and answered {"accepted":1} — an org could not read those rows,
// so every such caller lost everything it sent while every status check it had
// stayed green. Three first-party properties shipped keyless without one failed
// build, and a fleet-wide outage answered 200 for two days. A 200 that discards
// data is worse than a 4xx, so the lane is gone rather than gated: attribution is
// the key, a project mints one at create, and a write nobody can attribute is
// refused in the one field every client already reads.
//
// The first branch below is the ONLY unprojected write in this package. It is reached
// only from here, and only with an org eventTenant resolved from a credential — which
@@ -468,37 +645,34 @@ func handle(c *zip.Ctx, dec decode, source string) error {
// principal does not name the person; its token does.
return publicIngest(c, dec, a.org, source, a.subject)
}
// ONE door, every event kind: the observability plane gets first refusal
// on the canonical door's authenticated bodies. It lives in ANOTHER
// PROCESS (a plugin is a process), so the offer goes over the plane
// socket — a package global was written in o11y and read here as nil,
// which silently sent every LLM-obs batch down the product wire. Only
// the FULL lane offers: obs events are tenant data, so the anonymous and
// reduced projections never reach that plane.
// THE DOOR RUNS ITS OWN WIRE, and there is no longer anything in front of
// it. Every authenticated body used to be offered to the observability
// plane first (plane op obs_event_claim) so an LLM-observability batch
// could be filed in its own store instead of the product warehouse. That
// sink is deleted: it inserted UNQUALIFIED `traces`/`observations`/
// `scores` over a DSN naming no database, so the names resolved to
// `default` — where no migration in this platform has ever created them,
// and where the datastore's query log records no such INSERT, ever. The
// concept it claimed is served twice over already: LLM observability is
// READ off gen_ai spans in event.span by the o11y runtime, and the eval
// product owns the grounded projections (apps/eval/telemetry.go).
//
// FAIL-SOFT AND UNCLAIMED: any plane error (o11y absent, asleep past the
// wake budget, mid-restart) falls through to the product wire rather
// than failing the caller's ingest. A dropped claim costs one event in
// the obs store; a failed door costs every event.
if source == sourceEvent {
cctx, cancel := context.WithTimeout(cloud.For(c.Context(), a.org), obsClaimTimeout)
out, err := cloud.Ask[planeops.ObsClaimIn, planeops.ObsClaimed](cctx, peerO11y, planeops.ObsEventClaim,
&planeops.ObsClaimIn{Org: a.org, Body: c.Body()})
cancel()
if err == nil && out != nil && out.Claimed {
return c.JSON(http.StatusOK, CaptureResult{Accepted: out.Accepted, Dropped: out.Dropped})
}
}
// So the claim could only ever decline, at the cost of a synchronous
// cross-process round-trip per event on the fleet's busiest door.
//
// IT IS ALSO ONE FEWER LANE REACHING `answer`, and the receipt is the
// same either way. An LLM-obs-shaped body names no event kind, so the
// canonical decode yields nothing routable, and a caller holding a full
// credential that stored nothing is told 400 — which is exactly what the
// claim's own branch was made to answer. The lane that used to be
// exempt from the honest receipt is now the ordinary path through it.
evs, err := dec(c.Body())
if err != nil {
return zip.ErrBadRequest("malformed event payload")
}
return ingestDecoded(c, a.org, source, evs, 0)
return ingestDecoded(c, a.org, source, attributeProject(evs, a.project), refusal{})
}
if presented(c) {
return zip.ErrForbidden("valid bearer or a resolvable ingest key required")
}
return publicIngest(c, dec, publicTenant, source)
return cannotAttribute(presented(c))
}
// door is one ingest door: a PATH bound to the WIRE it speaks. Capability is not a
@@ -541,12 +715,10 @@ type door struct {
//
// - /v1/event — the canonical door and the canonical wire (Event | [Event] |
// {batch:[…]} | the team SPA's bare snake_case array, dispatched by shape —
// isTeamArray), which every current Hanzo client emits. The SAME door also
// carries LLM-observability ingestion batches: handle offers each
// authenticated body to the o11y plane's claim first (the plane op
// obs_event_claim, asked over the socket),
// which takes only {"batch":[{"type":"trace-create"|…}]} shapes — consumers
// and shapes behind ONE door, not more doors.
// isTeamArray), which every current Hanzo client emits. Nothing gets first
// refusal on it: the o11y plane's claim on this door (obs_event_claim) is
// retired with the sink behind it, so the wire the shape selects is the wire
// that runs — consumers and shapes behind ONE door, not more doors.
//
// - the PostHog wire has NO door of its own: decodeIngest dispatches it by
// shape (isInsightsWire), so PostHog SDKs land on /v1/event like everything
@@ -643,13 +815,11 @@ func decodeEvent(body []byte) ([]CaptureEvent, error) {
return decodeIngest(body)
}
// The observability peer and how long the door will wait on it. The budget is
// short on purpose: the claim is an OFFER on the hot ingest path, and a slow
// peer must degrade to the product wire rather than hold the caller.
const (
peerO11y = "o11y"
obsClaimTimeout = 3 * time.Second
)
// peerO11y is the observability peer this package asks over the plane socket.
// The ONE thing it is asked for is the Sentry relay (obs_error_post, whose
// budget is obsErrorTimeout in analytics.go): the Sentry wire's project segment
// is variable, so analytics has to own the route while o11y owns the runtime.
const peerO11y = "o11y"
var doors = []door{
{
@@ -658,6 +828,14 @@ var doors = []door{
description: "Stores pageviews, browser errors, identifies and custom commerce events as rows " +
"in the caller's own tenant, and answers a receipt {accepted, dropped} that always totals " +
"what was sent — a beacon is never silently discarded.\n\n" +
"THE STATUS SAYS WHETHER ANYTHING LANDED, so a green check can never mean an empty " +
"warehouse. 200 means at least one event was stored (or that nothing was sent), and a " +
"nonzero `dropped` beside a nonzero `accepted` is a PARTIAL batch, never a failed one — a " +
"batch is not refused whole for its worst element. If NOTHING was stored the request is an " +
"error, and it names the one thing that fixes it: 401 `ingest_key_required` when every " +
"event was refused for want of a credential (the same events land with a key), and 400 " +
"`unroutable_events` when the caller HAD capability and the body still named nothing " +
"storable.\n\n" +
"ONE door for every wire a Hanzo surface emits, dispatched by the SHAPE of the body and " +
"never by a second path: a bare event object, a bare array of them, the {batch:[…]} / " +
"{events:[…]} envelope, the team console's snake_case array, and the PostHog wire (spelled " +
@@ -674,30 +852,27 @@ var doors = []door{
"back always takes a real bearer. A Hanzo Team workspace token resolves its org at " +
"REDUCED capability: the signed " +
"account names the person, so a `distinctId` in the body cannot pin events on a colleague.\n\n" +
"NO CREDENTIAL IS ALSO ADMITTED, and that is the point — a logged-out visitor has none. " +
"Such a write is PROJECTED: filed under the reserved `$public` tenant, narrowed to what the " +
"SERVER can name — pageviews and errors, plus the closed autocapture vocabulary ($click, " +
"$input, $change, $submit, $view) — where EVERY one of those names is resolved through a " +
"server-owned table and stored as that table's value, so the name on the wire is never the " +
"name in the row. Stripped, too, to the fields the projection names, so revenue, personId, " +
"groupId and every property but the element annotation " +
"cannot reach a row — and an exception is carried only on an error, never on an " +
"interaction, so a click cannot ship a stack trace into a row's attributes. " +
"ITS IDENTITY IS NAMESPACED for the same reason the name is: nobody signed for it, so a " +
"`distinctId` off the wire is stored under a reserved `$anon:` prefix that no identified " +
"subject carries — an anonymous visitor still counts as one visitor, and still cannot be " +
"joined to a person the org knows. Everything refused is counted in `dropped`. On a " +
"published-site host " +
"the same projection applies with that site's org as the tenant. But a credential that IS " +
"presented and does NOT resolve is 403, never quietly downgraded: filing a misconfigured " +
"key's events under $public would hide them in a partition their owner cannot read.\n\n" +
"The anonymous lane alone is bounded: 413 over 64 KiB, 400 over 50 events, 429 on the " +
"NO CREDENTIAL IS REFUSED: a write the server cannot attribute to a project is 401 " +
"`ingest_key_required`, and a credential that IS presented but resolves to no project is " +
"403 `ingest_key_unknown`. Nothing is filed under a shared tenant — events nobody can " +
"read are worse than events nobody sent, because the caller is told it succeeded. A " +
"browser bundle therefore always ships a pk-, which is what /v1/event.js takes.\n\n" +
"A REDUCED principal — a Hanzo Team workspace token — writes through the PROJECTION into " +
"its own org: narrowed to what the SERVER can name (pageviews and errors, plus the closed " +
"autocapture vocabulary $click, $input, $change, $submit, $view), where every one of those " +
"names is resolved through a server-owned table and stored as that table's value, so the " +
"name on the wire is never the name in the row. Stripped, too, to the fields the projection " +
"names, so revenue, personId, groupId and every property but the element annotation cannot " +
"reach a row — and an exception is carried only on an error, never on an interaction, so a " +
"click cannot ship a stack trace into a row's attributes. It does NOT name the person: the " +
"signed account is the identity, so a `distinctId` in the body cannot pin events on a " +
"colleague. Everything refused is counted in `dropped`.\n\n" +
"The projected lane alone is bounded: 413 over 64 KiB, 400 over 50 events, 429 on the " +
"per-client-IP and per-peer caps, and a DNT:1 or Sec-GPC:1 request stores nothing and says " +
"so in the receipt. Two stored values carry their own bounds on top, because a request cap " +
"does not bound one value: an element annotation over 2 KiB (or a trail over 32 steps) and " +
"an exception class over 256 bytes are dropped from the row, which still lands. Where a " +
"deployment switches anonymous capture off, a credential-less " +
"write is 403 instead. Authenticated bodies are offered to the observability plane first, " +
"an exception class over 256 bytes are dropped from the row, which still lands. " +
"Authenticated bodies are offered to the observability plane first, " +
"which claims LLM-observability ingestion batches and declines everything else.",
},
}
@@ -822,11 +997,3 @@ const sentryWire = "\n\nCLOUD ROUTES IT AND READS NONE OF IT. The body is relaye
func (d door) ingest(_ *cloud.Service[state], c *zip.Ctx) error {
return handle(c, d.decode, d.source)
}
// anon is the door's SITE-HOST handler: the anonymous lane directly, with the
// resolved Site's org as the tenant. It does not consult handle because there is
// nothing to consult — sites.Middleware runs before the identity boundary, so no
// credential on a site host has been validated by anything (installHostCarve).
func (d door) anon(org string, c *zip.Ctx) error {
return publicIngest(c, d.decode, org, d.source)
}
-102
View File
@@ -1,102 +0,0 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"net/http"
"testing"
)
// appBeaconBody is the EXACT payload the published-site page beacon posts
// (app/lib/publishing/wired-injection.ts:68-69): a {batch:[CaptureEvent]} envelope.
// The only change the app makes is repointing ANALYTICS_ENDPOINT from /v1/analytics
// to /v1/event — the body is unchanged and MUST land via the canonical door's
// site-host carve.
const appBeaconBody = `{"batch":[{"messageId":"m-abc123","type":"pageview","event":"$pageview",` +
`"timestamp":"2026-07-22T12:00:00.000Z","distinctId":"anon-9","anonymousId":"anon-9",` +
`"sessionId":"sess-1","url":"https://yadota.hanzo.app/pricing","path":"/pricing",` +
`"referrer":"https://news.ycombinator.com/","properties":{"space":"yadota","title":"Pricing"},` +
`"library":"@hanzo/capture-wired","libraryVersion":"0.1.1"}]}`
// TestMount_HostCarve_EventDoorIngestsForSiteOrg is the /v1/event twin of
// TestMount_HostCarve_IngestsForSiteOrg: a beacon POST to the CANONICAL door on a LIVE
// site host is ingested for the site's Org in EVERY wire shape the tolerant decoder
// accepts, even though the request carries a forged org (body + X-Org-Id) and NO
// validated principal. 503 is the discriminator: it passed the door and stopped only at
// datastore-down, so the org came from the host.
//
// The kind is pageview because a site host is anonymous by construction (it runs before
// the identity boundary) and the anonymous lane admits pageview and error. A custom
// event on the same door is dropped — TestMount_HostCarve_AnonymousCapabilityOnly.
func TestMount_HostCarve_EventDoorIngestsForSiteOrg(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := carveApp(t, "hanzo")
for _, body := range []string{
`{"batch":[{"type":"pageview"}],"org":"evil"}`, // {batch} envelope
`{"events":[{"type":"pageview"}],"tenant_id":"evil"}`, // {events} alias
`{"batch":[{"type":"error","error":{"message":"x"}}]}`, // the other admitted kind
} {
code := postHost(t, app, "yadota.hanzo.app", "/v1/event", body,
map[string]string{"X-Org-Id": "attacker"})
if code != http.StatusServiceUnavailable {
t.Fatalf("/v1/event beacon %q want 503 (ingested for the site org), got %d", body, code)
}
}
// The BARE canonical Event wire ({event,distinctId,time,properties}) carries no
// `type` field at all, so canonicalType folds it to "event" — a kind the anonymous
// allowlist does not admit. On a site host, where nothing can be vouched for, the
// bare wire is therefore always dropped; a beacon that wants to record a pageview
// sends the {batch:[…]} envelope, which is exactly what the app's wired injection
// emits (appBeaconBody below).
for _, body := range []string{
`{"event":"signup_completed","distinctId":"d","org":"attacker"}`,
`[{"event":"signup_completed","distinctId":"d"}]`,
} {
if code := postHost(t, app, "yadota.hanzo.app", "/v1/event", body,
map[string]string{"X-Org-Id": "attacker"}); code != http.StatusOK {
t.Fatalf("/v1/event bare-Event beacon %q want 200 (kind not anonymously admitted), got %d", body, code)
}
}
}
// TestMount_HostCarve_AppBeaconExactBody confirms the CANONICAL door accepts the
// APP beacon's EXACT {batch:[ev]} body via the site-host carve — the acceptance test
// for repointing ANALYTICS_ENDPOINT to /v1/event. Admitted (503, datastore down),
// tenant forced to the site's Org regardless of the beacon's properties.space claim.
func TestMount_HostCarve_AppBeaconExactBody(t *testing.T) {
app := carveApp(t, "hanzo")
code := postHost(t, app, "yadota.hanzo.app", "/v1/event", appBeaconBody, nil)
if code != http.StatusServiceUnavailable {
t.Fatalf("exact app beacon on /v1/event want 503 (admitted via carve), got %d", code)
}
}
// TestMount_HostCarve_EventEmptyBatchOK: an empty beacon batch on the canonical door
// is an honest 200 (zero counts) BEFORE the datastore is consulted — proving the
// carve decodes and funnels through the ONE write core with the host-forced org.
func TestMount_HostCarve_EventEmptyBatchOK(t *testing.T) {
app := carveApp(t, "hanzo")
if code := postHost(t, app, "yadota.hanzo.app", "/v1/event", `{"batch":[]}`, nil); code != http.StatusOK {
t.Fatalf("empty beacon batch on /v1/event want 200, got %d", code)
}
}
// TestMount_HostCarve_EventDirectNoHostGetsNoOrg pins that the forced-org carve is
// HOST-scoped: the SAME body on a NON-site host does not get a site org. The carve did
// not fire, so the request runs the normal canonical gate — no principal and no key, so
// it takes the ANONYMOUS lane, where the forged X-Org-Id and the custom event kind both
// buy nothing: 200 with an all-dropped receipt, no row under `attacker`.
func TestMount_HostCarve_EventDirectNoHostGetsNoOrg(t *testing.T) {
app := carveApp(t, "hanzo")
code := postHost(t, app, "evil.example.com", "/v1/event",
`{"event":"signup_completed","distinctId":"d"}`, map[string]string{"X-Org-Id": "attacker"})
if code != http.StatusOK {
t.Fatalf("anonymous /v1/event on a non-site host want 200 (anonymous lane, kind dropped), got %d", code)
}
}
+27 -39
View File
@@ -105,7 +105,7 @@ func TestEventNormalizeThroughCore(t *testing.T) {
if !ok {
t.Fatal("want routable")
}
if f.org != "acme" || f.name != "signup" || f.signal != signalEvent || f.kind != kindTrack {
if f.org != "acme" || f.name != "signup" || f.signal != signalAct || f.kind != kindTrack {
t.Fatalf("fact = org %q name %q signal %q kind %q", f.org, f.name, f.signal, f.kind)
}
}
@@ -181,22 +181,19 @@ func TestSourceStampedIntoAttributes(t *testing.T) {
// ADMITTED one reaches requireDatastore and returns 503 (no datastore in tests).
// So "not 403" ⇒ the tenant gate admitted the request.
// TestEvent_NoPrincipalNoKeyIsAnonymous: a caller with NO principal and NO key is not
// refused — it takes the anonymous lane (public.go), attributed to the reserved public
// tenant. The canonical-Event wire carries no `type`, so canonicalType folds it to
// "event", which is not on the anonymous allowlist: the request is answered 200 with an
// honest all-dropped receipt. What IS refused is a presented credential that does not
// resolve (TestEvent_UnresolvableKeyFailsClosedEvenOnBrandHost).
func TestEvent_NoPrincipalNoKeyIsAnonymous(t *testing.T) {
// TestEvent_NoPrincipalNoKeyIsRefused: a caller with NO principal and NO key is
// refused AT THE GATE — 401 ingest_key_required, whatever it sent. A pageview is not
// a special case any more: there is no lane that stores an unattributable event.
// What a PRESENTED but unresolvable credential gets is 403
// (TestEvent_UnresolvableKeyFailsClosedEvenOnBrandHost).
func TestEvent_NoPrincipalNoKeyIsRefused(t *testing.T) {
app := mountApp(t)
code, body := doBody(t, app, http.MethodPost, "/v1/event", "", "", `{"event":"e","distinctId":"d"}`)
if code != http.StatusOK {
t.Fatalf("no-principal no-key /v1/event want 200 (anonymous lane, kind dropped), got %d (%s)", code, body)
}
// A pageview on the same credential-less request IS stored — it reaches the
// warehouse (503 here, no datastore in the harness).
if code, body := doBody(t, app, http.MethodPost, "/v1/event", "", "", `{"batch":[{"type":"pageview"}]}`); code != http.StatusServiceUnavailable {
t.Fatalf("anonymous pageview want 503 (admitted), got %d (%s)", code, body)
for _, body := range []string{
`{"event":"e","distinctId":"d"}`,
`{"batch":[{"type":"pageview"}]}`,
} {
code, got := doBody(t, app, http.MethodPost, "/v1/event", "", "", body)
refusedAnon(t, "no-principal no-key "+body, code, got)
}
}
@@ -215,9 +212,9 @@ func TestEvent_BearerPrincipalAdmitted(t *testing.T) {
func TestEvent_ResolvedKeyAdmitted(t *testing.T) {
app := mountApp(t)
got := stubResolver(t, func(string) (string, bool) { return "acme", true })
code := postKeyed(t, app, "/v1/event", "", `{"api_key":"hk-k","event":"e","distinctId":"d"}`, nil)
if *got != "hk-k" {
t.Fatalf("resolver handed key %q, want hk-k", *got)
code := postKeyed(t, app, "/v1/event", "", `{"api_key":"sk-k","event":"e","distinctId":"d"}`, nil)
if *got != "sk-k" {
t.Fatalf("resolver handed key %q, want sk-k", *got)
}
if code == http.StatusForbidden {
t.Fatalf("a resolved access key must pass the /v1/event gate, got 403")
@@ -227,21 +224,19 @@ func TestEvent_ResolvedKeyAdmitted(t *testing.T) {
func TestEvent_UnresolvableKeyFailsClosedEvenOnBrandHost(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false })
code := postKeyed(t, app, "/v1/event", "hanzo.ai", `{"api_key":"hk-bad","event":"e","distinctId":"d"}`, nil)
code := postKeyed(t, app, "/v1/event", "hanzo.ai", `{"api_key":"sk-bad","event":"e","distinctId":"d"}`, nil)
if code != http.StatusForbidden {
t.Fatalf("presented-but-unresolvable key on /v1/event must 403 (fail closed), got %d", code)
}
}
// TestEvent_NoBrandHostFallback is THE invariant, and it now holds on EVERY door rather
// than only the canonical one: the request Host NEVER selects a tenant. It used to be a
// distinction — /v1/event ignored the Host while the deprecated aliases resolved
// anonymous traffic on a recognized brand host to that BRAND's REAL org, a real tenant
// picked by a caller-settable header. That was the hole; every door now takes the same
// anonymous lane, so the Host buys nothing anywhere.
// TestEvent_NoBrandHostFallback is THE invariant: the request Host NEVER selects a
// tenant. It used to — the deprecated aliases resolved anonymous traffic on a
// recognized brand host to that BRAND's REAL org, a real tenant picked by a
// caller-settable header.
//
// A pageview is admitted identically on a brand host and on an unrelated one, and the
// commerce payload the brand fallback used to wave through is dropped on both.
// Now a Host buys nothing anywhere because there is nothing to buy: without a
// credential every door refuses, brand host or not.
func TestEvent_NoBrandHostFallback(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
@@ -249,16 +244,9 @@ func TestEvent_NoBrandHostFallback(t *testing.T) {
commerce := `{"batch":[{"type":"event","event":"order_completed","revenue":999}]}`
path := canonDoor
for _, host := range []string{"hanzo.ai", "zoo.ngo", "evil.example.com"} {
if code, body := doHost(t, app, path, "", "", host, pageview); code != http.StatusServiceUnavailable {
t.Fatalf("anonymous pageview %s on host %q want 503 (admitted to the public tenant), got %d (%s)",
path, host, code, body)
}
code, body := doHost(t, app, path, "", "", host, commerce)
if code != http.StatusOK {
t.Fatalf("anonymous commerce %s on host %q want 200 all-dropped, got %d (%s)", path, host, code, body)
}
if r := receipt(t, body); r.Accepted != 0 || r.Dropped != 1 {
t.Fatalf("anonymous commerce %s on host %q receipt = %+v, want accepted:0 dropped:1", path, host, r)
}
code, body := doHost(t, app, path, "", "", host, pageview)
refusedAnon(t, "anonymous pageview "+path+" on host "+host, code, body)
code, body = doHost(t, app, path, "", "", host, commerce)
refusedAnon(t, "anonymous commerce "+path+" on host "+host, code, body)
}
}
+355 -224
View File
@@ -18,33 +18,35 @@
// fact; bus.go owns the container. Nothing here knows about NATS, and nothing here
// knows about SQL — normalize is pure, so the tests drive it directly.
//
// ONE NAME FOR A THING, ACROSS TRANSPORT AND STORAGE. A signal's subject and its table
// are the same string, derived from the same constant, so they cannot drift:
// ONE TABLE, DISCRIMINATED BY A COLUMN. Every occurrence — a click, a crash, a log
// line, a span, a replay clip — is one row of event.fact, and `signal` says which sort
// it is. It was five tables with an identical envelope, which is CONSISTENT but not
// UNIFIED: no cross-signal question could be asked without a five-way UNION ALL, and a
// new product meant a new table name, which is how a namespace grows a `_v2`.
//
// subject event.error -> table event.error
// subject event.span -> table event.span
// subject event.log -> table event.log
// subject event.event -> table event.event
// subject event.metric -> table event.metric
// A SIGNAL IS A VALUE, NOT A PLACE. That is the whole of the change. `clip` — the
// session-replay index — is the worked example: it costs one signal value and two
// columns (object, bytes), reuses duration/session_id/url, and adds no name to the
// namespace at all. Under the old shape it would have been a table, and then a
// session-summary table, and then a partition-statistics table beside it.
//
// SIGNAL AND KIND ARE DIFFERENT THINGS, and conflating them is the mistake this file
// exists to prevent. The SIGNAL picks the table (and the subject). The KIND is a COLUMN
// on it — the discriminator WITHIN a signal:
// exists to prevent. The SIGNAL picks the partition (and the subject, and the durable).
// The KIND is a COLUMN — the discriminator WITHIN a signal:
//
// - on event.event, kind is track | page | identify | group. Those are things a CALLER
// DOES (event.Track(), event.Page()), not durable types, so they are a column value
// and never a table. `name` carries the specific (button_clicked, page_viewed).
// - on event.span, kind is the OTel span kind (server | client | internal | …).
// - on event.error and event.log the caller owns the sub-vocabulary, so kind is
// whatever it stated and otherwise EMPTY. Defaulting it to the signal name would
// make a column that always equals its own table — information-free.
// - on act, kind is track | page | identify | group. Those are things a CALLER DOES
// (event.Track(), event.Page()), not durable types, so they are a column value and
// never a table. `name` carries the specific (button_clicked, page_viewed).
// - on span, kind is the OTel span kind (server | client | internal | …).
// - on error, log and clip the caller owns the sub-vocabulary, so kind is whatever it
// stated and otherwise EMPTY. Defaulting it to the signal name would make a column
// that always equals its own discriminator — information-free.
//
// SEPARATE TABLES, ONE ENVELOPE. Each table has its own ORDER BY because each is read
// differently (event by (org,time) for funnels, error by (org,group,time) for issue
// lists, log by (org,service,time), span by (org,trace_id) to assemble a trace) — the
// sort key is the reason they are separate, not sparse columns, which the store
// compresses away. They are held together by an IDENTICAL envelope: same names, same
// semantics, so a cross-signal correlation is a UNION ALL and not a translation layer.
// TWO GRAINS, TWO TABLES, AND NO MORE. An occurrence HAPPENED; a sample was MEASURED.
// They are 326:1 in row count, they key differently (a sample has no id, no name, no
// session) and rate()/increase() need a fingerprint-major ordering that no time-ordered
// key can express. So `sample` lands in event.sample and everything else in event.fact
// — see writers (warehouse.go), which is the one place that mapping is written down.
//
// The org is NOT on the wire. It is stamped here from the SERVER-resolved tenant, so a
// caller can only ever write into its own partition — the one tenancy invariant, in the
@@ -63,29 +65,53 @@ import (
// plane is the database, the subject root and — with the case NATS conventionally
// gives a stream — the stream name. ONE word at three layers: no brand ("hanzo."), no
// numeronym ("o11y_"), no product name ("insights"/"analytics"), no version suffix.
// A query reads FROM error because the connection's default database is this one.
const plane = "event"
// signal names the KIND OF FACT, which is what picks a table and a subject. It is not
// the `kind` column — see the file header. The zero value is deliberately not a valid
// signal so an unrouted fact cannot silently become an event.
// signal names the KIND OF FACT. It picks the subject, the durable and the partition.
// It is not the `kind` column — see the file header. The zero value is deliberately not
// a valid signal so an unrouted fact cannot silently become an act.
type signal string
const (
signalEvent signal = "event" // a product event: track | page | identify | group
signalError signal = "error" // a thrown/reported failure
signalLog signal = "log" // a log record
signalSpan signal = "span" // one span of a trace
signalMetric signal = "metric" // one metric sample
// signalAct is something a person or a surface DID: track | page | identify |
// group. It is `act` and not `event` because `event` is the NAMESPACE — a value
// cannot also be the set it belongs to, and `event.fact WHERE signal='event'` is
// exactly the stutter that reads as a schema nobody finished naming.
signalAct signal = "act"
// signalClip is one recorded slice of a session. The row is the INDEX — where the
// blob lives and how big it is. The blob itself never travels on the bus and never
// lands in a column: it is a multi-megabyte time-ordered binary, wrong for a
// message and wrong for a row.
signalClip signal = "clip"
// signalError is a thrown or reported failure.
signalError signal = "error"
// signalLog is a log record.
signalLog signal = "log"
// signalSpan is one span of a trace.
signalSpan signal = "span"
// signalSample is one measurement. Different grain, different table — see the
// header, and writers (warehouse.go) for why the door refuses it today.
signalSample signal = "sample"
)
// subject is the bus subject this signal travels on, and table is the warehouse table
// it lands in. They are the SAME name by construction — one string, two layers — which
// is the whole point of deriving both here instead of writing either down twice.
// subject is the bus subject a signal travels on, and it is the durable name a consumer
// binds. Derived from the signal so a new signal cannot be published to a name nothing
// filters for.
//
// It is no longer also the TABLE name. It was, and that was the right shape when a
// signal was a table; now one table holds five signals, so what the two share is the
// discriminator VALUE rather than the identifier. The sink says where a signal lands,
// once, in writers.
func (s signal) subject() string { return plane + "." + string(s) }
func (s signal) table() string { return plane + "." + string(s) }
// The `kind` vocabulary of event.event: what a caller DID. These are column values.
// The two tables of the plane, named for their GRAIN. Everything else in the namespace
// is a rollup of one of them or a dimension beside it.
const (
factTable = plane + ".fact" // one row per thing that HAPPENED
sampleTable = plane + ".sample" // one row per thing MEASURED
)
// The `kind` vocabulary of act: what a caller DID. These are column values.
const (
kindTrack = "track"
kindPage = "page"
@@ -96,8 +122,8 @@ const (
// kindInternal is OTel's default span kind, used when a span states none.
const kindInternal = "internal"
// route is the pair a wire event resolves to: the signal that picks the table, and the
// kind column on it. It is a comparable value so the anonymous lane's allowlist is a
// route is the pair a wire event resolves to: the signal that picks the partition, and
// the kind column on it. It is a comparable value so the anonymous lane's allowlist is a
// map lookup rather than a chain of conditions (public.go).
type route struct {
signal signal
@@ -110,7 +136,7 @@ type route struct {
// spell() and the event routes identically, carrying no other caller spelling along.
// routeSpellRoundTrips pins that inverse.
func (r route) spell() string {
if r.signal != signalEvent {
if r.signal != signalAct {
return string(r.signal)
}
switch r.kind {
@@ -121,14 +147,18 @@ func (r route) spell() string {
case kindGroup:
return kindGroup
default:
return string(signalEvent)
return "event"
}
}
// routeOf maps the wire's ONE `type` field onto a route. `type` selects the ROUTE and
// nothing else — one field, one job; a signal's own body may refine the kind afterwards
// (a span's OTel kind, an error's level). An unknown or absent type is a tracked
// product event, which is what every pre-existing caller meant by omitting it.
// (a span's OTel kind). An unknown or absent type is a tracked act, which is what every
// pre-existing caller meant by omitting it.
//
// The wire word `event` is still accepted and still means an act: it is what every
// deployed client sends and it is a PUBLISHED spelling, so it maps rather than moves.
// What changed is the name of the thing it maps ONTO.
func routeOf(e CaptureEvent) route {
t := strings.ToLower(strings.TrimSpace(e.Type))
// AN EVENT CARRYING AN EXCEPTION IS AN ERROR, whatever it called itself — and a
@@ -140,21 +170,23 @@ func routeOf(e CaptureEvent) route {
}
switch t {
case "pageview", "page":
return route{signalEvent, kindPage}
return route{signalAct, kindPage}
case "identify":
return route{signalEvent, kindIdentify}
return route{signalAct, kindIdentify}
case "group":
return route{signalEvent, kindGroup}
return route{signalAct, kindGroup}
case "error", "exception":
return route{signal: signalError}
case "log":
return route{signal: signalLog}
case "span":
return route{signalSpan, kindInternal}
case "metric":
return route{signal: signalMetric}
case "clip", "replay":
return route{signal: signalClip}
case "metric", "sample":
return route{signal: signalSample}
default:
return route{signalEvent, kindTrack}
return route{signalAct, kindTrack}
}
}
@@ -178,45 +210,6 @@ func (a annotation) empty() bool {
a.name == "" && a.component == "" && len(a.path) == 0
}
// envelope is the IDENTICAL 15-column head of every signal — same names, same
// semantics, in the same positions. ingested_at is NOT here: the server stamps it
// through a column DEFAULT, so nothing on the wire can influence when a row expires
// (retention is measured from it). That is the same reason it was kept off the old
// insert list, and it is the only column that can carry a TTL honestly.
type envelope struct {
org string
time time.Time
id string
name string
kind string
product string
session string
distinct string
anonymous string
person string
url string
path string
attributes map[string]string
el annotation
}
// fault is what event.error adds to the envelope: the failure's identity (class,
// message, the grouping fingerprint) and its frames.
type fault struct {
group string // the deterministic fingerprint — see fingerprint()
message string
class string
site string
handled bool
level string
release string
environment string
service string
trace string
span string
frames []frame
}
// frame is one stack frame, stored across the parallel frames.* arrays. `own` marks
// first-party code, which is what makes an issue list readable: a browser extension or
// a vendor bundle at the top of a stack is noise, not the fault's location.
@@ -228,45 +221,91 @@ type frame struct {
own bool
}
// record is what event.log adds: the OTel log-record fields.
type record struct {
service string
severity string
number uint8
body string
trace string
span string
resource uint64
}
// span is what event.span adds: the trace linkage and the timing.
type span struct {
service string
trace string
id string
parent string
duration uint64 // nanoseconds
status string
}
// sample is what event.metric carries. It is normalized and PUBLISHED like every other
// signal, but it is deliberately NOT warehoused — see writers (warehouse.go) for why
// and for the exact fix.
// sample is what event.sample carries. It is normalized and PUBLISHED like every other
// signal, but it lands in the OTHER table — see writers (warehouse.go) for what the
// door does with it today and what makes it landable.
type sample struct {
metric string
value float64
labels map[string]string
}
// fact is one normalized signal: the shared envelope, the signal that routes it, and
// exactly the one body that signal carries. Every lane produces these and nothing else,
// so "what is an event" has a single answer on the wire, on the bus and in the store.
// fact is one normalized occurrence, FLAT, because the table is flat. Its fields are
// the column names.
//
// It was an envelope plus one of four optional bodies, which was the right shape when
// each body had its own table. With one table a body is just the subset of columns a
// signal populates, and a pointer per signal would be a second description of the same
// thing — the one every reader would then have to hold alongside the columns.
//
// SPARSITY IS NOT A COST, measured rather than assumed: on the live event.log
// (798,375 rows) an unpopulated column costs 515 bytes for the WHOLE table. Six of
// them is ~3 KiB against 48 MiB. The instinct that a wide row wastes space is a
// row-store instinct.
type fact struct {
envelope
// ── spine ────────────────────────────────────────────────────────────────
org string // THE tenant: the IAM org slug, stamped server-side. Never on the wire.
signal signal
fault *fault
record *record
span *span
time time.Time
id string
// ── what ─────────────────────────────────────────────────────────────────
name string
kind string
// message is one column because a log's BODY is an error's MESSAGE: the human
// text of what happened. Two names for it is how two spellings of one fact begin.
message string
// severity is the OTLP number (1..24) and the ONLY spelling of it. A row cannot
// carry a number and a word that disagree; the word is severityText(), read-time.
severity uint8
duration uint64 // nanoseconds — a span's, and a clip's
// ── where ────────────────────────────────────────────────────────────────
product string // the emitting SURFACE. A Sentry "project" is this.
env string
service string
release string
url string
path string
// ── who ──────────────────────────────────────────────────────────────────
person string
distinct string
anonymous string
// groups is group-type -> group-key. A Map, not group0..group4: five positional
// slots are a sixth slot waiting to become a `_v2`.
groups map[string]string
// ── correlation ──────────────────────────────────────────────────────────
session string
trace string
span string
parent string
resource string // resource fingerprint; joins the *_resource dimensions
// ── open ─────────────────────────────────────────────────────────────────
attributes map[string]string
el annotation
// ── error ────────────────────────────────────────────────────────────────
issue string // the deterministic grouping fingerprint — see fingerprint()
class string
origin string // the place of the fault (Sentry's culprit)
handled bool
frames []frame
// ── span ─────────────────────────────────────────────────────────────────
status string
// ── clip ─────────────────────────────────────────────────────────────────
object string // object-store address of the blob. The blob is never in the row.
bytes uint64
// ── the other grain ──────────────────────────────────────────────────────
// sample is the ONE body that is not a subset of the occurrence columns, because
// a measurement is not an occurrence: it has a value and no identity. It lands in
// event.sample, so it is carried as a pointer rather than flattened into columns
// that would be empty on every occurrence row.
sample *sample
}
@@ -284,40 +323,48 @@ func normalize(org string, now time.Time, e CaptureEvent) (fact, bool) {
}
props := scrubMap(e.Properties)
f := fact{
signal: r.signal,
envelope: envelope{
org: org,
time: clampTS(e.Timestamp, now),
id: firstNonEmptyStr(strings.TrimSpace(e.MessageID), randID()),
name: name,
kind: firstNonEmptyStr(trim(e.Kind), r.kind),
product: trim(e.Product),
session: trim(e.SessionID),
distinct: trim(e.DistinctID),
anonymous: trim(e.AnonymousID),
person: trim(e.PersonID),
url: trim(e.URL),
path: trim(e.Path),
el: annotationOf(props),
},
signal: r.signal,
org: org,
time: clampTS(e.Timestamp, now),
id: firstNonEmptyStr(strings.TrimSpace(e.MessageID), randID()),
name: name,
kind: firstNonEmptyStr(trim(e.Kind), r.kind),
product: trim(e.Product),
session: trim(e.SessionID),
distinct: trim(e.DistinctID),
anonymous: trim(e.AnonymousID),
person: trim(e.PersonID),
url: trim(e.URL),
path: trim(e.Path),
el: annotationOf(props),
// The qualifiers every signal shares. They were duplicated onto the error body
// alone, which is what let sentry.hanzo.ai group faults by release while the
// same fact on the event stream had no release at all.
env: trim(e.Environment),
service: trim(e.Service),
release: trim(e.Release),
origin: trim(e.Site),
trace: trim(e.TraceID),
span: trim(e.SpanID),
groups: groupsOf(e),
}
// Everything that is not an envelope column and not the annotation travels in
// attributes. The old wide table gave utm_*, referrer, revenue, channel and the
// rest their own columns; they are the same facts under the same names, in the one
// place a caller's own vocabulary belongs.
// Everything that is not a column and not the annotation travels in attributes.
// The old wide table gave utm_*, referrer, revenue, channel and the rest their own
// columns; they are the same facts under the same names, in the one place a
// caller's own vocabulary belongs.
f.attributes = attributesOf(props, e)
switch r.signal {
case signalError:
f.fault = faultOf(e)
applyFault(&f, e)
case signalLog:
f.record = recordOf(e)
applyRecord(&f, e)
case signalSpan:
f.span = spanOf(e)
if e.Span != nil && trim(e.Span.Kind) != "" {
f.kind = strings.ToLower(trim(e.Span.Kind))
}
case signalMetric:
applySpan(&f, e)
case signalClip:
applyClip(&f, e)
case signalSample:
f.sample = sampleOf(e)
}
return f, true
@@ -339,26 +386,26 @@ const (
nameGroup = "group_identified"
nameLog = "log_record"
nameSpan = "span"
nameClip = "clip"
)
// resolveName picks the stored event name. A tracked product event MUST name itself (an
// unnamed one is unroutable and dropped — the pre-existing rule); every other route has
// a server-chosen default, so a caller that names nothing cannot leave the row unnamed
// resolveName picks the stored event name. A tracked act MUST name itself (an unnamed
// one is unroutable and dropped — the pre-existing rule); every other route has a
// server-chosen default, so a caller that names nothing cannot leave the row unnamed
// and cannot choose what it is called.
//
// AN ERROR IS NAMED `error`, NEVER ITS EXCEPTION CLASS. This branch used to fall back to
// e.Error.Type, and that was a caller string on a function the ANONYMOUS lane reaches:
// `{"type":"error","error":{"type":"…"}}` with no credential wrote 60 KiB of chosen bytes
// into `name`, fifty distinct per request, and on a published-site host into a real org's
// partition — unbounded cardinality in the column the plane orders by, from a caller
// partition — unbounded cardinality in the column the plane indexes, from a caller
// nobody vouched for.
//
// Dropping it costs nothing, which is why the fix belongs here and not in a per-lane
// special case. The class was never this column's fact to hold: it is stored in the
// fault's own `class`, it is the first thing fingerprint() hashes into `group`, and the
// error lens surfaces it from attributes['$exception']. Naming the row after it was a
// THIRD copy of one fact under a third spelling — and it is what kept `name` from being
// the low-cardinality column every signal here treats it as.
// special case. The class was never this column's fact to hold: it is stored in `class`,
// it is the first thing fingerprint() hashes into `issue`, and the error lens surfaces
// it from attributes['$exception']. Naming the row after it was a THIRD copy of one
// fact under a third spelling.
func resolveName(r route, e CaptureEvent) string {
if n := strings.TrimSpace(e.Event); n != "" {
return n
@@ -370,7 +417,9 @@ func resolveName(r route, e CaptureEvent) string {
return nameLog
case signalSpan:
return nameSpan
case signalMetric:
case signalClip:
return nameClip
case signalSample:
if e.Metric != nil {
return trim(e.Metric.Name)
}
@@ -384,9 +433,75 @@ func resolveName(r route, e CaptureEvent) string {
case kindGroup:
return nameGroup
}
return "" // a tracked event with no name is unroutable
return "" // a tracked act with no name is unroutable
}
// ── severity, one spelling ───────────────────────────────────────────────────
//
// OTLP numbers severity 1..24 in bands of four: TRACE 1-4, DEBUG 5-8, INFO 9-12,
// WARN 13-16, ERROR 17-20, FATAL 21-24. The NUMBER is what is stored, because it
// orders, it fits a UInt8 minmax index, and it survives a client that spells the word
// differently. The word is a function of it and is never stored beside it — a row
// carrying both is a row that can contradict itself, which is precisely what
// `severity_text` + `severity_number` + `level` was.
const (
severityTrace = 1
severityDebug = 5
severityInfo = 9
severityWarn = 13
severityError = 17
severityFatal = 21
)
// severityOf resolves the ONE stored number from whatever the caller sent. An explicit
// in-range number wins — it is the precise form. Otherwise the word is mapped by its
// band, and an unrecognized word yields fallback rather than 0, so a signal whose
// severity is meaningful (an error) can never be stored as "unspecified".
func severityOf(text string, number uint8, fallback uint8) uint8 {
if number > 0 && number <= 24 {
return number
}
switch strings.ToLower(strings.TrimSpace(text)) {
case "trace":
return severityTrace
case "debug":
return severityDebug
case "info", "information", "notice", "log":
return severityInfo
case "warn", "warning":
return severityWarn
case "error", "err", "severe":
return severityError
case "fatal", "critical", "crit", "panic", "emergency", "alert":
return severityFatal
}
return fallback
}
// severityText is the read-time inverse: the band's word. It is the only place a
// severity becomes text, so a lens and a log line cannot disagree about what 13 means.
func severityText(n uint8) string {
switch {
case n == 0:
return ""
case n < severityDebug:
return "trace"
case n < severityInfo:
return "debug"
case n < severityWarn:
return "info"
case n < severityError:
return "warn"
case n < severityFatal:
return "error"
default:
return "fatal"
}
}
// ── per-signal bodies ────────────────────────────────────────────────────────
// annotationKeys are the @hanzo/observe AST properties, lifted OUT of the property bag
// into the `el` tuple so they are not stored twice under two spellings.
var annotationKeys = []string{"$el", "$role", "$testid", "$name", "$component", "$path"}
@@ -408,6 +523,22 @@ func annotationOf(props map[string]any) annotation {
}
}
// groupsOf builds the group membership map. The wire carries ONE group today
// (`groupId`), so the map holds one entry — but it is a MAP and not a column because
// the second group type is a data change and not a schema change. That is the whole
// difference between this and the group0..group4 slots it replaces.
func groupsOf(e CaptureEvent) map[string]string {
id := trim(e.GroupID)
if id == "" {
return nil
}
kind := trim(e.GroupType)
if kind == "" {
kind = "organization"
}
return map[string]string{kind: id}
}
// attributesOf flattens the scrubbed property bag plus the wire's own attribution
// fields into the attributes map. Values are strings because the column is
// Map(LowCardinality(String), String); a non-scalar is stored as compact JSON so it is
@@ -439,7 +570,6 @@ func attributesOf(props map[string]any, e CaptureEvent) map[string]string {
set("utm_content", trim(e.UTM.Content))
set("ref_code", trim(e.RefCode))
set("channel", trim(e.Channel))
set("group_id", trim(e.GroupID))
set("signup_week", trim(e.SignupWeek))
set("product_id", trim(e.ProductID))
set("currency", trim(e.Currency))
@@ -463,24 +593,14 @@ func isAnnotationKey(k string) bool {
return false
}
// faultOf builds the error body and computes its grouping fingerprint.
// applyFault fills the error columns and computes the grouping fingerprint.
//
// GROUPING IS COMPUTED HERE, ISSUE LIFECYCLE IS NOT. `group` leads event.error's ORDER
// BY after org, so a row cannot be written without it — it is a pure function of the
// GROUPING IS COMPUTED HERE, ISSUE LIFECYCLE IS NOT. `issue` is a pure function of the
// failure's shape, which makes it enrichment and puts it on the ingest path. What a
// downstream consumer owns is the ISSUE: status, assignee, first_seen, count, keyed
// (org, group). That is the one non-telemetry concept in this plane and it stays
// downstream consumer owns is the ISSUE ROW: status, assignee, first_seen, count, keyed
// (org, issue). That is the one non-telemetry concept in this plane and it stays
// relational; nothing about it belongs in a columnar fact.
func faultOf(e CaptureEvent) *fault {
f := &fault{
site: trim(e.Site),
level: strings.ToLower(trim(e.Level)),
release: trim(e.Release),
environment: trim(e.Environment),
service: trim(e.Service),
trace: trim(e.TraceID),
span: trim(e.SpanID),
}
func applyFault(f *fact, e CaptureEvent) {
// ONE scrub, at the ONE point the exception enters a fact: scrubException copies
// and redacts the free text (a stack frame carries API URLs with query secrets and
// PII as readily as a message does), and everything below reads the copy — so
@@ -493,11 +613,69 @@ func faultOf(e CaptureEvent) *fault {
f.handled = ex.Handled != nil && *ex.Handled
f.frames = framesOf(ex)
}
if f.level == "" {
f.level = "error"
// An error with no stated level IS an error. That is what makes the fallback
// severityError rather than zero: a failure stored as "unspecified" sorts below
// every warning in the one list that exists to surface it.
f.severity = severityOf(e.Level, 0, severityError)
f.issue = fingerprint(f)
}
// applyRecord fills the log columns.
func applyRecord(f *fact, e CaptureEvent) {
f.resource = trim(e.Resource)
if e.Log != nil {
f.message = scrubText(e.Log.Body)
f.severity = severityOf(firstNonEmptyStr(e.Log.Severity, e.Level), e.Log.Number, 0)
return
}
f.severity = severityOf(e.Level, 0, 0)
}
// applySpan fills the span columns. A span with no trace id is still stored: dropping
// an orphan span would hide a real observation, and the fact table is ordered by time
// rather than by trace, so it costs nothing to keep.
func applySpan(f *fact, e CaptureEvent) {
f.resource = trim(e.Resource)
if e.Span == nil {
return
}
f.parent = trim(e.Span.Parent)
f.duration = e.Span.Duration
f.status = strings.ToLower(trim(e.Span.Status))
if k := trim(e.Span.Kind); k != "" {
f.kind = strings.ToLower(k)
}
if f.span == "" {
f.span = trim(e.Span.ID)
}
if f.trace == "" {
f.trace = trim(e.Span.Trace)
}
}
// applyClip fills the two columns a replay clip costs. THE BLOB IS NOT ONE OF THEM:
// `object` is its address in object storage, and the bytes stay there. A multi-megabyte
// binary is wrong for a bus message and wrong for a warehouse row, and the answer to
// "where does the blob go then" is: the same place it already goes.
func applyClip(f *fact, e CaptureEvent) {
if e.Clip == nil {
return
}
f.object = trim(e.Clip.Object)
f.bytes = e.Clip.Bytes
f.duration = e.Clip.Duration
}
// sampleOf builds the measurement body.
func sampleOf(e CaptureEvent) *sample {
if e.Metric == nil {
return &sample{}
}
return &sample{
metric: trim(e.Metric.Name),
value: e.Metric.Value,
labels: strMap(e.Metric.Labels),
}
f.group = fingerprint(f)
return f
}
// fingerprint is the deterministic grouping key: the same failure shape always yields
@@ -505,7 +683,7 @@ func faultOf(e CaptureEvent) *fault {
// two failures thrown from the same line of our code are one issue even when the
// message differs — and falls back to the message with its variable parts removed so
// "user 41 not found" and "user 907 not found" do not become two issues.
func fingerprint(f *fault) string {
func fingerprint(f *fact) string {
h := sha256.New()
_, _ = h.Write([]byte(f.class))
_, _ = h.Write([]byte{0})
@@ -574,53 +752,6 @@ func shape(msg string) string {
return b.String()
}
// recordOf builds the log body.
//
// `resource` stays 0. It is a fingerprint into a resource dimension table, and this
// plane has no such table — writing a hash that nothing can resolve would be a number
// that looks like a join key and is not one. It becomes real when a resource plane
// exists, and nothing else has to change here.
func recordOf(e CaptureEvent) *record {
r := &record{service: trim(e.Service), trace: trim(e.TraceID), span: trim(e.SpanID)}
if e.Log != nil {
r.severity = strings.ToLower(trim(e.Log.Severity))
r.number = e.Log.Number
r.body = scrubText(e.Log.Body)
}
return r
}
// spanOf builds the span body. A span with no trace id is still stored: event.span is
// ordered (org, trace_id, time, id), so it lands in the empty-trace bucket rather than
// being refused — an orphan span is a real observation and dropping it would hide it.
func spanOf(e CaptureEvent) *span {
s := &span{service: trim(e.Service), trace: trim(e.TraceID), id: trim(e.SpanID)}
if e.Span != nil {
s.parent = trim(e.Span.Parent)
s.duration = e.Span.Duration
s.status = strings.ToLower(trim(e.Span.Status))
if s.id == "" {
s.id = trim(e.Span.ID)
}
if s.trace == "" {
s.trace = trim(e.Span.Trace)
}
}
return s
}
// sampleOf builds the metric body.
func sampleOf(e CaptureEvent) *sample {
if e.Metric == nil {
return &sample{}
}
return &sample{
metric: trim(e.Metric.Name),
value: e.Metric.Value,
labels: strMap(e.Metric.Labels),
}
}
// ── stack frames ─────────────────────────────────────────────────────────────
// framesOf returns the fault's frames: the STRUCTURED ones when the client sent them
+1 -10
View File
@@ -14,7 +14,7 @@
// forward.go is the CONSUMER fan-out seam of the canonical event plane. The ONE
// write core (ingestEvents) commits a batch as FACTS — the publish that the sink
// lands in event.event and its sibling signal tables; nothing here writes storage.
// lands in event.fact under its own signal; nothing here writes storage.
// This file used to sit beside a second storage write (the wide hanzo.events INSERT)
// and hand the plane its only copy of each batch; that double-write is gone — the
// fact publish IS the commit — and what remains here are the two SUBSCRIBER
@@ -98,15 +98,6 @@ func fanOut(org string, evs []CaptureEvent) {
live = append(live, fn)
}
}
// The public tenant never fans out. A destination is a connection an ORG made, and
// this sink is handed the RAW pre-scrub event so a Conversions API can hash match
// keys — so forwarding an unattested event would push it into an external platform
// on an org's behalf. publicTenant holds no connection, so the lookup is already
// empty; stating it here makes that a property of the SEAM rather than a property of
// the destination table.
if org == publicTenant {
return
}
now := time.Now()
out := make([]SinkEvent, 0, len(evs))
for _, e := range evs {
-303
View File
@@ -1,303 +0,0 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/sites"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// liveResolver is a sites.Resolver that knows exactly ONE published site — the
// slug key "yadota" and the bound custom host "yadota.tech". Any other key is an
// honest miss (found=false), exactly as the real projects store behaves, so a stray
// external host is NOT mistaken for a bound custom domain.
//
// It answers the TWO lookups a Site can be found by SEPARATELY, because they are two
// different questions and the carve is required to ask the right one:
//
// - unpinned (Resolve) — the bare key: an explicit custom-domain binding, or on the
// multi-tenant apex the unique-live-slug-across-orgs fallback. Whoever owns that
// slug answers.
// - pinned (ResolveOrg) — the slug WITHIN a named org, which is the only lookup
// allowed on our own first-party apex.
//
// Configuring the two with DIFFERENT orgs is the only thing that makes the pin
// observable at all: with one field both lookups returned the same Site, so swapping
// resolveLivePinned for resolveLive changed nothing any test could see.
type liveResolver struct {
pinned string // the org ResolveOrg answers for — the first-party owner
unpinned string // the org Resolve answers for — whoever holds the bare slug
}
func (r liveResolver) Resolve(_ context.Context, key string) (sites.Site, bool, error) {
switch key {
case "yadota", "yadota.tech":
return sites.Site{Org: r.unpinned, Slug: "yadota", Bucket: "b", Prefix: r.unpinned + "/yadota", Status: "live"}, true, nil
default:
return sites.Site{}, false, nil
}
}
// ResolveOrg is the PINNED lookup: the slug within the named org, and nothing else.
// It answers only for r.pinned, so a first-party host can reach exactly one org's
// project — which is the property the pin exists for.
func (r liveResolver) ResolveOrg(_ context.Context, org, slug string) (sites.Site, bool, error) {
if org == r.pinned && slug == "yadota" {
return sites.Site{Org: org, Slug: "yadota", Bucket: "b", Prefix: org + "/yadota", Status: "live"}, true, nil
}
return sites.Site{}, false, nil
}
// siteHosts is the host policy every carve app here shares: the multi-tenant apex
// (where sites are the default) plus our own domains. firstPartyApp adds the opt-in
// first-party apex on top of it.
func siteHosts() sites.Config {
return sites.Config{
Apex: "hanzo.app",
Reserved: []string{"app", "api", "admin"},
SelfDomains: []string{"hanzo.ai", "hanzo.app"},
}
}
// carveOn mounts analytics (which installs the site-host ingest carve via
// sites.SetAnalyticsHost) BEHIND the sites host-router middleware, under a given host
// policy and resolver. Everything downstream of the middleware is identical for both
// configurations below, so the host policy is the only variable under test.
func carveOn(t *testing.T, cfg sites.Config, r sites.Resolver) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
srv := sites.New(cfg, luxlog.New("test"))
app.Use(srv.Middleware())
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
t.Fatalf("Mount: %v", err)
}
stopSink() // see mountApp: a test process holds no live consumer
sites.SetResolver(r)
t.Cleanup(func() {
sites.SetResolver(nil)
sites.SetAnalyticsHost(nil)
})
return app
}
// carveApp is the MULTI-TENANT apex: `<slug>.hanzo.app` and bound custom domains,
// where the bare-key lookup is the correct one, so both of the resolver's answers are
// the site's own org. A POST to the site host is intercepted by the middleware and
// forced to Site.Org; a POST to any other host falls through to the normal
// /v1/event route.
func carveApp(t *testing.T, org string) *zip.App {
t.Helper()
return carveOn(t, siteHosts(), liveResolver{pinned: org, unpinned: org})
}
// ownerOrg / squatterOrg are the two answers the first-party app's resolver gives for
// the SAME slug: the org that owns our first-party sites, and a customer who published
// a project under the same name. On the first-party apex only the first may ever be
// reached.
const (
ownerOrg = "hanzo"
squatterOrg = "squatter"
)
// firstPartyApp is the FIRST-PARTY apex — our own opt-in sites on hanzo.ai — where the
// two lookups disagree: the pin yields ownerOrg and the bare slug yields squatterOrg.
func firstPartyApp(t *testing.T) *zip.App {
t.Helper()
cfg := siteHosts()
cfg.FirstPartyApex = "hanzo.ai"
cfg.FirstPartySites = []string{"yadota"}
cfg.FirstPartyOrg = ownerOrg
return carveOn(t, cfg, liveResolver{pinned: ownerOrg, unpinned: squatterOrg})
}
func postHost(t *testing.T, app *zip.App, host, path, body string, hdr map[string]string) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "http://"+host+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Host = host
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test POST %s%s: %v", host, path, err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
// TestMount_HostCarve_IngestsForSiteOrg is the end-to-end proof: Mount wires the carve,
// and a page's OWN beacon POST to a LIVE site host is ingested for the site's Org even
// though the request carries a forged org (body + X-Org-Id) and NO validated principal.
// The discriminator is 503: the request passed the door and stopped only at the
// datastore-down 503, so the org came from the host and never from the caller/body.
//
// The kinds here are pageview and error, because that is what the carve admits. The
// carve runs BEFORE the identity boundary (serve.go: sites at 241, IdentityMiddleware
// at 267), so nothing on a site host can be vouched for and every beacon takes the
// ANONYMOUS lane — see TestMount_HostCarve_AnonymousCapabilityOnly for the other half.
func TestMount_HostCarve_IngestsForSiteOrg(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := carveApp(t, "hanzo")
// Canonical wire on /v1/event.
if code := postHost(t, app, "yadota.hanzo.app", canonDoor,
`{"batch":[{"type":"pageview","path":"/pricing"}],"org":"attacker","properties":{"space":"attacker"}}`,
map[string]string{"X-Org-Id": "attacker"}); code != http.StatusServiceUnavailable {
t.Fatalf("beacon POST %s want 503 (ingested for the site org, datastore down), got %d", canonDoor, code)
}
// PostHog wire on the ONE door /v1/event (decodeEvent falls back to the PostHog decoder).
code := postHost(t, app, "yadota.hanzo.app", "/v1/event",
`{"event":"$pageview","distinct_id":"d","properties":{"space":"attacker"}}`,
map[string]string{"X-Org-Id": "attacker"})
if code != http.StatusServiceUnavailable {
t.Fatalf("insights beacon want 503 (ingested for the site org), got %d", code)
}
}
// TestMount_HostCarve_FirstPartyHostResolvesPinned is the pin, and it is asserted on
// the ROW because that is the only place the pin is visible.
//
// On our own apex a slug must resolve WITHIN our org (ResolveOrg over FirstPartyOrg),
// never by the unique-live-slug-across-orgs fallback. Resolve unpinned and a customer
// who published a project named `yadota` answers for `yadota.hanzo.ai`: their Site.Org
// becomes the tenant, so our first-party pages' beacons land in THEIR partition —
// readable by them, missing from ours. That is a cross-tenant attribution flip bought
// with nothing but a project name, and every status code on both sides of it is 200.
//
// Every OTHER test in this file runs on the multi-tenant apex, where firstParty is
// false and resolveLivePinned delegates straight to resolveLive — so before this test
// liveResolver.ResolveOrg was never called by this package at all (an unconditional
// panic in it left the whole suite green), and all three of the carve's
// resolveLivePinned call sites could be swapped to resolveLive with nothing going red.
func TestMount_HostCarve_FirstPartyHostResolvesPinned(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
w := fakeWarehouse(t)
app := firstPartyApp(t)
if code := postHost(t, app, "yadota.hanzo.ai", "/v1/event", canonPageview,
map[string]string{"X-Org-Id": "attacker"}); code != http.StatusOK {
t.Fatalf("first-party site beacon = %d, want 200 (carved and written)", code)
}
got := w.tenants(t)
if len(got) != 1 || got[0] != ownerOrg {
t.Fatalf("first-party host wrote tenants %v, want [%s]", got, ownerOrg)
}
if got[0] == squatterOrg {
t.Errorf("the first-party host resolved UNPINNED: a customer's same-named project "+
"answered for %s and now owns our beacons", "yadota.hanzo.ai")
}
}
// TestMount_HostCarve_AnonymousCapabilityOnly is the other half, and the fix: the carve
// authorizes a TENANT from the host, never a CAPABILITY. It used to call the
// full-capability core with zero credential, so the same Host header that made a beacon
// land in a site's org also let a stranger write a custom event name, revenue, personId
// and groupId there. Now a credential-less beacon — which on a site host is every
// beacon — gets the anonymous projection, so a non-allowlisted kind is refused storage
// and reported in the honest receipt.
func TestMount_HostCarve_AnonymousCapabilityOnly(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := carveApp(t, "hanzo")
code := postHost(t, app, "yadota.hanzo.app", canonDoor,
`{"batch":[{"type":"event","event":"signup_completed","revenue":999,"groupId":"victim"}]}`,
map[string]string{"X-Org-Id": "attacker"})
if code != http.StatusOK {
t.Fatalf("site-host custom event on %s want 200 (all-dropped, never stored), got %d", canonDoor, code)
}
}
// TestMount_HostCarve_EmptyBatchOK: an empty beacon batch on the site host is an
// honest 200 (zero counts) BEFORE the datastore is consulted — proving the carve
// decodes and funnels through the ONE write core without any principal.
func TestMount_HostCarve_EmptyBatchOK(t *testing.T) {
app := carveApp(t, "hanzo")
if code := postHost(t, app, "yadota.hanzo.app", canonDoor, `{"batch":[]}`, nil); code != http.StatusOK {
t.Fatalf("empty beacon batch want 200, got %d", code)
}
}
// TestMount_HostCarve_CustomDomainCarves: the carve fires for a bound custom domain
// too — REACHABILITY, which is all a status code can show. It does not prove WHOSE org
// the beacon was filed under, and it used to be named as though it did.
//
// That fact is pinned where it is decided: sites.Middleware resolves the host, and
// clients/sites' TestMiddlewareAnalyticsCarveCustomDomain asserts the org handed to
// the carve handler is the resolved Site's and that the resolver saw the full host.
// Everything after that argument — publicIngest → the write core → tenant_id — is the
// same code for both host shapes and is pinned end-to-end on the slug host by
// TestSiteHostLaneWritesTheResolvedSiteOrg, so asserting the row again here would be a
// second place answering one question.
func TestMount_HostCarve_CustomDomainCarves(t *testing.T) {
app := carveApp(t, "yadota")
code := postHost(t, app, "yadota.tech", canonDoor,
`{"batch":[{"type":"pageview"}]}`, map[string]string{"X-Org-Id": "attacker"})
if code != http.StatusServiceUnavailable {
t.Fatalf("custom-domain beacon want 503 (ingested as site org), got %d", code)
}
}
// TestMount_HostCarve_GetNotHijacked: a GET on the site host is NOT ingest — it is
// served as static (storage unconfigured here ⇒ 503 from the serve path), never
// routed to the ingest carve; the read-lens surface is untouched.
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)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer func() { _ = resp.Body.Close() }()
// It must have reached the static serve, tagged X-Hanzo-Site — not the ingest
// carve (which would 200 the empty body) and not the API pipeline.
if resp.Header.Get("X-Hanzo-Site") != "yadota" {
t.Fatalf("GET did not reach the static serve (X-Hanzo-Site=%q, status=%d)", resp.Header.Get("X-Hanzo-Site"), resp.StatusCode)
}
}
// TestMount_HostCarve_NonSiteHostUsesNormalGate: on a NON-site host the middleware
// Continues and the normal /v1/event route runs — the carve did not fire, so the
// beacon gets the normal door's anonymous lane (the reserved public tenant) rather than
// any site's org. A pageview is admitted there (503) and a custom event is dropped, so
// the host-scoped carve neither leaks a site org off-host nor weakens the normal gate.
func TestMount_HostCarve_NonSiteHostUsesNormalGate(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := carveApp(t, "hanzo")
if code := postHost(t, app, "evil.example.com", canonDoor,
`{"batch":[{"type":"pageview"}]}`, map[string]string{"X-Org-Id": "attacker"}); code != http.StatusServiceUnavailable {
t.Fatalf("anonymous unknown-host beacon want 503 (normal door's anonymous lane), got %d", code)
}
if code := postHost(t, app, "evil.example.com", canonDoor,
`{"batch":[{"type":"event","event":"order_completed","revenue":99}]}`,
map[string]string{"X-Org-Id": "attacker"}); code != http.StatusOK {
t.Fatalf("anonymous unknown-host commerce want 200 all-dropped, got %d", code)
}
}
// TestMount_HostCarve_DisabledWhenPublicCaptureOff: with public capture off the
// carve is NOT installed, so a beacon POST to the site host falls to the static
// serve and 405s (unchanged from before the fix).
func TestMount_HostCarve_DisabledWhenPublicCaptureOff(t *testing.T) {
t.Setenv(publicCaptureEnv, "off")
app := carveApp(t, "hanzo")
code := postHost(t, app, "yadota.hanzo.app", canonDoor,
`{"batch":[{"type":"pageview"}]}`, nil)
if code != http.StatusMethodNotAllowed {
t.Fatalf("public-capture-off site beacon want 405 (carve not installed), got %d", code)
}
}

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